feat(tui): redesign crash screen (#33549)

This commit is contained in:
Aiden Cline 2026-06-23 18:23:15 -05:00 committed by GitHub
parent d2305d4a76
commit b8cfd69acf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,6 +1,7 @@
import { TextAttributes } from "@opentui/core" import { release } from "node:os"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal } from "solid-js" import { createSignal, For, Show } from "solid-js"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
@ -10,70 +11,256 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const term = useTerminalDimensions() const term = useTerminalDimensions()
const exit = useExit() const exit = useExit()
const clipboard = useClipboard() const clipboard = useClipboard()
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
void exit()
}
})
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml") // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
// Choose safe fallback colors per mode since theme context may not be available
const isLight = props.mode === "light" const isLight = props.mode === "light"
const colors = { const colors = isLight
bg: isLight ? "#ffffff" : "#0a0a0a", ? {
text: isLight ? "#1a1a1a" : "#eeeeee", bg: "#ffffff",
muted: isLight ? "#8a8a8a" : "#808080", element: "#f5f5f5",
primary: isLight ? "#3b7dd8" : "#fab283", borderSubtle: "#d4d4d4",
text: "#1a1a1a",
muted: "#8a8a8a",
primary: "#3b7dd8",
onPrimary: "#ffffff",
error: "#d1383d",
success: "#3d9a57",
}
: {
bg: "#0a0a0a",
element: "#1e1e1e",
borderSubtle: "#3c3c3c",
text: "#eeeeee",
muted: "#808080",
primary: "#fab283",
onPrimary: "#0a0a0a",
error: "#e06c75",
success: "#7fd88f",
}
const message = props.error.message || "An unknown error occurred."
const stack = props.error.stack || "No stack trace available."
const issueURL = buildIssueURL(message, stack)
const copyReport = () => {
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
} }
if (props.error.message) { const actions = [
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`) { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
} { key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
const [selected, setSelected] = createSignal(0)
const move = (delta: number) => setSelected((prev) => (prev + delta + actions.length) % actions.length)
let scroll: ScrollBoxRenderable | undefined
if (props.error.stack) { useKeyboard((evt) => {
issueURL.searchParams.set( if (evt.ctrl && evt.name === "c") return exit()
"description", if (evt.name === "return") {
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```", evt.preventDefault()
) evt.stopPropagation()
} return actions[selected()].onUse()
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
return move(-1)
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
return move(1)
}
if (evt.name === "tab") {
evt.preventDefault()
evt.stopPropagation()
return move(evt.shift ? -1 : 1)
}
// Vertical keys scroll the stack trace; buttons navigate horizontally.
if (evt.name === "up") return scroll?.scrollBy(-1)
if (evt.name === "down") return scroll?.scrollBy(1)
if (evt.name === "pageup" && scroll) return scroll.scrollBy(-scroll.height)
if (evt.name === "pagedown" && scroll) return scroll.scrollBy(scroll.height)
if (evt.name === "home" && scroll) return scroll.scrollTo(0)
if (evt.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
if (evt.name === "q") return exit()
if (evt.name === "c") return copyReport()
if (evt.name === "r") return props.reset()
})
issueURL.searchParams.set("opencode-version", InstallationVersion) // Responsive thresholds.
const contentWidth = () => Math.min(84, Math.max(24, term().width - 4))
const copyIssueURL = () => { const showSubtext = () => term().height >= 18
void clipboard.write?.(issueURL.toString()).then(() => { const showFooter = () => term().height >= 20
setCopied(true)
})
}
return ( return (
<box flexDirection="column" gap={1} backgroundColor={colors.bg}> <box
<box flexDirection="row" gap={1} alignItems="center"> width={term().width}
<text attributes={TextAttributes.BOLD} fg={colors.text}> height={term().height}
Please report an issue. backgroundColor={colors.bg}
</text> flexDirection="column"
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}> alignItems="center"
<text attributes={TextAttributes.BOLD} fg={colors.bg}> >
Copy issue URL (exception info pre-filled) <box
width={contentWidth()}
flexGrow={1}
flexDirection="column"
paddingTop={1}
paddingBottom={1}
gap={1}
>
{/* Headline */}
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text attributes={TextAttributes.BOLD} fg={colors.text}>
opencode crashed
</text> </text>
<Show when={showSubtext()}>
<text fg={colors.muted}>An unexpected error stopped the session.</text>
</Show>
</box> </box>
{copied() && <text fg={colors.muted}>Successfully copied</text>}
{/* Error message panel */}
<box
flexShrink={0}
border
borderStyle="rounded"
borderColor={colors.error}
title=" Error "
titleColor={colors.error}
paddingLeft={2}
paddingRight={2}
>
<text fg={colors.text}>{message}</text>
</box>
{/* Actions */}
<box flexDirection="row" flexWrap="wrap" justifyContent="center" gap={2} rowGap={1} flexShrink={0}>
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const isCopied = () => action.copy && copied()
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
minWidth={15}
alignItems="center"
paddingLeft={2}
paddingRight={2}
>
<text
attributes={TextAttributes.BOLD}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
</box>
<text fg={isSelected() ? colors.primary : colors.muted}>{action.key}</text>
</box>
)
}}
</For>
</box>
{/* Stack trace */}
<box
flexGrow={1}
flexBasis={0}
minHeight={3}
border
borderStyle="rounded"
borderColor={colors.borderSubtle}
title=" Stack trace "
titleColor={colors.muted}
bottomTitle=" ↑↓ scroll "
bottomTitleAlignment="right"
paddingLeft={1}
paddingRight={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
scrollAcceleration={getScrollAcceleration()}
>
<text fg={colors.muted}>{stack}</text>
</scrollbox>
</box>
{/* Footer */}
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copied()
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>opencode {InstallationVersion}</text>
</box>
</Show>
</box> </box>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={colors.text}>A fatal error occurred!</text>
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>
<scrollbox height={Math.floor(term().height * 0.7)} scrollAcceleration={getScrollAcceleration()}>
<text fg={colors.muted}>{props.error.stack}</text>
</scrollbox>
<text fg={colors.text}>{props.error.message}</text>
</box> </box>
) )
} }
function buildIssueURL(message: string, stack: string) {
// Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue
// form opens pre-filled. Populating os/terminal/reproduce keeps the report past
// the contributing-guidelines compliance check, which pushes for system info.
const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
url.searchParams.set("title", `TUI crash: ${message}`)
url.searchParams.set("opencode-version", InstallationVersion)
url.searchParams.set("os", describeOS())
url.searchParams.set("terminal", describeTerminal())
url.searchParams.set(
"reproduce",
"Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
)
// Budget the stack against the fully URL-encoded length (not the raw length) so
// the final link stays under GitHub's practical limit; flag truncation so a
// clipped trace is obvious. searchParams.set handles encoding without throwing,
// so measuring url.toString() is both correct and safe on any input.
const MAX_URL_LENGTH = 6000
const marker = "\n... (truncated)"
const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
setBody(stack)
if (url.toString().length <= MAX_URL_LENGTH) return url
// Largest raw stack prefix whose encoded URL (with the marker) still fits.
let lo = 0
let hi = stack.length
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
setBody(stack.slice(0, mid) + marker)
if (url.toString().length <= MAX_URL_LENGTH) lo = mid
else hi = mid - 1
}
setBody(stack.slice(0, lo) + marker)
return url
}
function describeOS() {
const name =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
return `${name} ${release()} (${process.arch})`
}
function describeTerminal() {
const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
return `${program}${version}${multiplexer}`
}