opencode/packages/tui/src/component/prompt/index.tsx
2026-07-06 14:35:31 -04:00

1649 lines
56 KiB
TypeScript

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 { useLocal } from "../../context/local"
import { Flag } from "@opencode-ai/core/flag/flag"
import { tint, useTheme } from "../../context/theme"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { normalizePromptContent, openEditor } from "../../editor"
import { useExit } from "../../context/exit"
import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "../../context/args"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
export type PromptProps = {
sessionID?: 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[]
}
}
function pastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
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",
})
const DRAFT_RETENTION_MIN_CHARS = 20
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 `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\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 `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = useLeaderActive()
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
const sdk = useSDK()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const sync = useSync()
const data = useData()
const currentLocation = useLocation()
const tuiConfig = useTuiConfig()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return data.session.family(props.sessionID).filter(
(id) => id !== props.sessionID && data.session.status(id) === "running",
).length
})
const runningShells = createMemo(
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const renderer = useRenderer()
const exit = useExit()
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<string>()
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<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
toast.show({
variant: "warning",
message: "Connect a provider to send prompts",
duration: 3000,
})
if (!connected()) {
dialog.replace(() => <DialogIntegration />)
}
}
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("tui.prompt.append", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (!input || input.isDestroyed) return
input.insertText(evt.data.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 session = sync.session.get(props.sessionID)
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 = session?.cost ?? 0
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
}
})
const subagentStatusLabel = createMemo(() => {
const agents = activeSubagents()
if (!agents) return undefined
return `${agents} subagent${agents === 1 ? "" : "s"}`
})
const shellStatusLabel = createMemo(() => {
const shells = runningShells()
if (!shells) return undefined
return `${shells} shell${shells === 1 ? "" : "s"}`
})
const liveWorkStatusVisible = createMemo(() => Boolean(subagentStatusLabel() || shellStatusLabel()))
// Far-right footer cluster: live work counts lead, then context/cost usage.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const stats = usage()
return [stats?.context, stats?.cost].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPart: Map<number, PromptPartRef>
interrupt: number
placeholder: number
}>({
placeholder: randomIndex(list().length),
prompt: emptyPrompt(),
mode: "normal",
extmarkToPart: new Map(),
interrupt: 0,
})
createEffect(
on(
() => props.sessionID,
() => {
setStore("placeholder", randomIndex(list().length))
},
{ defer: true },
),
)
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
const promptCommands = createMemo(() =>
[
{
title: "Clear prompt",
name: "prompt.clear",
category: "Prompt",
hidden: true,
run: () => {
clearPrompt()
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<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
},
},
{
title: "Interrupt session",
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status() === "running",
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.api.session.interrupt({
sessionID: props.sessionID,
})
setStore("interrupt", 0)
}
dialog.clear()
},
},
{
title: "Background blocking tools",
name: "session.background",
category: "Session",
hidden: true,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
if (!input.focused) return
if (!props.sessionID) return
void sdk.api.session.background({
sessionID: props.sessionID,
})
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.pasted.reduce(
(result, part) => result.replace(part.source.text, part.text),
store.prompt.text,
)
const value = text
const content = await openEditor({
renderer,
value,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
paths.cwd,
})
if (!content) return
const normalized = normalizePromptContent(content)
input.setText(normalized)
// Update attachment positions and drop virtual text deleted in the editor.
// this handles a case where the user edits the text in the editor
// such that the virtual text moves around or is deleted
const moveSource = <Part extends { source?: { start: number; end: number; text: string } }>(part: Part) => {
if (!part.source?.text) return part
const start = normalized.indexOf(part.source.text)
if (start === -1) return
return { ...part, source: { ...part.source, start, end: start + part.source.text.length } }
}
setStore("prompt", {
text: normalized,
files: store.prompt.files?.map(moveSource).filter((part) => part !== undefined),
agents: store.prompt.agents?.map(moveSource).filter((part) => part !== undefined),
pasted: [],
})
restoreExtmarksFromPrompt(store.prompt)
input.cursorOffset = Bun.stringWidth(normalized)
},
},
{
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slashName: "skills",
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation()}
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
...emptyPrompt(),
text: `/${skill} `,
})
input.gotoBufferEnd()
}}
/>
))
},
},
{
title: "Warp",
desc: "Change the workspace for the session",
name: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "warp",
run: () => {
workspace.open()
},
},
{
title: "Move session",
desc: "Move to another project dir",
name: "session.move",
category: "Session",
slashName: "move",
run: () => {
move.open()
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: promptCommands(),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("prompt.palette", [
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"workspace.set",
"session.move",
]),
}))
const ref: PromptRef = {
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.text)
setStore("prompt", prompt)
restoreExtmarksFromPrompt(prompt)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
},
submit() {
void submit()
},
}
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(saved.prompt)
input.cursorOffset = saved.cursor
}
})
onCleanup(() => {
if (store.prompt.text) {
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,
autocompleteVisible: !!auto()?.visible,
}),
}
})
function restoreExtmarksFromPrompt(prompt: PromptInfo) {
input.extmarks.clear()
setStore("extmarkToPart", new Map())
const parts = [
...(prompt.files ?? []).map((part, index) => ({ part, ref: { type: "file" as const, index }, styleId: fileStyleId })),
...(prompt.agents ?? []).map((part, index) => ({ part, ref: { type: "agent" as const, index }, styleId: agentStyleId })),
...prompt.pasted.map((part, index) => ({ part, ref: { type: "pasted" as const, index }, styleId: pasteStyleId })),
]
parts.forEach(({ part, ref, styleId }) => {
if (part.source?.text) {
const extmarkId = input.extmarks.create({
start: part.source.start,
end: part.source.end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
const newMap = new Map(map)
newMap.set(extmarkId, ref)
return newMap
})
}
})
}
function syncExtmarksWithPromptParts() {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
setStore(
produce((draft) => {
const newMap = new Map<number, PromptPartRef>()
const files: NonNullable<PromptInfo["files"]> = []
const agents: NonNullable<PromptInfo["agents"]> = []
const pasted: PromptInfo["pasted"] = []
for (const extmark of allExtmarks) {
const ref = draft.extmarkToPart.get(extmark.id)
if (!ref) continue
if (ref.type === "file") {
const part = draft.prompt.files?.[ref.index]
if (!part?.source) continue
part.source.start = extmark.start
part.source.end = extmark.end
const index = files.length
files.push(part)
newMap.set(extmark.id, { type: "file", index })
continue
}
if (ref.type === "agent") {
const part = draft.prompt.agents?.[ref.index]
if (!part?.source) continue
part.source.start = extmark.start
part.source.end = extmark.end
const index = agents.length
agents.push(part)
newMap.set(extmark.id, { type: "agent", index })
continue
}
const part = draft.prompt.pasted[ref.index]
if (!part) continue
part.source.start = extmark.start
part.source.end = extmark.end
const index = pasted.length
pasted.push(part)
newMap.set(extmark.id, { type: "pasted", index })
}
draft.extmarkToPart = newMap
draft.prompt.files = files
draft.prompt.agents = agents
draft.prompt.pasted = pasted
}),
)
}
const stashCommands = createMemo(() =>
[
{
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.text) return
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", 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.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}
dialog.clear()
},
},
{
title: "Stash list",
name: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}}
/>
))
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: stashCommands(),
}))
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: tuiConfig.keybinds.get("prompt.paste"),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
bindings: tuiConfig.keybinds.get("prompt.clear"),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
)
})(),
bindings: [
{
key: "!",
desc: "Shell mode",
group: "Prompt",
cmd: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
},
],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
name: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
run() {
if (input.cursorOffset !== 0) {
if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
return false
}
const item = history.move(-1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = 0
},
},
],
bindings: tuiConfig.keybinds.get("prompt.history.previous"),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
name: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
run() {
if (input.cursorOffset !== input.plainText.length) {
if (
input.scrollY + input.visualCursor.visualRow ===
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
)
input.cursorOffset = input.plainText.length
return false
}
const item = history.move(1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = input.plainText.length
},
},
],
bindings: tuiConfig.keybinds.get("prompt.history.next"),
}
})
let submitting = false
async function submit() {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
// clears `store.prompt.text`, then awaits its own `session.create` and
// ultimately reads the now-empty store — sending a phantom empty prompt
// to a freshly created session.
if (submitting) return false
submitting = true
try {
return await submitInner()
} finally {
submitting = false
}
}
async function submitInner() {
workspace.clearNotice()
// 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.text) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const agent = local.agent.current()
if (!agent) return false
const trimmed = store.prompt.text.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(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
workspace.open()
return false
}}
/>
))
return false
}
const variant = local.model.variant.current()
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
if (sessionID == null) {
const selectedWorkspace = workspace.selection()
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
const directory = await move.getDirectory(store.prompt.text)
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
const created = await sdk.api.session
.create({
location: directory
? { directory, workspaceID }
: { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID },
agent: agent.id,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
variant,
},
})
.catch(() => undefined)
if (!created) {
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = created.id
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types.
session = structuredClone(created) as SessionV2Info
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
const editorParts =
editorSelection && editor.labelState() === "pending"
? [
{
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") {
move.startSubmit()
void sdk.client.v2.session.shell({
sessionID,
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation()) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
// 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.api.session
.command({
sessionID,
command: command.slice(1),
arguments: args,
agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation()) ?? []).some(
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
void sdk.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
})
} else {
move.startSubmit()
if (!session) {
await data.session.refresh(sessionID)
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await sdk.api.session.switchAgent({ sessionID, agent: agent.id })
}
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session.model.variant !== variant
) {
await sdk.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
})
}
if (session?.revert) {
const error = await sdk.api.session.revertCommit({ sessionID }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
return false
}
}
const error = await sdk.api.session
.prompt({
sessionID,
prompt: {
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
files: store.prompt.files,
agents: store.prompt.agents,
},
})
.then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false
}
if (editorParts.length > 0) editor.markSelectionSent()
}
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", 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()
if (finishMoveProgress) move.finishSubmit()
return true
}
function pasteText(text: string, virtualText: string) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
input.insertText(virtualText + " ")
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
const index = draft.prompt.pasted.length
draft.prompt.pasted.push({
text,
source: { start: extmarkStart, end: extmarkEnd, text: virtualText },
})
draft.extmarkToPart.set(extmarkId, { type: "pasted", index })
}),
)
}
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
return
}
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
}
}
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; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
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: NonNullable<PromptInfo["files"]>[number] = {
uri: file.uri,
name: file.filename,
source: {
start: extmarkStart,
end: extmarkEnd,
text: virtualText,
},
}
setStore(
produce((draft) => {
const files = (draft.prompt.files ??= [])
const index = files.length
files.push(part)
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 ||
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append({
...store.prompt,
mode: store.mode,
})
}
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
}
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.id)
})
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 spinnerDef = createMemo(() => {
const agent =
status() === "running"
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current()
const color = agent ? local.agent.color(agent.id) : 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,
}),
}
})
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (
<>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
<box
width="100%"
border={["left"]}
borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
width="100%"
>
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
return
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
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 = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
keymap.dispatchCommand("prompt.paste")
return
}
// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text>
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
{local.model.variant.current()}
</span>
</text>
</Show>
</box>
</Show>
</>
)}
</Show>
</box>
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
</box>
</Show>
</box>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<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={workspace.notice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspace.label()}>
{(label) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspace.creating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspace.creating() ? theme.accent : theme.text}>
{(() => {
const item = label()
if (item.type === "new") {
if (workspace.creating())
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3}>
<Spinner color={theme.accent}>
{progress()}
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3}>
<text fg={theme.accent}>(new working copy)</text>
</box>
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={theme.textMuted} wrapMode="none">
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: theme.textMuted }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: theme.textMuted }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: theme.textMuted }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box>
</box>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(part, extmarkId) => {
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
const newMap = new Map(map)
newMap.set(extmarkId, part)
return newMap
})
}}
value={store.prompt.text}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
</>
)
}