index.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
  1. import {
  2. TextAttributes,
  3. BoxRenderable,
  4. TextareaRenderable,
  5. MouseEvent,
  6. PasteEvent,
  7. t,
  8. dim,
  9. fg,
  10. type KeyBinding,
  11. } from "@opentui/core"
  12. import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
  13. import { useLocal } from "@tui/context/local"
  14. import { useTheme } from "@tui/context/theme"
  15. import { SplitBorder } from "@tui/component/border"
  16. import { useSDK } from "@tui/context/sdk"
  17. import { useRoute } from "@tui/context/route"
  18. import { useSync } from "@tui/context/sync"
  19. import { Identifier } from "@/id/id"
  20. import { createStore, produce } from "solid-js/store"
  21. import { useKeybind } from "@tui/context/keybind"
  22. import { usePromptHistory, type PromptInfo } from "./history"
  23. import { type AutocompleteRef, Autocomplete } from "./autocomplete"
  24. import { useCommandDialog } from "../dialog-command"
  25. import { useRenderer } from "@opentui/solid"
  26. import { Editor } from "@tui/util/editor"
  27. import { useExit } from "../../context/exit"
  28. import { Clipboard } from "../../util/clipboard"
  29. import type { FilePart } from "@opencode-ai/sdk"
  30. import { TuiEvent } from "../../event"
  31. import { iife } from "@/util/iife"
  32. export type PromptProps = {
  33. sessionID?: string
  34. disabled?: boolean
  35. onSubmit?: () => void
  36. ref?: (ref: PromptRef) => void
  37. hint?: JSX.Element
  38. showPlaceholder?: boolean
  39. }
  40. export type PromptRef = {
  41. focused: boolean
  42. set(prompt: PromptInfo): void
  43. reset(): void
  44. blur(): void
  45. focus(): void
  46. }
  47. export function Prompt(props: PromptProps) {
  48. let input: TextareaRenderable
  49. let anchor: BoxRenderable
  50. let autocomplete: AutocompleteRef
  51. const keybind = useKeybind()
  52. const local = useLocal()
  53. const sdk = useSDK()
  54. const route = useRoute()
  55. const sync = useSync()
  56. const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
  57. const history = usePromptHistory()
  58. const command = useCommandDialog()
  59. const renderer = useRenderer()
  60. const { theme, syntax } = useTheme()
  61. const textareaKeybindings = createMemo(() => {
  62. const newlineBindings = keybind.all.input_newline || []
  63. const submitBindings = keybind.all.input_submit || []
  64. return [
  65. { name: "return", action: "submit" },
  66. { name: "return", meta: true, action: "newline" },
  67. ...newlineBindings.map((binding) => ({
  68. name: binding.name,
  69. ctrl: binding.ctrl || undefined,
  70. meta: binding.meta || undefined,
  71. shift: binding.shift || undefined,
  72. action: "newline" as const,
  73. })),
  74. ...submitBindings.map((binding) => ({
  75. name: binding.name,
  76. ctrl: binding.ctrl || undefined,
  77. meta: binding.meta || undefined,
  78. shift: binding.shift || undefined,
  79. action: "submit" as const,
  80. })),
  81. ] satisfies KeyBinding[]
  82. })
  83. const fileStyleId = syntax().getStyleId("extmark.file")!
  84. const agentStyleId = syntax().getStyleId("extmark.agent")!
  85. const pasteStyleId = syntax().getStyleId("extmark.paste")!
  86. let promptPartTypeId: number
  87. command.register(() => {
  88. return [
  89. {
  90. title: "Open editor",
  91. category: "Session",
  92. keybind: "editor_open",
  93. value: "prompt.editor",
  94. onSelect: async (dialog, trigger) => {
  95. dialog.clear()
  96. // replace summarized text parts with the actual text
  97. const text = store.prompt.parts
  98. .filter((p) => p.type === "text")
  99. .reduce((acc, p) => {
  100. if (!p.source) return acc
  101. return acc.replace(p.source.text.value, p.text)
  102. }, store.prompt.input)
  103. const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
  104. const value = trigger === "prompt" ? "" : text
  105. const content = await Editor.open({ value, renderer })
  106. if (!content) return
  107. input.setText(content, { history: false })
  108. // Update positions for nonTextParts based on their location in new content
  109. // Filter out parts whose virtual text was deleted
  110. // this handles a case where the user edits the text in the editor
  111. // such that the virtual text moves around or is deleted
  112. const updatedNonTextParts = nonTextParts
  113. .map((part) => {
  114. let virtualText = ""
  115. if (part.type === "file" && part.source?.text) {
  116. virtualText = part.source.text.value
  117. } else if (part.type === "agent" && part.source) {
  118. virtualText = part.source.value
  119. }
  120. if (!virtualText) return part
  121. const newStart = content.indexOf(virtualText)
  122. // if the virtual text is deleted, remove the part
  123. if (newStart === -1) return null
  124. const newEnd = newStart + virtualText.length
  125. if (part.type === "file" && part.source?.text) {
  126. return {
  127. ...part,
  128. source: {
  129. ...part.source,
  130. text: {
  131. ...part.source.text,
  132. start: newStart,
  133. end: newEnd,
  134. },
  135. },
  136. }
  137. }
  138. if (part.type === "agent" && part.source) {
  139. return {
  140. ...part,
  141. source: {
  142. ...part.source,
  143. start: newStart,
  144. end: newEnd,
  145. },
  146. }
  147. }
  148. return part
  149. })
  150. .filter((part) => part !== null)
  151. setStore("prompt", {
  152. input: content,
  153. // keep only the non-text parts because the text parts were
  154. // already expanded inline
  155. parts: updatedNonTextParts,
  156. })
  157. restoreExtmarksFromParts(updatedNonTextParts)
  158. input.cursorOffset = Bun.stringWidth(content)
  159. },
  160. },
  161. {
  162. title: "Clear prompt",
  163. value: "prompt.clear",
  164. category: "Prompt",
  165. disabled: true,
  166. onSelect: (dialog) => {
  167. input.extmarks.clear()
  168. input.clear()
  169. dialog.clear()
  170. },
  171. },
  172. {
  173. title: "Submit prompt",
  174. value: "prompt.submit",
  175. disabled: true,
  176. keybind: "input_submit",
  177. category: "Prompt",
  178. onSelect: (dialog) => {
  179. if (!input.focused) return
  180. submit()
  181. dialog.clear()
  182. },
  183. },
  184. {
  185. title: "Paste",
  186. value: "prompt.paste",
  187. disabled: true,
  188. keybind: "input_paste",
  189. category: "Prompt",
  190. onSelect: async () => {
  191. const content = await Clipboard.read()
  192. if (content?.mime.startsWith("image/")) {
  193. await pasteImage({
  194. filename: "clipboard",
  195. mime: content.mime,
  196. content: content.data,
  197. })
  198. }
  199. },
  200. },
  201. {
  202. title: "Interrupt session",
  203. value: "session.interrupt",
  204. keybind: "session_interrupt",
  205. disabled: status() !== "working",
  206. category: "Session",
  207. onSelect: (dialog) => {
  208. if (!props.sessionID) return
  209. if (autocomplete.visible) return
  210. if (!input.focused) return
  211. setStore("interrupt", store.interrupt + 1)
  212. setTimeout(() => {
  213. setStore("interrupt", 0)
  214. }, 5000)
  215. if (store.interrupt >= 2) {
  216. sdk.client.session.abort({
  217. path: {
  218. id: props.sessionID,
  219. },
  220. })
  221. setStore("interrupt", 0)
  222. }
  223. dialog.clear()
  224. },
  225. },
  226. ]
  227. })
  228. sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
  229. input.insertText(evt.properties.text)
  230. })
  231. createEffect(() => {
  232. if (props.disabled) input.cursorColor = theme.backgroundElement
  233. if (!props.disabled) input.cursorColor = theme.primary
  234. })
  235. const [store, setStore] = createStore<{
  236. prompt: PromptInfo
  237. mode: "normal" | "shell"
  238. extmarkToPartIndex: Map<number, number>
  239. interrupt: number
  240. }>({
  241. prompt: {
  242. input: "",
  243. parts: [],
  244. },
  245. mode: "normal",
  246. extmarkToPartIndex: new Map(),
  247. interrupt: 0,
  248. })
  249. createEffect(() => {
  250. input.focus()
  251. })
  252. onMount(() => {
  253. promptPartTypeId = input.extmarks.registerType("prompt-part")
  254. })
  255. function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
  256. input.extmarks.clear()
  257. setStore("extmarkToPartIndex", new Map())
  258. parts.forEach((part, partIndex) => {
  259. let start = 0
  260. let end = 0
  261. let virtualText = ""
  262. let styleId: number | undefined
  263. if (part.type === "file" && part.source?.text) {
  264. start = part.source.text.start
  265. end = part.source.text.end
  266. virtualText = part.source.text.value
  267. styleId = fileStyleId
  268. } else if (part.type === "agent" && part.source) {
  269. start = part.source.start
  270. end = part.source.end
  271. virtualText = part.source.value
  272. styleId = agentStyleId
  273. } else if (part.type === "text" && part.source?.text) {
  274. start = part.source.text.start
  275. end = part.source.text.end
  276. virtualText = part.source.text.value
  277. styleId = pasteStyleId
  278. }
  279. if (virtualText) {
  280. const extmarkId = input.extmarks.create({
  281. start,
  282. end,
  283. virtual: true,
  284. styleId,
  285. typeId: promptPartTypeId,
  286. })
  287. setStore("extmarkToPartIndex", (map: Map<number, number>) => {
  288. const newMap = new Map(map)
  289. newMap.set(extmarkId, partIndex)
  290. return newMap
  291. })
  292. }
  293. })
  294. }
  295. function syncExtmarksWithPromptParts() {
  296. const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
  297. setStore(
  298. produce((draft) => {
  299. const newMap = new Map<number, number>()
  300. const newParts: typeof draft.prompt.parts = []
  301. for (const extmark of allExtmarks) {
  302. const partIndex = draft.extmarkToPartIndex.get(extmark.id)
  303. if (partIndex !== undefined) {
  304. const part = draft.prompt.parts[partIndex]
  305. if (part) {
  306. if (part.type === "agent" && part.source) {
  307. part.source.start = extmark.start
  308. part.source.end = extmark.end
  309. } else if (part.type === "file" && part.source?.text) {
  310. part.source.text.start = extmark.start
  311. part.source.text.end = extmark.end
  312. } else if (part.type === "text" && part.source?.text) {
  313. part.source.text.start = extmark.start
  314. part.source.text.end = extmark.end
  315. }
  316. newMap.set(extmark.id, newParts.length)
  317. newParts.push(part)
  318. }
  319. }
  320. }
  321. draft.extmarkToPartIndex = newMap
  322. draft.prompt.parts = newParts
  323. }),
  324. )
  325. }
  326. props.ref?.({
  327. get focused() {
  328. return input.focused
  329. },
  330. focus() {
  331. input.focus()
  332. },
  333. blur() {
  334. input.blur()
  335. },
  336. set(prompt) {
  337. input.setText(prompt.input, { history: false })
  338. setStore("prompt", prompt)
  339. restoreExtmarksFromParts(prompt.parts)
  340. input.gotoBufferEnd()
  341. },
  342. reset() {
  343. input.clear()
  344. input.extmarks.clear()
  345. setStore("prompt", {
  346. input: "",
  347. parts: [],
  348. })
  349. setStore("extmarkToPartIndex", new Map())
  350. },
  351. })
  352. async function submit() {
  353. if (props.disabled) return
  354. if (autocomplete.visible) return
  355. if (!store.prompt.input) return
  356. const sessionID = props.sessionID
  357. ? props.sessionID
  358. : await (async () => {
  359. const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
  360. return sessionID
  361. })()
  362. const messageID = Identifier.ascending("message")
  363. let inputText = store.prompt.input
  364. // Expand pasted text inline before submitting
  365. const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
  366. const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
  367. for (const extmark of sortedExtmarks) {
  368. const partIndex = store.extmarkToPartIndex.get(extmark.id)
  369. if (partIndex !== undefined) {
  370. const part = store.prompt.parts[partIndex]
  371. if (part?.type === "text" && part.text) {
  372. const before = inputText.slice(0, extmark.start)
  373. const after = inputText.slice(extmark.end)
  374. inputText = before + part.text + after
  375. }
  376. }
  377. }
  378. // Filter out text parts (pasted content) since they're now expanded inline
  379. const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
  380. if (store.mode === "shell") {
  381. sdk.client.session.shell({
  382. path: {
  383. id: sessionID,
  384. },
  385. body: {
  386. agent: local.agent.current().name,
  387. model: {
  388. providerID: local.model.current().providerID,
  389. modelID: local.model.current().modelID,
  390. },
  391. command: inputText,
  392. },
  393. })
  394. setStore("mode", "normal")
  395. } else if (
  396. inputText.startsWith("/") &&
  397. iife(() => {
  398. const command = inputText.split(" ")[0].slice(1)
  399. console.log(command)
  400. return sync.data.command.some((x) => x.name === command)
  401. })
  402. ) {
  403. let [command, ...args] = inputText.split(" ")
  404. sdk.client.session.command({
  405. path: {
  406. id: sessionID,
  407. },
  408. body: {
  409. command: command.slice(1),
  410. arguments: args.join(" "),
  411. agent: local.agent.current().name,
  412. model: `${local.model.current().providerID}/${local.model.current().modelID}`,
  413. messageID,
  414. },
  415. })
  416. } else {
  417. sdk.client.session.prompt({
  418. path: {
  419. id: sessionID,
  420. },
  421. body: {
  422. ...local.model.current(),
  423. messageID,
  424. agent: local.agent.current().name,
  425. model: local.model.current(),
  426. parts: [
  427. {
  428. id: Identifier.ascending("part"),
  429. type: "text",
  430. text: inputText,
  431. },
  432. ...nonTextParts.map((x) => ({
  433. id: Identifier.ascending("part"),
  434. ...x,
  435. })),
  436. ],
  437. },
  438. })
  439. }
  440. history.append(store.prompt)
  441. input.extmarks.clear()
  442. setStore("prompt", {
  443. input: "",
  444. parts: [],
  445. })
  446. setStore("extmarkToPartIndex", new Map())
  447. props.onSubmit?.()
  448. // temporary hack to make sure the message is sent
  449. if (!props.sessionID)
  450. setTimeout(() => {
  451. route.navigate({
  452. type: "session",
  453. sessionID,
  454. })
  455. }, 50)
  456. input.clear()
  457. }
  458. const exit = useExit()
  459. async function pasteImage(file: { filename?: string; content: string; mime: string }) {
  460. const currentOffset = input.visualCursor.offset
  461. const extmarkStart = currentOffset
  462. const count = store.prompt.parts.filter((x) => x.type === "file").length
  463. const virtualText = `[Image ${count + 1}]`
  464. const extmarkEnd = extmarkStart + virtualText.length
  465. const textToInsert = virtualText + " "
  466. input.insertText(textToInsert)
  467. const extmarkId = input.extmarks.create({
  468. start: extmarkStart,
  469. end: extmarkEnd,
  470. virtual: true,
  471. styleId: pasteStyleId,
  472. typeId: promptPartTypeId,
  473. })
  474. const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
  475. type: "file" as const,
  476. mime: file.mime,
  477. filename: file.filename,
  478. url: `data:${file.mime};base64,${file.content}`,
  479. source: {
  480. type: "file",
  481. path: file.filename ?? "",
  482. text: {
  483. start: extmarkStart,
  484. end: extmarkEnd,
  485. value: virtualText,
  486. },
  487. },
  488. }
  489. setStore(
  490. produce((draft) => {
  491. const partIndex = draft.prompt.parts.length
  492. draft.prompt.parts.push(part)
  493. draft.extmarkToPartIndex.set(extmarkId, partIndex)
  494. }),
  495. )
  496. return
  497. }
  498. return (
  499. <>
  500. <Autocomplete
  501. sessionID={props.sessionID}
  502. ref={(r) => (autocomplete = r)}
  503. anchor={() => anchor}
  504. input={() => input}
  505. setPrompt={(cb) => {
  506. setStore("prompt", produce(cb))
  507. }}
  508. setExtmark={(partIndex, extmarkId) => {
  509. setStore("extmarkToPartIndex", (map: Map<number, number>) => {
  510. const newMap = new Map(map)
  511. newMap.set(extmarkId, partIndex)
  512. return newMap
  513. })
  514. }}
  515. value={store.prompt.input}
  516. fileStyleId={fileStyleId}
  517. agentStyleId={agentStyleId}
  518. promptPartTypeId={() => promptPartTypeId}
  519. />
  520. <box ref={(r) => (anchor = r)}>
  521. <box
  522. flexDirection="row"
  523. {...SplitBorder}
  524. borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
  525. justifyContent="space-evenly"
  526. >
  527. <box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
  528. <text attributes={TextAttributes.BOLD} fg={theme.primary}>
  529. {store.mode === "normal" ? ">" : "!"}
  530. </text>
  531. </box>
  532. <box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
  533. <textarea
  534. placeholder={
  535. props.showPlaceholder
  536. ? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
  537. : undefined
  538. }
  539. textColor={theme.text}
  540. focusedTextColor={theme.text}
  541. minHeight={1}
  542. maxHeight={6}
  543. onContentChange={() => {
  544. const value = input.plainText
  545. setStore("prompt", "input", value)
  546. autocomplete.onInput(value)
  547. syncExtmarksWithPromptParts()
  548. }}
  549. keyBindings={textareaKeybindings()}
  550. onKeyDown={async (e) => {
  551. if (props.disabled) {
  552. e.preventDefault()
  553. return
  554. }
  555. if (keybind.match("input_clear", e) && store.prompt.input !== "") {
  556. input.clear()
  557. input.extmarks.clear()
  558. setStore("prompt", {
  559. input: "",
  560. parts: [],
  561. })
  562. setStore("extmarkToPartIndex", new Map())
  563. return
  564. }
  565. if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
  566. const cursorOffset = input.cursorOffset
  567. if (cursorOffset < input.plainText.length) {
  568. const text = input.plainText
  569. const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
  570. input.setText(newText)
  571. input.cursorOffset = cursorOffset
  572. }
  573. e.preventDefault()
  574. return
  575. }
  576. if (keybind.match("app_exit", e)) {
  577. await exit()
  578. return
  579. }
  580. if (e.name === "!" && input.visualCursor.offset === 0) {
  581. setStore("mode", "shell")
  582. e.preventDefault()
  583. return
  584. }
  585. if (store.mode === "shell") {
  586. if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
  587. setStore("mode", "normal")
  588. e.preventDefault()
  589. return
  590. }
  591. }
  592. if (store.mode === "normal") autocomplete.onKeyDown(e)
  593. if (!autocomplete.visible) {
  594. if (
  595. (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
  596. (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
  597. ) {
  598. const direction = keybind.match("history_previous", e) ? -1 : 1
  599. const item = history.move(direction, input.plainText)
  600. if (item) {
  601. input.setText(item.input, { history: false })
  602. setStore("prompt", item)
  603. restoreExtmarksFromParts(item.parts)
  604. e.preventDefault()
  605. if (direction === -1) input.cursorOffset = 0
  606. if (direction === 1) input.cursorOffset = input.plainText.length
  607. }
  608. return
  609. }
  610. if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
  611. if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
  612. input.cursorOffset = input.plainText.length
  613. }
  614. }}
  615. onSubmit={submit}
  616. onPaste={async (event: PasteEvent) => {
  617. if (props.disabled) {
  618. event.preventDefault()
  619. return
  620. }
  621. // Normalize line endings at the boundary
  622. // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
  623. // Replace CRLF first, then any remaining CR
  624. const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
  625. const pastedContent = normalizedText.trim()
  626. if (!pastedContent) {
  627. command.trigger("prompt.paste")
  628. return
  629. }
  630. // trim ' from the beginning and end of the pasted content. just
  631. // ' and nothing else
  632. const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
  633. console.log(pastedContent, filepath)
  634. try {
  635. const file = Bun.file(filepath)
  636. if (file.type.startsWith("image/")) {
  637. event.preventDefault()
  638. const content = await file
  639. .arrayBuffer()
  640. .then((buffer) => Buffer.from(buffer).toString("base64"))
  641. .catch(console.error)
  642. if (content) {
  643. await pasteImage({
  644. filename: file.name,
  645. mime: file.type,
  646. content,
  647. })
  648. return
  649. }
  650. }
  651. } catch {}
  652. const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
  653. if (
  654. (lineCount >= 3 || pastedContent.length > 150) &&
  655. !sync.data.config.experimental?.disable_paste_summary
  656. ) {
  657. event.preventDefault()
  658. const currentOffset = input.visualCursor.offset
  659. const virtualText = `[Pasted ~${lineCount} lines]`
  660. const textToInsert = virtualText + " "
  661. const extmarkStart = currentOffset
  662. const extmarkEnd = extmarkStart + virtualText.length
  663. input.insertText(textToInsert)
  664. const extmarkId = input.extmarks.create({
  665. start: extmarkStart,
  666. end: extmarkEnd,
  667. virtual: true,
  668. styleId: pasteStyleId,
  669. typeId: promptPartTypeId,
  670. })
  671. const part = {
  672. type: "text" as const,
  673. text: pastedContent,
  674. source: {
  675. text: {
  676. start: extmarkStart,
  677. end: extmarkEnd,
  678. value: virtualText,
  679. },
  680. },
  681. }
  682. setStore(
  683. produce((draft) => {
  684. const partIndex = draft.prompt.parts.length
  685. draft.prompt.parts.push(part)
  686. draft.extmarkToPartIndex.set(extmarkId, partIndex)
  687. }),
  688. )
  689. return
  690. }
  691. }}
  692. ref={(r: TextareaRenderable) => (input = r)}
  693. onMouseDown={(r: MouseEvent) => r.target?.focus()}
  694. focusedBackgroundColor={theme.backgroundElement}
  695. cursorColor={theme.primary}
  696. syntaxStyle={syntax()}
  697. />
  698. </box>
  699. <box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
  700. </box>
  701. <box flexDirection="row" justifyContent="space-between">
  702. <text flexShrink={0} wrapMode="none" fg={theme.text}>
  703. <span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
  704. <span style={{ bold: true }}>{local.model.parsed().model}</span>
  705. </text>
  706. <Switch>
  707. <Match when={status() === "compacting"}>
  708. <text fg={theme.textMuted}>compacting...</text>
  709. </Match>
  710. <Match when={status() === "working"}>
  711. <box flexDirection="row" gap={1}>
  712. <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
  713. esc{" "}
  714. <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
  715. {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
  716. </span>
  717. </text>
  718. </box>
  719. </Match>
  720. <Match when={props.hint}>{props.hint!}</Match>
  721. <Match when={true}>
  722. <text fg={theme.text}>
  723. {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
  724. </text>
  725. </Match>
  726. </Switch>
  727. </box>
  728. </box>
  729. </>
  730. )
  731. }