Compare commits
1 commit
dev
...
retry-layo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
import { formatDuration } from "../../util/format"
|
|
||||||
import { createColors, createFrames } from "../../ui/spinner"
|
import { createColors, createFrames } from "../../ui/spinner"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
||||||
|
|
@ -56,6 +55,7 @@ import { useTuiConfig } from "../../config"
|
||||||
import { usePromptWorkspace } from "./workspace"
|
import { usePromptWorkspace } from "./workspace"
|
||||||
import { usePromptMove } from "./move"
|
import { usePromptMove } from "./move"
|
||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
|
import { PromptInterruptHint, PromptRetryStatus } from "./retry-status"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
|
|
@ -159,6 +159,10 @@ export function Prompt(props: PromptProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
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 history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
|
|
@ -1512,79 +1516,22 @@ export function Prompt(props: PromptProps) {
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
|
flexShrink={1}
|
||||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
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}>
|
<box marginLeft={1}>
|
||||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
<box flexDirection="row" gap={1} flexShrink={1}>
|
||||||
{(() => {
|
<Show when={retry()} keyed>
|
||||||
const retry = createMemo(() => {
|
{(retry) => <PromptRetryStatus status={retry} theme={theme} dialog={dialog} />}
|
||||||
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>
|
</Show>
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
<PromptInterruptHint armed={store.interrupt > 0} theme={theme} />
|
||||||
esc{" "}
|
|
||||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
|
||||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={workspace.notice()}>
|
<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