Compare commits
3 Commits
dev
...
tui-experi
| Author | SHA1 | Date |
|---|---|---|
|
|
b892d91bae | |
|
|
ad110878c9 | |
|
|
572fab3743 |
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"description": "AI-powered development tool",
|
"description": "AI-powered coding assistant that generates perfect code most of the time",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.10",
|
"packageManager": "bun@1.3.10",
|
||||||
|
|
|
||||||
|
|
@ -814,208 +814,204 @@ export function Prompt(props: PromptProps) {
|
||||||
agentStyleId={agentStyleId}
|
agentStyleId={agentStyleId}
|
||||||
promptPartTypeId={() => promptPartTypeId}
|
promptPartTypeId={() => promptPartTypeId}
|
||||||
/>
|
/>
|
||||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
<box
|
||||||
<box
|
ref={(r) => (anchor = r)}
|
||||||
border={["left"]}
|
visible={props.visible !== false}
|
||||||
borderColor={highlight()}
|
backgroundColor={theme.backgroundElement}
|
||||||
customBorderChars={{
|
paddingBottom={1}
|
||||||
...EmptyBorder,
|
paddingLeft={1}
|
||||||
vertical: "┃",
|
paddingRight={2}
|
||||||
bottomLeft: "╹",
|
border={["left"]}
|
||||||
}}
|
borderColor={highlight()}
|
||||||
>
|
customBorderChars={{
|
||||||
<box
|
...EmptyBorder,
|
||||||
paddingLeft={2}
|
vertical: "▎",
|
||||||
paddingRight={2}
|
}}
|
||||||
paddingTop={1}
|
>
|
||||||
flexShrink={0}
|
<box paddingTop={1} flexShrink={0} flexGrow={1}>
|
||||||
backgroundColor={theme.backgroundElement}
|
<textarea
|
||||||
flexGrow={1}
|
placeholder={placeholderText()}
|
||||||
>
|
textColor={keybind.leader ? theme.textMuted : theme.text}
|
||||||
<textarea
|
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
||||||
placeholder={placeholderText()}
|
minHeight={1}
|
||||||
textColor={keybind.leader ? theme.textMuted : theme.text}
|
maxHeight={6}
|
||||||
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
onContentChange={() => {
|
||||||
minHeight={1}
|
const value = input.plainText
|
||||||
maxHeight={6}
|
setStore("prompt", "input", value)
|
||||||
onContentChange={() => {
|
autocomplete.onInput(value)
|
||||||
const value = input.plainText
|
syncExtmarksWithPromptParts()
|
||||||
setStore("prompt", "input", value)
|
}}
|
||||||
autocomplete.onInput(value)
|
keyBindings={textareaKeybindings()}
|
||||||
syncExtmarksWithPromptParts()
|
onKeyDown={async (e) => {
|
||||||
}}
|
if (props.disabled) {
|
||||||
keyBindings={textareaKeybindings()}
|
e.preventDefault()
|
||||||
onKeyDown={async (e) => {
|
return
|
||||||
if (props.disabled) {
|
}
|
||||||
|
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
|
||||||
|
// This is needed because Windows terminal doesn't properly send image data
|
||||||
|
// through bracketed paste, so we need to intercept the keypress and
|
||||||
|
// directly read from clipboard before the terminal handles it
|
||||||
|
if (keybind.match("input_paste", e)) {
|
||||||
|
const content = await Clipboard.read()
|
||||||
|
if (content?.mime.startsWith("image/")) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
await pasteImage({
|
||||||
}
|
filename: "clipboard",
|
||||||
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
|
mime: content.mime,
|
||||||
// This is needed because Windows terminal doesn't properly send image data
|
content: content.data,
|
||||||
// through bracketed paste, so we need to intercept the keypress and
|
|
||||||
// directly read from clipboard before the terminal handles it
|
|
||||||
if (keybind.match("input_paste", e)) {
|
|
||||||
const content = await Clipboard.read()
|
|
||||||
if (content?.mime.startsWith("image/")) {
|
|
||||||
e.preventDefault()
|
|
||||||
await pasteImage({
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
if (keybind.match("app_exit", e)) {
|
// If no image, let the default paste behavior continue
|
||||||
if (store.prompt.input === "") {
|
}
|
||||||
await exit()
|
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||||
// Don't preventDefault - let textarea potentially handle the event
|
input.clear()
|
||||||
e.preventDefault()
|
input.extmarks.clear()
|
||||||
return
|
setStore("prompt", {
|
||||||
}
|
input: "",
|
||||||
}
|
parts: [],
|
||||||
if (e.name === "!" && input.visualCursor.offset === 0) {
|
})
|
||||||
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
|
setStore("extmarkToPartIndex", new Map())
|
||||||
setStore("mode", "shell")
|
return
|
||||||
|
}
|
||||||
|
if (keybind.match("app_exit", e)) {
|
||||||
|
if (store.prompt.input === "") {
|
||||||
|
await exit()
|
||||||
|
// Don't preventDefault - let textarea potentially handle the event
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.mode === "shell") {
|
}
|
||||||
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
if (e.name === "!" && input.visualCursor.offset === 0) {
|
||||||
setStore("mode", "normal")
|
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
|
||||||
e.preventDefault()
|
setStore("mode", "shell")
|
||||||
return
|
e.preventDefault()
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
if (store.mode === "shell") {
|
||||||
if (!autocomplete.visible) {
|
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
||||||
if (
|
setStore("mode", "normal")
|
||||||
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
e.preventDefault()
|
||||||
(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
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Normalize line endings at the boundary
|
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
if (!autocomplete.visible) {
|
||||||
// Replace CRLF first, then any remaining CR
|
|
||||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
||||||
const pastedContent = normalizedText.trim()
|
|
||||||
if (!pastedContent) {
|
|
||||||
command.trigger("prompt.paste")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// trim ' from the beginning and end of the pasted content. just
|
|
||||||
// ' and nothing else
|
|
||||||
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
|
|
||||||
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/")) {
|
|
||||||
event.preventDefault()
|
|
||||||
const content = await Filesystem.readArrayBuffer(filepath)
|
|
||||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
|
||||||
.catch(() => {})
|
|
||||||
if (content) {
|
|
||||||
await pasteImage({
|
|
||||||
filename,
|
|
||||||
mime,
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
|
||||||
if (
|
if (
|
||||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
||||||
!sync.data.config.experimental?.disable_paste_summary
|
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
const direction = keybind.match("history_previous", e) ? -1 : 1
|
||||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force layout update and render for the pasted content
|
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
||||||
setTimeout(() => {
|
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
|
||||||
// setTimeout is a workaround and needs to be addressed properly
|
input.cursorOffset = input.plainText.length
|
||||||
if (!input || input.isDestroyed) return
|
}
|
||||||
input.getLayoutNode().markDirty()
|
}}
|
||||||
renderer.requestRender()
|
onSubmit={submit}
|
||||||
}, 0)
|
onPaste={async (event: PasteEvent) => {
|
||||||
}}
|
if (props.disabled) {
|
||||||
ref={(r: TextareaRenderable) => {
|
event.preventDefault()
|
||||||
input = r
|
return
|
||||||
if (promptPartTypeId === 0) {
|
}
|
||||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
|
||||||
}
|
// Normalize line endings at the boundary
|
||||||
props.ref?.(ref)
|
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||||
setTimeout(() => {
|
// Replace CRLF first, then any remaining CR
|
||||||
// setTimeout is a workaround and needs to be addressed properly
|
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
if (!input || input.isDestroyed) return
|
const pastedContent = normalizedText.trim()
|
||||||
input.cursorColor = theme.text
|
if (!pastedContent) {
|
||||||
}, 0)
|
command.trigger("prompt.paste")
|
||||||
}}
|
return
|
||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
}
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
|
||||||
cursorColor={theme.text}
|
// trim ' from the beginning and end of the pasted content. just
|
||||||
syntaxStyle={syntax()}
|
// ' and nothing else
|
||||||
/>
|
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
const isUrl = /^(https?):\/\//.test(filepath)
|
||||||
<text fg={highlight()}>
|
if (!isUrl) {
|
||||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
try {
|
||||||
</text>
|
const mime = Filesystem.mimeType(filepath)
|
||||||
<Show when={store.mode === "normal"}>
|
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/")) {
|
||||||
|
event.preventDefault()
|
||||||
|
const content = await Filesystem.readArrayBuffer(filepath)
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||||
|
.catch(() => {})
|
||||||
|
if (content) {
|
||||||
|
await pasteImage({
|
||||||
|
filename,
|
||||||
|
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">
|
||||||
|
<Switch>
|
||||||
|
<Match when={store.mode === "normal"}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={highlight()}>{Locale.titlecase(local.agent.current().name)} </text>
|
||||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||||
{local.model.parsed().model}
|
{local.model.parsed().model}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -1027,141 +1023,29 @@ export function Prompt(props: PromptProps) {
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
<Show when={status().type !== "retry"}>
|
||||||
</box>
|
<box gap={2} flexDirection="row">
|
||||||
</box>
|
<Show when={local.model.variant.list().length > 0}>
|
||||||
</box>
|
<text fg={theme.text}>
|
||||||
<box
|
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||||
height={1}
|
</text>
|
||||||
border={["left"]}
|
</Show>
|
||||||
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 flexDirection="row" justifyContent="space-between">
|
|
||||||
<Show when={status().type !== "idle"} fallback={<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"}>
|
|
||||||
<Show when={local.model.variant.list().length > 0}>
|
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
<text fg={theme.text}>
|
||||||
<text fg={theme.text}>
|
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
</text>
|
||||||
</text>
|
</box>
|
||||||
<text fg={theme.text}>
|
</Show>
|
||||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
</Match>
|
||||||
</text>
|
<Match when={store.mode === "shell"}>
|
||||||
</Match>
|
<text fg={highlight()}>
|
||||||
<Match when={store.mode === "shell"}>
|
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||||
<text fg={theme.text}>
|
</text>
|
||||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
</Match>
|
||||||
</text>
|
</Switch>
|
||||||
</Match>
|
</box>
|
||||||
</Switch>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,6 @@ export function Header() {
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
{...SplitBorder}
|
|
||||||
border={["left"]}
|
|
||||||
borderColor={theme.border}
|
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
backgroundColor={theme.backgroundPanel}
|
backgroundColor={theme.backgroundPanel}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { Dynamic } from "solid-js/web"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useRoute, useRouteData } from "@tui/context/route"
|
import { useRoute, useRouteData } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
import {
|
import {
|
||||||
|
|
@ -1046,7 +1046,12 @@ export function Session() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
<box
|
||||||
|
flexGrow={1}
|
||||||
|
gap={1}
|
||||||
|
paddingX={sidebarVisible() && wide() ? 2 : 0}
|
||||||
|
paddingY={sidebarVisible() && wide() ? 1 : 0}
|
||||||
|
>
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
|
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
@ -1057,7 +1062,7 @@ export function Session() {
|
||||||
paddingRight: showScrollbar() ? 1 : 0,
|
paddingRight: showScrollbar() ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
verticalScrollbarOptions={{
|
verticalScrollbarOptions={{
|
||||||
paddingLeft: 1,
|
paddingLeft: 0,
|
||||||
visible: showScrollbar(),
|
visible: showScrollbar(),
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: theme.backgroundElement,
|
backgroundColor: theme.backgroundElement,
|
||||||
|
|
@ -1251,13 +1256,7 @@ function UserMessage(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={text()}>
|
<Show when={text()}>
|
||||||
<box
|
<box id={props.message.id} marginTop={props.index === 0 ? 0 : 1}>
|
||||||
id={props.message.id}
|
|
||||||
border={["left"]}
|
|
||||||
borderColor={color()}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
marginTop={props.index === 0 ? 0 : 1}
|
|
||||||
>
|
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => {
|
onMouseOver={() => {
|
||||||
setHover(true)
|
setHover(true)
|
||||||
|
|
@ -1268,9 +1267,15 @@ function UserMessage(props: {
|
||||||
onMouseUp={props.onMouseUp}
|
onMouseUp={props.onMouseUp}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundMenu}
|
||||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
paddingLeft={1}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={color()}
|
||||||
|
customBorderChars={{
|
||||||
|
...EmptyBorder,
|
||||||
|
vertical: "▎",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text fg={theme.text}>{text()?.text}</text>
|
<text fg={theme.text}>{text()?.text}</text>
|
||||||
<Show when={files().length}>
|
<Show when={files().length}>
|
||||||
|
|
@ -1331,6 +1336,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||||
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
|
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
|
||||||
|
|
||||||
const final = createMemo(() => {
|
const final = createMemo(() => {
|
||||||
|
if (props.message.error) return true
|
||||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1342,6 +1348,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||||
return props.message.time.completed - user.time.created
|
return props.message.time.completed - user.time.created
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const interrupted = createMemo(() => props.message.error?.name === "MessageAbortedError")
|
||||||
|
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1362,14 +1370,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
|
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
|
||||||
<box paddingTop={1} paddingLeft={3}>
|
<box paddingTop={1} paddingLeft={2}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{keybind.print("session_child_first")}
|
{keybind.print("session_child_first")}
|
||||||
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
|
<Show when={props.message.error && !interrupted()}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
|
|
@ -1384,28 +1392,26 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
|
<Match when={props.last || final()}>
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={2} marginTop={1}>
|
||||||
<text marginTop={1}>
|
<Show when={!duration()}>
|
||||||
<span
|
<Spinner color={local.agent.color(props.message.agent)}>
|
||||||
style={{
|
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||||
fg:
|
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||||
props.message.error?.name === "MessageAbortedError"
|
</Spinner>
|
||||||
? theme.textMuted
|
</Show>
|
||||||
: local.agent.color(props.message.agent),
|
<Show when={duration()}>
|
||||||
}}
|
<text fg={interrupted() ? theme.textMuted : theme.text}>
|
||||||
>
|
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||||
▣{" "}
|
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||||
</span>{" "}
|
<Show when={!interrupted()}>
|
||||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||||
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
</Show>
|
||||||
<Show when={duration()}>
|
<Show when={interrupted()}>
|
||||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
<span style={{ fg: theme.textMuted }}> · interrupted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.message.error?.name === "MessageAbortedError"}>
|
</text>
|
||||||
<span style={{ fg: theme.textMuted }}> · interrupted</span>
|
</Show>
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
@ -1457,7 +1463,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
return (
|
return (
|
||||||
<Show when={props.part.text.trim()}>
|
<Show when={props.part.text.trim()}>
|
||||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
<box id={"text-" + props.part.id} paddingLeft={2} marginTop={1} flexShrink={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||||
<markdown
|
<markdown
|
||||||
|
|
@ -1625,7 +1631,7 @@ function GenericTool(props: ToolProps<any>) {
|
||||||
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
|
<text paddingLeft={2} fg={props.when ? theme.textMuted : theme.text}>
|
||||||
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
|
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
|
||||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1676,7 +1682,7 @@ function InlineTool(props: {
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
marginTop={margin()}
|
marginTop={margin()}
|
||||||
paddingLeft={3}
|
paddingLeft={2}
|
||||||
onMouseOver={() => props.onClick && setHover(true)}
|
onMouseOver={() => props.onClick && setHover(true)}
|
||||||
onMouseOut={() => setHover(false)}
|
onMouseOut={() => setHover(false)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
|
|
@ -1711,7 +1717,7 @@ function InlineTool(props: {
|
||||||
<Spinner color={fg()} children={props.children} />
|
<Spinner color={fg()} children={props.children} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
<text paddingLeft={2} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||||
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1738,15 +1744,13 @@ function BlockTool(props: {
|
||||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
|
marginTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
marginTop={1}
|
paddingRight={2}
|
||||||
gap={1}
|
gap={1}
|
||||||
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
|
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={theme.background}
|
|
||||||
onMouseOver={() => props.onClick && setHover(true)}
|
onMouseOver={() => props.onClick && setHover(true)}
|
||||||
onMouseOut={() => setHover(false)}
|
onMouseOut={() => setHover(false)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
|
|
@ -1757,7 +1761,7 @@ function BlockTool(props: {
|
||||||
<Show
|
<Show
|
||||||
when={props.spinner}
|
when={props.spinner}
|
||||||
fallback={
|
fallback={
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
<text paddingLeft={2} fg={theme.textMuted}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
|
|
@ -1905,8 +1909,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
<For each={loaded()}>
|
<For each={loaded()}>
|
||||||
{(filepath) => (
|
{(filepath) => (
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={2}>
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
<text paddingLeft={2} fg={theme.textMuted}>
|
||||||
↳ Loaded {normalizePath(filepath)}
|
↳ Loaded {normalizePath(filepath)}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
@ -2056,7 +2060,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diff !== undefined}>
|
<Match when={props.metadata.diff !== undefined}>
|
||||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||||
<box paddingLeft={1}>
|
<box>
|
||||||
<diff
|
<diff
|
||||||
diff={diffContent()}
|
diff={diffContent()}
|
||||||
view={view()}
|
view={view()}
|
||||||
|
|
@ -2066,6 +2070,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
width="100%"
|
width="100%"
|
||||||
wrapMode={ctx.diffWrapMode()}
|
wrapMode={ctx.diffWrapMode()}
|
||||||
fg={theme.text}
|
fg={theme.text}
|
||||||
|
bg={theme.background}
|
||||||
addedBg={theme.diffAddedBg}
|
addedBg={theme.diffAddedBg}
|
||||||
removedBg={theme.diffRemovedBg}
|
removedBg={theme.diffRemovedBg}
|
||||||
contextBg={theme.diffContextBg}
|
contextBg={theme.diffContextBg}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue