Просмотр исходного кода

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

Aiden Cline 2 месяцев назад
Родитель
Сommit
b8cfd69acf
1 измененных файлов с 238 добавлено и 51 удалено
  1. 238 51
      packages/tui/src/component/error-component.tsx

+ 238 - 51
packages/tui/src/component/error-component.tsx

@@ -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 { createSignal } from "solid-js"
+import { createSignal, For, Show } from "solid-js"
 import { getScrollAcceleration } from "../util/scroll"
 import { useClipboard } from "../context/clipboard"
 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 exit = useExit()
   const clipboard = useClipboard()
-
-  useKeyboard((evt) => {
-    if (evt.ctrl && evt.name === "c") {
-      void exit()
-    }
-  })
   const [copied, setCopied] = createSignal(false)
 
-  const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
-
-  // Choose safe fallback colors per mode since theme context may not be available
+  // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
+  // theme context may be the thing that crashed.
   const isLight = props.mode === "light"
-  const colors = {
-    bg: isLight ? "#ffffff" : "#0a0a0a",
-    text: isLight ? "#1a1a1a" : "#eeeeee",
-    muted: isLight ? "#8a8a8a" : "#808080",
-    primary: isLight ? "#3b7dd8" : "#fab283",
-  }
+  const colors = isLight
+    ? {
+        bg: "#ffffff",
+        element: "#f5f5f5",
+        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",
+      }
 
-  if (props.error.message) {
-    issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
-  }
+  const message = props.error.message || "An unknown error occurred."
+  const stack = props.error.stack || "No stack trace available."
+  const issueURL = buildIssueURL(message, stack)
 
-  if (props.error.stack) {
-    issueURL.searchParams.set(
-      "description",
-      "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
-    )
+  const copyReport = () => {
+    void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
   }
 
-  issueURL.searchParams.set("opencode-version", InstallationVersion)
+  const actions = [
+    { 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
 
-  const copyIssueURL = () => {
-    void clipboard.write?.(issueURL.toString()).then(() => {
-      setCopied(true)
-    })
-  }
+  useKeyboard((evt) => {
+    if (evt.ctrl && evt.name === "c") return exit()
+    if (evt.name === "return") {
+      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()
+  })
+
+  // Responsive thresholds.
+  const contentWidth = () => Math.min(84, Math.max(24, term().width - 4))
+  const showSubtext = () => term().height >= 18
+  const showFooter = () => term().height >= 20
 
   return (
-    <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
-      <box flexDirection="row" gap={1} alignItems="center">
-        <text attributes={TextAttributes.BOLD} fg={colors.text}>
-          Please report an issue.
-        </text>
-        <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
-          <text attributes={TextAttributes.BOLD} fg={colors.bg}>
-            Copy issue URL (exception info pre-filled)
+    <box
+      width={term().width}
+      height={term().height}
+      backgroundColor={colors.bg}
+      flexDirection="column"
+      alignItems="center"
+    >
+      <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>
+          <Show when={showSubtext()}>
+            <text fg={colors.muted}>An unexpected error stopped the session.</text>
+          </Show>
         </box>
-        {copied() && <text fg={colors.muted}>Successfully copied</text>}
-      </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>
+
+        {/* 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>
-        <box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
-          <text fg={colors.bg}>Exit</text>
+
+        {/* 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>
-      <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>
   )
 }
+
+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}`
+}