Files
opencode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Kit Langton ae614d919f fix(tui): simplify console org display (#21339)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-07 21:03:24 -04:00

1262 lines
42 KiB
TypeScript

import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
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 { useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { 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 { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
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)
}
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 dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
toast.show({
variant: "warning",
message: "Connect a provider to send prompts",
duration: 3000,
})
if (sync.data.provider.length === 0) {
dialog.replace(() => <DialogProviderConnect />)
}
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0
sdk.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 (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<number, number>
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) {
local.agent.set(msg.agent)
if (msg.model) {
local.model.set(msg.model)
local.model.variant.set(msg.model.variant)
}
}
}
})
command.register(() => {
return [
{
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
if (!input.focused) return
submit()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
keybind: "input_paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.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) {
sdk.client.session.abort({
sessionID: props.sessionID,
})
setStore("interrupt", 0)
}
dialog.clear()
},
},
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
slash: {
name: "editor",
},
onSelect: async (dialog) => {
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",
value: "prompt.skills",
category: "Prompt",
slash: {
name: "skills",
},
onSelect: () => {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
input: `/${skill} `,
parts: [],
})
input.gotoBufferEnd()
}}
/>
))
},
},
]
})
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() {
submit()
},
}
onCleanup(() => {
props.ref?.(undefined)
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
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.
input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
})
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
}),
)
}
command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
onSelect: (dialog) => {
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",
value: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
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",
value: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
])
async function submit() {
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
return
}
const selectedModel = local.model.current()
if (!selectedModel) {
promptModelWarning()
return
}
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({
workspaceID: props.workspaceID,
})
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
}
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 variant = local.model.variant.current()
if (store.mode === "shell") {
sdk.client.session.shell({
sessionID,
agent: local.agent.current().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 : "")
sdk.client.session.command({
sessionID,
command: command.slice(1),
arguments: args,
agent: local.agent.current().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: local.agent.current().name,
model: selectedModel,
variant,
parts: [
{
id: PartID.ascending(),
type: "text",
text: inputText,
},
...nonTextParts.map(assign),
],
})
.catch(() => {})
}
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)
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
input.clear()
}
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 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<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.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 (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().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 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 color = local.agent.color(local.agent.current().name)
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 (
<>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
autocomplete = r
setAuto(() => 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)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : 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
}
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
// If no image, let the default paste behavior continue
}
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("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", randomIndex(shell().length))
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)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
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 = 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) {
command.trigger("prompt.paste")
return
}
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 = Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
event.preventDefault()
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) &&
!sync.data.config.experimental?.disable_paste_summary
) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}}
ref={(r: TextareaRenderable) => {
input = 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={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</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={highlight()}
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">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<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>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</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>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
</>
)
}