diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 115c10296e..21f0f9cdc2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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"} > - + [⋯]}> - - {(() => { - 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 ( - - - {retryText()} - - - ) - })()} + + + {(retry) => } + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - + 0} theme={theme} /> diff --git a/packages/tui/src/component/prompt/retry-status.tsx b/packages/tui/src/component/prompt/retry-status.tsx new file mode 100644 index 0000000000..bc81ad2932 --- /dev/null +++ b/packages/tui/src/component/prompt/retry-status.tsx @@ -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 + +export function PromptRetryStatus(props: { status: RetryStatus; theme: Theme; dialog: ReturnType }) { + 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 ( + { + if (isTruncated()) void DialogAlert.show(props.dialog, "Retry Error", props.status.message) + }} + > + + {retryText()} + + + ) +} + +export function PromptInterruptHint(props: { armed: boolean; theme: Theme }) { + return ( + + esc{" "} + + {props.armed ? "again to interrupt" : "interrupt"} + + + ) +} diff --git a/packages/tui/test/prompt/retry-status.test.tsx b/packages/tui/test/prompt/retry-status.test.tsx new file mode 100644 index 0000000000..7e0dc3d6c6 --- /dev/null +++ b/packages/tui/test/prompt/retry-status.test.tsx @@ -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 ( + + + + + [⋯] + + + + + + + + + ) +} + +test("retry status keeps the interrupt hint on one line", async () => { + const app = await testRender(() => , { 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() + } +})