import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, type KeyEvent, type Renderable, } from "@opentui/core" import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" import * as Clipboard from "../../util/clipboard" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { confirmWorkspaceFileChanges, openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection, } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { sessionID?: string workspaceID?: string visible?: boolean disabled?: boolean onSubmit?: () => void ref?: (ref: PromptRef | undefined) => void hint?: JSX.Element right?: JSX.Element showPlaceholder?: boolean placeholders?: { normal?: string[] shell?: string[] } } export type PromptRef = { focused: boolean current: PromptInfo set(prompt: PromptInfo): void reset(): void blur(): void focus(): void submit(): void } const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }) function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) } function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { return ( selection.selection.start.line !== selection.selection.end.line || selection.selection.start.character !== selection.selection.end.character ) } function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { if (!hasEditorRangeSelection(selection)) return if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` return `#${selection.selection.start.line}-${selection.selection.end.line}` } function formatEditorContext(selection: EditorSelection) { const selected = selection.ranges.filter(hasEditorRangeSelection) if (selected.length === 0) return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` const ranges = selected.map((range, index) => { const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` }) return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable const [inputTarget, setInputTarget] = createSignal() const leader = useLeaderActive() const local = useLocal() const args = useArgs() const sdk = useSDK() const editor = useEditorContext() const route = useRoute() const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() const command = useCommandPalette() const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() const editorContext = createMemo(() => { const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection }) const editorPath = createMemo(() => editorContext()?.filePath) const editorSelectionLabel = createMemo(() => { const ranges = editorContext()?.ranges if (!ranges) return const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] if (!first) return return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] .filter(Boolean) .join(" ") }) const editorFileLabel = createMemo(() => { const value = editorPath() if (!value) return const filename = path.basename(value) const file = /^index\.[^./]+$/.test(filename) ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/") : filename return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}` }) const editorFileLabelDisplay = createMemo(() => { const file = editorFileLabel() if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const [workspaceSelection, setWorkspaceSelection] = createSignal() const [workspaceCreating, setWorkspaceCreating] = createSignal(false) const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) const [warpNotice, setWarpNotice] = createSignal() const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) function selectWorkspace(selection: WorkspaceSelection | undefined) { setWorkspaceSelection(selection) } function setCreatingWorkspace(creating: boolean) { setWorkspaceCreating(creating) } function showWarpNotice(name: string) { setWarpNotice(`Warped to ${name}`) setTimeout(() => setWarpNotice(undefined), 4000) } async function createWorkspace(selection: Extract) { setCreatingWorkspace(true) const result = await sdk.client.experimental.workspace .create({ type: selection.workspaceType, branch: null }) .catch(() => undefined) if (result == undefined || result.error || !result.data) { selectWorkspace(undefined) setCreatingWorkspace(false) toast.show({ message: "Creating workspace failed", variant: "error", }) return } await project.workspace.sync() const workspace = result.data selectWorkspace({ type: "existing", workspaceID: workspace.id, workspaceType: workspace.type, workspaceName: workspace.name, }) setCreatingWorkspace(false) return workspace } async function warpSession(selection: WorkspaceSelection) { if (!props.sessionID) { selectWorkspace(selection) dialog.clear() if (selection.type === "new") void createWorkspace(selection) return } const sourceWorkspaceID = project.workspace.current() const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) if (copyChanges === undefined) return selectWorkspace(selection) dialog.clear() const workspace = selection.type === "none" ? { id: null, name: "local project" } : selection.type === "existing" ? { id: selection.workspaceID, name: selection.workspaceName } : await createWorkspace(selection) if (!workspace) return const warped = await warpWorkspaceSession({ dialog, sdk, sync, project, toast, sourceWorkspaceID, workspaceID: workspace.id, sessionID: props.sessionID, copyChanges, }) if (warped) showWarpNotice(workspace.name) } createEffect(() => { if (!workspaceCreating()) { setWorkspaceCreatingDots(3) return } const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) onCleanup(() => clearInterval(timer)) }) function promptModelWarning() { toast.show({ variant: "warning", message: "Connect a provider to send prompts", duration: 3000, }) if (sync.data.provider.length === 0) { dialog.replace(() => ) } } function dismissEditorContext() { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 const event = useEvent() event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return input.getLayoutNode().markDirty() input.gotoBufferEnd() renderer.requestRender() }, 0) }) createEffect(() => { if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) const lastUserMessage = createMemo(() => { if (!props.sessionID) return undefined const messages = sync.data.message[props.sessionID] if (!messages) return undefined return messages.findLast((m): m is UserMessage => m.role === "user") }) const usage = createMemo(() => { if (!props.sessionID) return const msg = sync.data.message[props.sessionID] ?? [] const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, } }) const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" extmarkToPartIndex: Map interrupt: number placeholder: number }>({ placeholder: randomIndex(list().length), prompt: { input: "", parts: [], }, mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, }) createEffect( on( () => props.sessionID, () => { setStore("placeholder", randomIndex(list().length)) }, { defer: true }, ), ) // Initialize agent/model/variant from last user message when session changes let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID const msg = lastUserMessage() if (sessionID !== syncedSessionID) { if (!sessionID || !msg) return syncedSessionID = sessionID // Only set agent if it's a primary agent (not a subagent) const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) if (msg.model) { local.model.set(msg.model) local.model.variant.set(msg.model.variant) } } } }) const promptCommands = createMemo(() => [ { title: "Clear prompt", name: "prompt.clear", category: "Prompt", hidden: true, run: () => { input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Submit prompt", name: "prompt.submit", category: "Prompt", hidden: true, run: async () => { if (!input.focused) return const handled = await submit() if (!handled) return dialog.clear() }, }, { title: "Remove editor context", name: "prompt.editor_context.clear", category: "Prompt", enabled: Boolean(editorContext()), run: () => { dismissEditorContext() dialog.clear() }, }, { title: "Paste", name: "prompt.paste", category: "Prompt", hidden: true, run: async (ctx: CommandContext) => { ctx.event.preventDefault() ctx.event.stopPropagation() const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", mime: content.mime, content: content.data, }) return } if (content?.mime === "text/plain") { await pasteInputText(content.data) } }, }, { title: "Interrupt session", name: "session.interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", run: () => { if (auto()?.visible) return if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { setStore("mode", "normal") return } if (!props.sessionID) return setStore("interrupt", store.interrupt + 1) setTimeout(() => { setStore("interrupt", 0) }, 5000) if (store.interrupt >= 2) { void sdk.client.session.abort({ sessionID: props.sessionID, }) setStore("interrupt", 0) } dialog.clear() }, }, { title: "Open editor", category: "Session", name: "prompt.editor", slashName: "editor", run: async () => { 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 = text const content = await Editor.open({ value, renderer }) if (!content) return input.setText(content) // 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: "Skills", name: "prompt.skills", category: "Prompt", slashName: "skills", run: () => { dialog.replace(() => ( { input.setText(`/${skill} `) setStore("prompt", { input: `/${skill} `, parts: [], }) input.gotoBufferEnd() }} /> )) }, }, { title: "Warp", desc: "Change the workspace for the session", name: "workspace.set", category: "Session", enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "warp", run: () => { void openWorkspaceSelect({ dialog, sdk, sync, toast, onSelect: (selection) => { void warpSession(selection) }, }) }, }, ].map((entry) => ({ namespace: "palette", ...entry, })), ) useBindings(() => ({ commands: promptCommands(), })) useBindings(() => ({ enabled: command.matcher, bindings: keymapConfig.pick("prompt", [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", "prompt.stash", "prompt.stash.pop", "prompt.stash.list", "session.interrupt", "workspace.set", ]), })) const ref: PromptRef = { get focused() { return input.focused }, get current() { return store.prompt }, focus() { input.focus() }, blur() { input.blur() }, set(prompt) { input.setText(prompt.input) setStore("prompt", prompt) restoreExtmarksFromParts(prompt.parts) input.gotoBufferEnd() }, reset() { input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) }, submit() { void submit() }, } onMount(() => { const saved = stashed stashed = undefined if (store.prompt.input) return if (saved && saved.prompt.input) { input.setText(saved.prompt.input) setStore("prompt", saved.prompt) restoreExtmarksFromParts(saved.prompt.parts) input.cursorOffset = saved.cursor } }) onCleanup(() => { if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } setInputTarget(undefined) props.ref?.(undefined) }) createEffect(() => { if (!input || input.isDestroyed) return if (props.visible === false || dialog.stack.length > 0) { if (input.focused) input.blur() return } // Slot/plugin updates can remount the background prompt while a dialog is open. // Keep focus with the dialog and let the prompt reclaim it after the dialog closes. if (!input.focused) input.focus() }) createEffect(() => { if (!input || input.isDestroyed) return input.traits = { ...input.traits, ...computePromptTraits({ mode: store.mode, disabled: !!props.disabled, autocompleteVisible: !!auto()?.visible, }), } }) 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) => { 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() 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 }), ) } const stashCommands = createMemo(() => [ { title: "Stash prompt", name: "prompt.stash", category: "Prompt", enabled: !!store.prompt.input, run: () => { if (!store.prompt.input) return stash.push({ input: store.prompt.input, parts: store.prompt.parts, }) input.extmarks.clear() input.clear() setStore("prompt", { input: "", parts: [] }) setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Stash pop", name: "prompt.stash.pop", category: "Prompt", enabled: stash.list().length > 0, run: () => { const entry = stash.pop() if (entry) { input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() } dialog.clear() }, }, { title: "Stash list", name: "prompt.stash.list", category: "Prompt", enabled: stash.list().length > 0, run: () => { dialog.replace(() => ( { input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() }} /> )) }, }, ].map((entry) => ({ namespace: "palette", ...entry, })), ) useBindings(() => ({ commands: stashCommands(), })) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, bindings: keymapConfig.pick("prompt", ["prompt.paste"]), } }) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", bindings: keymapConfig.pick("prompt", ["prompt.clear"]), } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return ( inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 ) })(), bindings: [ { key: "!", cmd: () => { setStore("placeholder", randomIndex(shell().length)) setStore("mode", "shell") }, }, ], } }) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }], } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 })(), bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }], } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return ( inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined && (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) ) })(), commands: [ { name: "prompt.history.previous", run() { if (input.cursorOffset !== 0) { input.cursorOffset = 0 return } const item = history.move(-1, input.plainText) if (!item) return input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = 0 }, }, ], bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return ( inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined && (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) ) })(), commands: [ { name: "prompt.history.next", run() { if (input.cursorOffset !== input.plainText.length) { input.cursorOffset = input.plainText.length return } const item = history.move(1, input.plainText) if (!item) return input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = input.plainText.length }, }, ], bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), } }) async function submit() { setWarpNotice(undefined) // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } if (props.disabled) return false if (workspaceCreating()) return false if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true } const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() return false } const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined const workspaceID = workspaceSession?.workspaceID const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined if (props.sessionID && workspaceID && workspaceStatus !== "connected") { dialog.replace(() => ( { void openWorkspaceSelect({ dialog, sdk, sync, toast, onSelect: (selection) => { void warpSession(selection) }, }) return false }} /> )) return false } const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { const workspace = workspaceSelection() const workspaceID = iife(() => { if (!workspace) return defaultWorkspaceID() if (workspace.type === "none") return undefined if (workspace.type === "existing") return workspace.workspaceID return undefined }) const res = await sdk.client.session.create({ workspace: workspaceID, agent: agent.name, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant, }, }) if (res.error) { console.log("Creating a session failed:", res.error) toast.show({ message: "Creating a session failed. Open console for more details.", variant: "error", }) return true } sessionID = res.data.id } const messageID = MessageID.ascending() 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") // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() const editorParts = editorSelection && editor.labelState() === "pending" ? [ { id: PartID.ascending(), type: "text" as const, text: formatEditorContext(editorSelection), synthetic: true, metadata: { kind: "editor_context", source: editorSelection.source ?? "editor", filePath: editorSelection.filePath, ranges: editorSelection.ranges, }, }, ] : [] if (store.mode === "shell") { void sdk.client.session.shell({ sessionID, agent: agent.name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, }, command: inputText, }) setStore("mode", "normal") } else if ( inputText.startsWith("/") && iife(() => { const firstLine = inputText.split("\n")[0] const command = firstLine.split(" ")[0].slice(1) return sync.data.command.some((x) => x.name === command) }) ) { // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) const [command, ...firstLineArgs] = firstLine.split(" ") const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") void sdk.client.session.command({ sessionID, command: command.slice(1), arguments: args, agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, parts: nonTextParts .filter((x) => x.type === "file") .map((x) => ({ id: PartID.ascending(), ...x, })), }) } else { sdk.client.session .prompt({ sessionID, ...selectedModel, messageID, agent: agent.name, model: selectedModel, variant, parts: [ ...editorParts, { id: PartID.ascending(), type: "text", text: inputText, }, ...nonTextParts.map(assign), ], }) .catch(() => {}) if (editorParts.length > 0) editor.markSelectionSent() } history.append({ ...store.prompt, mode: currentMode, }) 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) { if (editorParts.length > 0) editor.preserveSelectionFromNewSession() setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) } input.clear() return true } const exit = useExit() function pasteText(text: string, virtualText: string) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const extmarkEnd = extmarkStart + virtualText.length input.insertText(virtualText + " ") const extmarkId = input.extmarks.create({ start: extmarkStart, end: extmarkEnd, virtual: true, styleId: pasteStyleId, typeId: promptPartTypeId, }) setStore( produce((draft) => { const partIndex = draft.prompt.parts.length draft.prompt.parts.push({ type: "text" as const, text, source: { text: { start: extmarkStart, end: extmarkEnd, value: virtualText, }, }, }) draft.extmarkToPartIndex.set(extmarkId, partIndex) }), ) } async function pasteInputText(text: string) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() const filepath = iife(() => { const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") if (raw.startsWith("file://")) { try { return fileURLToPath(raw) } catch {} } if (process.platform === "win32") return raw return raw.replace(/\\(.)/g, "$1") }) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { const mime = await Filesystem.mimeType(filepath) const filename = path.basename(filepath) if (mime === "image/svg+xml") { const content = await Filesystem.readText(filepath).catch(() => {}) if (content) { pasteText(content, `[SVG: ${filename ?? "image"}]`) return } } if (mime.startsWith("image/") || mime === "application/pdf") { const content = await Filesystem.readArrayBuffer(filepath) .then((buffer) => Buffer.from(buffer).toString("base64")) .catch(() => {}) if (content) { await pasteAttachment({ filename, filepath, mime, content, }) return } } } catch {} } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ( (lineCount >= 3 || pastedContent.length > 150) && kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) ) { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } input.insertText(normalizedText) setTimeout(() => { if (!input || input.isDestroyed) return input.getLayoutNode().markDirty() renderer.requestRender() }, 0) } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const pdf = file.mime === "application/pdf" const count = store.prompt.parts.filter((x) => { if (x.type !== "file") return false if (pdf) return x.mime === "application/pdf" return x.mime.startsWith("image/") }).length const virtualText = pdf ? `[PDF ${count + 1}]` : `[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 = { type: "file" as const, mime: file.mime, filename: file.filename, url: `data:${file.mime};base64,${file.content}`, source: { type: "file", path: file.filepath ?? 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 } const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border return local.agent.color(agent.name) }) const showVariant = createMemo(() => { const variants = local.model.variant.list() if (variants.length === 0) return false const current = local.model.variant.current() return !!current }) const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) const variantMetaAlpha = createFadeIn( () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] return `Run a command... "${example}"` } if (!list().length) return undefined return `Ask anything... "${list()[store.placeholder % list().length]}"` }) const workspaceLabel = createMemo< | { type: "new"; workspaceType: string } | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } | undefined >(() => { const selected = workspaceSelection() if (!selected) { const workspaceID = defaultWorkspaceID() if (props.sessionID || !workspaceID) return const workspace = project.workspace.get(workspaceID) return { type: "existing", workspaceType: workspace?.type ?? "unknown", workspaceName: workspace?.name ?? workspaceID, status: project.workspace.status(workspaceID) ?? "error", } } if (selected.type === "none") return if (props.sessionID && !workspaceCreating()) return if (selected.type === "new") { return { type: "new", workspaceType: selected.workspaceType, } } return { type: "existing", workspaceType: selected.workspaceType, workspaceName: selected.workspaceName, status: selected.type === "existing" ? "connected" : undefined, } }) const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border return { frames: createFrames({ color, style: "blocks", inactiveFactor: 0.6, // enableFading: false, minAlpha: 0.3, }), color: createColors({ color, style: "blocks", inactiveFactor: 0.6, // enableFading: false, minAlpha: 0.3, }), } }) return ( <> (anchor = r)} visible={props.visible !== false}>