fix(tui): keep retry interrupt hint horizontal

Let retry status content shrink and truncate before the fixed-width interrupt hint, preventing narrow terminal layouts from wrapping the hint one character per line.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Aiden Cline 2026-07-08 14:53:19 +00:00
parent 77429f5982
commit c6932f3508
3 changed files with 115 additions and 65 deletions

View file

@ -40,7 +40,6 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { formatDuration } from "../../util/format"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@ -56,6 +55,7 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { PromptInterruptHint, PromptRetryStatus } from "./retry-status"
registerOpencodeSpinner()
@ -159,6 +159,10 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const retry = createMemo(() => {
const value = status()
if (value.type === "retry") return value
})
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@ -1512,79 +1516,22 @@ export function Prompt(props: PromptProps) {
flexDirection="row"
gap={1}
flexGrow={1}
flexShrink={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box flexShrink={1} 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()) {
void 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 flexDirection="row" gap={1} flexShrink={1}>
<Show when={retry()} keyed>
{(retry) => <PromptRetryStatus status={retry} theme={theme} dialog={dialog} />}
</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>
<PromptInterruptHint armed={store.interrupt > 0} theme={theme} />
</box>
</Match>
<Match when={workspace.notice()}>

View file

@ -0,0 +1,54 @@
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import type { Theme } from "../../theme"
import { formatDuration } from "../../util/format"
import { DialogAlert } from "../../ui/dialog-alert"
import type { useDialog } from "../../ui/dialog"
type RetryStatus = Extract<SessionStatus, { type: "retry" }>
export function PromptRetryStatus(props: { status: RetryStatus; theme: Theme; dialog: ReturnType<typeof useDialog> }) {
const message = createMemo(() => {
if (props.status.message.includes("exceeded your current quota") && props.status.message.includes("gemini"))
return "gemini is way too hot right now"
if (props.status.message.length > 80) return props.status.message.slice(0, 80) + "..."
return props.status.message
})
const isTruncated = createMemo(() => props.status.message.length > 120)
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
if (props.status.next) setSeconds(Math.round((props.status.next - Date.now()) / 1000))
}, 1000)
onCleanup(() => clearInterval(timer))
})
const retryText = createMemo(() => {
const duration = formatDuration(seconds())
return `${message()}${isTruncated() ? " (click to expand)" : ""} [retrying ${duration ? `in ${duration} ` : ""}attempt #${props.status.attempt}]`
})
return (
<box
flexShrink={1}
onMouseUp={() => {
if (isTruncated()) void DialogAlert.show(props.dialog, "Retry Error", props.status.message)
}}
>
<text fg={props.theme.error} wrapMode="none" truncate>
{retryText()}
</text>
</box>
)
}
export function PromptInterruptHint(props: { armed: boolean; theme: Theme }) {
return (
<text fg={props.armed ? props.theme.primary : props.theme.text} wrapMode="none" flexShrink={0}>
esc{" "}
<span style={{ fg: props.armed ? props.theme.primary : props.theme.textMuted }}>
{props.armed ? "again to interrupt" : "interrupt"}
</span>
</text>
)
}

View file

@ -0,0 +1,49 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { PromptInterruptHint, PromptRetryStatus } from "../../src/component/prompt/retry-status"
import { RGBA } from "@opentui/core"
import type { Theme } from "../../src/theme"
function RetryRow() {
const theme = {
error: RGBA.fromInts(255, 0, 0, 255),
primary: RGBA.fromInts(255, 255, 255, 255),
text: RGBA.fromInts(255, 255, 255, 255),
textMuted: RGBA.fromInts(128, 128, 128, 255),
} as Theme
return (
<box width="100%" flexDirection="row" gap={1}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} justifyContent="space-between">
<box flexShrink={1} flexDirection="row" gap={1}>
<box marginLeft={1} flexShrink={0}>
<text>[]</text>
</box>
<box flexDirection="row" gap={1} flexShrink={1}>
<PromptRetryStatus
status={{ type: "retry", message: "Too Many Requests", attempt: 10, next: Date.now() + 824_000 }}
theme={theme}
dialog={{} as never}
/>
</box>
</box>
</box>
<PromptInterruptHint armed={false} theme={theme} />
</box>
)
}
test("retry status keeps the interrupt hint on one line", async () => {
const app = await testRender(() => <RetryRow />, { width: 50, height: 4 })
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Too Many Reque")
expect(frame).toContain("esc interrupt")
expect(frame.split("\n").filter((line) => line.trim()).length).toBe(1)
} finally {
app.renderer.destroy()
}
})