| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787 |
- import {
- TextAttributes,
- BoxRenderable,
- TextareaRenderable,
- MouseEvent,
- PasteEvent,
- t,
- dim,
- fg,
- type KeyBinding,
- } from "@opentui/core"
- import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
- import { useLocal } from "@tui/context/local"
- import { useTheme } from "@tui/context/theme"
- import { SplitBorder } from "@tui/component/border"
- import { useSDK } from "@tui/context/sdk"
- import { useRoute } from "@tui/context/route"
- import { useSync } from "@tui/context/sync"
- import { Identifier } from "@/id/id"
- import { createStore, produce } from "solid-js/store"
- import { useKeybind } from "@tui/context/keybind"
- import { usePromptHistory, type PromptInfo } from "./history"
- import { type AutocompleteRef, Autocomplete } from "./autocomplete"
- import { useCommandDialog } from "../dialog-command"
- import { useRenderer } from "@opentui/solid"
- import { Editor } from "@tui/util/editor"
- import { useExit } from "../../context/exit"
- import { Clipboard } from "../../util/clipboard"
- import type { FilePart } from "@opencode-ai/sdk"
- import { TuiEvent } from "../../event"
- import { iife } from "@/util/iife"
- export type PromptProps = {
- sessionID?: string
- disabled?: boolean
- onSubmit?: () => void
- ref?: (ref: PromptRef) => void
- hint?: JSX.Element
- showPlaceholder?: boolean
- }
- export type PromptRef = {
- focused: boolean
- set(prompt: PromptInfo): void
- reset(): void
- blur(): void
- focus(): void
- }
- export function Prompt(props: PromptProps) {
- let input: TextareaRenderable
- let anchor: BoxRenderable
- let autocomplete: AutocompleteRef
- const keybind = useKeybind()
- const local = useLocal()
- const sdk = useSDK()
- const route = useRoute()
- const sync = useSync()
- const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
- const history = usePromptHistory()
- const command = useCommandDialog()
- const renderer = useRenderer()
- const { theme, syntax } = useTheme()
- const textareaKeybindings = createMemo(() => {
- const newlineBindings = keybind.all.input_newline || []
- const submitBindings = keybind.all.input_submit || []
- return [
- { name: "return", action: "submit" },
- { name: "return", meta: true, action: "newline" },
- ...newlineBindings.map((binding) => ({
- name: binding.name,
- ctrl: binding.ctrl || undefined,
- meta: binding.meta || undefined,
- shift: binding.shift || undefined,
- action: "newline" as const,
- })),
- ...submitBindings.map((binding) => ({
- name: binding.name,
- ctrl: binding.ctrl || undefined,
- meta: binding.meta || undefined,
- shift: binding.shift || undefined,
- action: "submit" as const,
- })),
- ] satisfies KeyBinding[]
- })
- const fileStyleId = syntax().getStyleId("extmark.file")!
- const agentStyleId = syntax().getStyleId("extmark.agent")!
- const pasteStyleId = syntax().getStyleId("extmark.paste")!
- let promptPartTypeId: number
- command.register(() => {
- return [
- {
- title: "Open editor",
- category: "Session",
- keybind: "editor_open",
- value: "prompt.editor",
- onSelect: async (dialog, trigger) => {
- dialog.clear()
- // replace summarized text parts with the actual text
- const text = store.prompt.parts
- .filter((p) => p.type === "text")
- .reduce((acc, p) => {
- if (!p.source) return acc
- return acc.replace(p.source.text.value, p.text)
- }, store.prompt.input)
- const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
- const value = trigger === "prompt" ? "" : text
- const content = await Editor.open({ value, renderer })
- if (!content) return
- input.setText(content, { history: false })
- // Update positions for nonTextParts based on their location in new content
- // Filter out parts whose virtual text was deleted
- // this handles a case where the user edits the text in the editor
- // such that the virtual text moves around or is deleted
- const updatedNonTextParts = nonTextParts
- .map((part) => {
- let virtualText = ""
- if (part.type === "file" && part.source?.text) {
- virtualText = part.source.text.value
- } else if (part.type === "agent" && part.source) {
- virtualText = part.source.value
- }
- if (!virtualText) return part
- const newStart = content.indexOf(virtualText)
- // if the virtual text is deleted, remove the part
- if (newStart === -1) return null
- const newEnd = newStart + virtualText.length
- if (part.type === "file" && part.source?.text) {
- return {
- ...part,
- source: {
- ...part.source,
- text: {
- ...part.source.text,
- start: newStart,
- end: newEnd,
- },
- },
- }
- }
- if (part.type === "agent" && part.source) {
- return {
- ...part,
- source: {
- ...part.source,
- start: newStart,
- end: newEnd,
- },
- }
- }
- return part
- })
- .filter((part) => part !== null)
- setStore("prompt", {
- input: content,
- // keep only the non-text parts because the text parts were
- // already expanded inline
- parts: updatedNonTextParts,
- })
- restoreExtmarksFromParts(updatedNonTextParts)
- input.cursorOffset = Bun.stringWidth(content)
- },
- },
- {
- title: "Clear prompt",
- value: "prompt.clear",
- category: "Prompt",
- disabled: true,
- onSelect: (dialog) => {
- input.extmarks.clear()
- input.clear()
- dialog.clear()
- },
- },
- {
- title: "Submit prompt",
- value: "prompt.submit",
- disabled: true,
- keybind: "input_submit",
- category: "Prompt",
- onSelect: (dialog) => {
- if (!input.focused) return
- submit()
- dialog.clear()
- },
- },
- {
- title: "Paste",
- value: "prompt.paste",
- disabled: true,
- keybind: "input_paste",
- category: "Prompt",
- onSelect: async () => {
- const content = await Clipboard.read()
- if (content?.mime.startsWith("image/")) {
- await pasteImage({
- filename: "clipboard",
- mime: content.mime,
- content: content.data,
- })
- }
- },
- },
- {
- title: "Interrupt session",
- value: "session.interrupt",
- keybind: "session_interrupt",
- disabled: status() !== "working",
- category: "Session",
- onSelect: (dialog) => {
- if (!props.sessionID) return
- if (autocomplete.visible) return
- if (!input.focused) return
- setStore("interrupt", store.interrupt + 1)
- setTimeout(() => {
- setStore("interrupt", 0)
- }, 5000)
- if (store.interrupt >= 2) {
- sdk.client.session.abort({
- path: {
- id: props.sessionID,
- },
- })
- setStore("interrupt", 0)
- }
- dialog.clear()
- },
- },
- ]
- })
- sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
- input.insertText(evt.properties.text)
- })
- createEffect(() => {
- if (props.disabled) input.cursorColor = theme.backgroundElement
- if (!props.disabled) input.cursorColor = theme.primary
- })
- const [store, setStore] = createStore<{
- prompt: PromptInfo
- mode: "normal" | "shell"
- extmarkToPartIndex: Map<number, number>
- interrupt: number
- }>({
- prompt: {
- input: "",
- parts: [],
- },
- mode: "normal",
- extmarkToPartIndex: new Map(),
- interrupt: 0,
- })
- createEffect(() => {
- input.focus()
- })
- onMount(() => {
- promptPartTypeId = input.extmarks.registerType("prompt-part")
- })
- function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
- input.extmarks.clear()
- setStore("extmarkToPartIndex", new Map())
- parts.forEach((part, partIndex) => {
- let start = 0
- let end = 0
- let virtualText = ""
- let styleId: number | undefined
- if (part.type === "file" && part.source?.text) {
- start = part.source.text.start
- end = part.source.text.end
- virtualText = part.source.text.value
- styleId = fileStyleId
- } else if (part.type === "agent" && part.source) {
- start = part.source.start
- end = part.source.end
- virtualText = part.source.value
- styleId = agentStyleId
- } else if (part.type === "text" && part.source?.text) {
- start = part.source.text.start
- end = part.source.text.end
- virtualText = part.source.text.value
- styleId = pasteStyleId
- }
- if (virtualText) {
- const extmarkId = input.extmarks.create({
- start,
- end,
- virtual: true,
- styleId,
- typeId: promptPartTypeId,
- })
- setStore("extmarkToPartIndex", (map: Map<number, number>) => {
- const newMap = new Map(map)
- newMap.set(extmarkId, partIndex)
- return newMap
- })
- }
- })
- }
- function syncExtmarksWithPromptParts() {
- const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
- setStore(
- produce((draft) => {
- const newMap = new Map<number, number>()
- const newParts: typeof draft.prompt.parts = []
- for (const extmark of allExtmarks) {
- const partIndex = draft.extmarkToPartIndex.get(extmark.id)
- if (partIndex !== undefined) {
- const part = draft.prompt.parts[partIndex]
- if (part) {
- if (part.type === "agent" && part.source) {
- part.source.start = extmark.start
- part.source.end = extmark.end
- } else if (part.type === "file" && part.source?.text) {
- part.source.text.start = extmark.start
- part.source.text.end = extmark.end
- } else if (part.type === "text" && part.source?.text) {
- part.source.text.start = extmark.start
- part.source.text.end = extmark.end
- }
- newMap.set(extmark.id, newParts.length)
- newParts.push(part)
- }
- }
- }
- draft.extmarkToPartIndex = newMap
- draft.prompt.parts = newParts
- }),
- )
- }
- props.ref?.({
- get focused() {
- return input.focused
- },
- focus() {
- input.focus()
- },
- blur() {
- input.blur()
- },
- set(prompt) {
- input.setText(prompt.input, { history: false })
- setStore("prompt", prompt)
- restoreExtmarksFromParts(prompt.parts)
- input.gotoBufferEnd()
- },
- reset() {
- input.clear()
- input.extmarks.clear()
- setStore("prompt", {
- input: "",
- parts: [],
- })
- setStore("extmarkToPartIndex", new Map())
- },
- })
- async function submit() {
- if (props.disabled) return
- if (autocomplete.visible) return
- if (!store.prompt.input) return
- const sessionID = props.sessionID
- ? props.sessionID
- : await (async () => {
- const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
- return sessionID
- })()
- const messageID = Identifier.ascending("message")
- let inputText = store.prompt.input
- // Expand pasted text inline before submitting
- const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
- const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
- for (const extmark of sortedExtmarks) {
- const partIndex = store.extmarkToPartIndex.get(extmark.id)
- if (partIndex !== undefined) {
- const part = store.prompt.parts[partIndex]
- if (part?.type === "text" && part.text) {
- const before = inputText.slice(0, extmark.start)
- const after = inputText.slice(extmark.end)
- inputText = before + part.text + after
- }
- }
- }
- // Filter out text parts (pasted content) since they're now expanded inline
- const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
- if (store.mode === "shell") {
- sdk.client.session.shell({
- path: {
- id: sessionID,
- },
- body: {
- agent: local.agent.current().name,
- model: {
- providerID: local.model.current().providerID,
- modelID: local.model.current().modelID,
- },
- command: inputText,
- },
- })
- setStore("mode", "normal")
- } else if (
- inputText.startsWith("/") &&
- iife(() => {
- const command = inputText.split(" ")[0].slice(1)
- console.log(command)
- return sync.data.command.some((x) => x.name === command)
- })
- ) {
- let [command, ...args] = inputText.split(" ")
- sdk.client.session.command({
- path: {
- id: sessionID,
- },
- body: {
- command: command.slice(1),
- arguments: args.join(" "),
- agent: local.agent.current().name,
- model: `${local.model.current().providerID}/${local.model.current().modelID}`,
- messageID,
- },
- })
- } else {
- sdk.client.session.prompt({
- path: {
- id: sessionID,
- },
- body: {
- ...local.model.current(),
- messageID,
- agent: local.agent.current().name,
- model: local.model.current(),
- parts: [
- {
- id: Identifier.ascending("part"),
- type: "text",
- text: inputText,
- },
- ...nonTextParts.map((x) => ({
- id: Identifier.ascending("part"),
- ...x,
- })),
- ],
- },
- })
- }
- history.append(store.prompt)
- input.extmarks.clear()
- setStore("prompt", {
- input: "",
- parts: [],
- })
- setStore("extmarkToPartIndex", new Map())
- props.onSubmit?.()
- // temporary hack to make sure the message is sent
- if (!props.sessionID)
- setTimeout(() => {
- route.navigate({
- type: "session",
- sessionID,
- })
- }, 50)
- input.clear()
- }
- const exit = useExit()
- async function pasteImage(file: { filename?: string; content: string; mime: string }) {
- const currentOffset = input.visualCursor.offset
- const extmarkStart = currentOffset
- const count = store.prompt.parts.filter((x) => x.type === "file").length
- const virtualText = `[Image ${count + 1}]`
- const extmarkEnd = extmarkStart + virtualText.length
- const textToInsert = virtualText + " "
- input.insertText(textToInsert)
- const extmarkId = input.extmarks.create({
- start: extmarkStart,
- end: extmarkEnd,
- virtual: true,
- styleId: pasteStyleId,
- typeId: promptPartTypeId,
- })
- const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
- type: "file" as const,
- mime: file.mime,
- filename: file.filename,
- url: `data:${file.mime};base64,${file.content}`,
- source: {
- type: "file",
- path: file.filename ?? "",
- text: {
- start: extmarkStart,
- end: extmarkEnd,
- value: virtualText,
- },
- },
- }
- setStore(
- produce((draft) => {
- const partIndex = draft.prompt.parts.length
- draft.prompt.parts.push(part)
- draft.extmarkToPartIndex.set(extmarkId, partIndex)
- }),
- )
- return
- }
- return (
- <>
- <Autocomplete
- sessionID={props.sessionID}
- ref={(r) => (autocomplete = r)}
- anchor={() => anchor}
- input={() => input}
- setPrompt={(cb) => {
- setStore("prompt", produce(cb))
- }}
- setExtmark={(partIndex, extmarkId) => {
- setStore("extmarkToPartIndex", (map: Map<number, number>) => {
- const newMap = new Map(map)
- newMap.set(extmarkId, partIndex)
- return newMap
- })
- }}
- value={store.prompt.input}
- fileStyleId={fileStyleId}
- agentStyleId={agentStyleId}
- promptPartTypeId={() => promptPartTypeId}
- />
- <box ref={(r) => (anchor = r)}>
- <box
- flexDirection="row"
- {...SplitBorder}
- borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
- justifyContent="space-evenly"
- >
- <box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
- <text attributes={TextAttributes.BOLD} fg={theme.primary}>
- {store.mode === "normal" ? ">" : "!"}
- </text>
- </box>
- <box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
- <textarea
- placeholder={
- props.showPlaceholder
- ? 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"))}`
- : undefined
- }
- textColor={theme.text}
- focusedTextColor={theme.text}
- minHeight={1}
- maxHeight={6}
- onContentChange={() => {
- const value = input.plainText
- setStore("prompt", "input", value)
- autocomplete.onInput(value)
- syncExtmarksWithPromptParts()
- }}
- keyBindings={textareaKeybindings()}
- onKeyDown={async (e) => {
- if (props.disabled) {
- e.preventDefault()
- return
- }
- if (keybind.match("input_clear", e) && store.prompt.input !== "") {
- input.clear()
- input.extmarks.clear()
- setStore("prompt", {
- input: "",
- parts: [],
- })
- setStore("extmarkToPartIndex", new Map())
- return
- }
- if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
- const cursorOffset = input.cursorOffset
- if (cursorOffset < input.plainText.length) {
- const text = input.plainText
- const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
- input.setText(newText)
- input.cursorOffset = cursorOffset
- }
- e.preventDefault()
- return
- }
- if (keybind.match("app_exit", e)) {
- await exit()
- return
- }
- if (e.name === "!" && input.visualCursor.offset === 0) {
- setStore("mode", "shell")
- e.preventDefault()
- return
- }
- if (store.mode === "shell") {
- if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
- setStore("mode", "normal")
- e.preventDefault()
- return
- }
- }
- if (store.mode === "normal") autocomplete.onKeyDown(e)
- if (!autocomplete.visible) {
- if (
- (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
- (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
- ) {
- const direction = keybind.match("history_previous", e) ? -1 : 1
- const item = history.move(direction, input.plainText)
- if (item) {
- input.setText(item.input, { history: false })
- setStore("prompt", item)
- restoreExtmarksFromParts(item.parts)
- e.preventDefault()
- if (direction === -1) input.cursorOffset = 0
- if (direction === 1) input.cursorOffset = input.plainText.length
- }
- return
- }
- if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
- if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
- input.cursorOffset = input.plainText.length
- }
- }}
- onSubmit={submit}
- onPaste={async (event: PasteEvent) => {
- if (props.disabled) {
- event.preventDefault()
- return
- }
- // Normalize line endings at the boundary
- // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
- // Replace CRLF first, then any remaining CR
- const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
- const pastedContent = normalizedText.trim()
- if (!pastedContent) {
- command.trigger("prompt.paste")
- return
- }
- // trim ' from the beginning and end of the pasted content. just
- // ' and nothing else
- const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
- console.log(pastedContent, filepath)
- try {
- const file = Bun.file(filepath)
- if (file.type.startsWith("image/")) {
- event.preventDefault()
- const content = await file
- .arrayBuffer()
- .then((buffer) => Buffer.from(buffer).toString("base64"))
- .catch(console.error)
- if (content) {
- await pasteImage({
- filename: file.name,
- mime: file.type,
- content,
- })
- return
- }
- }
- } catch {}
- const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
- if (
- (lineCount >= 3 || pastedContent.length > 150) &&
- !sync.data.config.experimental?.disable_paste_summary
- ) {
- event.preventDefault()
- const currentOffset = input.visualCursor.offset
- const virtualText = `[Pasted ~${lineCount} lines]`
- const textToInsert = virtualText + " "
- const extmarkStart = currentOffset
- const extmarkEnd = extmarkStart + virtualText.length
- input.insertText(textToInsert)
- const extmarkId = input.extmarks.create({
- start: extmarkStart,
- end: extmarkEnd,
- virtual: true,
- styleId: pasteStyleId,
- typeId: promptPartTypeId,
- })
- const part = {
- type: "text" as const,
- text: pastedContent,
- source: {
- text: {
- start: extmarkStart,
- end: extmarkEnd,
- value: virtualText,
- },
- },
- }
- setStore(
- produce((draft) => {
- const partIndex = draft.prompt.parts.length
- draft.prompt.parts.push(part)
- draft.extmarkToPartIndex.set(extmarkId, partIndex)
- }),
- )
- return
- }
- }}
- ref={(r: TextareaRenderable) => (input = r)}
- onMouseDown={(r: MouseEvent) => r.target?.focus()}
- focusedBackgroundColor={theme.backgroundElement}
- cursorColor={theme.primary}
- syntaxStyle={syntax()}
- />
- </box>
- <box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
- </box>
- <box flexDirection="row" justifyContent="space-between">
- <text flexShrink={0} wrapMode="none" fg={theme.text}>
- <span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
- <span style={{ bold: true }}>{local.model.parsed().model}</span>
- </text>
- <Switch>
- <Match when={status() === "compacting"}>
- <text fg={theme.textMuted}>compacting...</text>
- </Match>
- <Match when={status() === "working"}>
- <box flexDirection="row" gap={1}>
- <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
- esc{" "}
- <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
- {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
- </span>
- </text>
- </box>
- </Match>
- <Match when={props.hint}>{props.hint!}</Match>
- <Match when={true}>
- <text fg={theme.text}>
- {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
- </text>
- </Match>
- </Switch>
- </box>
- </box>
- </>
- )
- }
|