mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 02:18:29 +00:00
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:
parent
77429f5982
commit
c6932f3508
3 changed files with 115 additions and 65 deletions
|
|
@ -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()}>
|
||||
|
|
|
|||
54
packages/tui/src/component/prompt/retry-status.tsx
Normal file
54
packages/tui/src/component/prompt/retry-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
packages/tui/test/prompt/retry-status.test.tsx
Normal file
49
packages/tui/test/prompt/retry-status.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue