error-component.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
  2. import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
  3. import { createSignal, For, Show } from "solid-js"
  4. import { getScrollAcceleration } from "../util/scroll"
  5. import { useClipboard } from "../context/clipboard"
  6. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  7. import { useExit } from "../context/exit"
  8. import { describeOS, describeTerminal } from "../util/system"
  9. export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
  10. const term = useTerminalDimensions()
  11. const exit = useExit()
  12. const clipboard = useClipboard()
  13. const [copied, setCopied] = createSignal(false)
  14. // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
  15. // theme context may be the thing that crashed.
  16. const isLight = props.mode === "light"
  17. const colors = isLight
  18. ? {
  19. bg: "#ffffff",
  20. element: "#f5f5f5",
  21. borderSubtle: "#d4d4d4",
  22. text: "#1a1a1a",
  23. muted: "#8a8a8a",
  24. primary: "#3b7dd8",
  25. onPrimary: "#ffffff",
  26. error: "#d1383d",
  27. success: "#3d9a57",
  28. }
  29. : {
  30. bg: "#0a0a0a",
  31. element: "#1e1e1e",
  32. borderSubtle: "#3c3c3c",
  33. text: "#eeeeee",
  34. muted: "#808080",
  35. primary: "#fab283",
  36. onPrimary: "#0a0a0a",
  37. error: "#e06c75",
  38. success: "#7fd88f",
  39. }
  40. const message = props.error.message || "An unknown error occurred."
  41. const stack = props.error.stack || "No stack trace available."
  42. const issueURL = buildIssueURL(message, stack)
  43. const copyReport = () => {
  44. void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
  45. }
  46. const actions = [
  47. { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
  48. { key: "r", label: () => "Restart", onUse: props.reset },
  49. { key: "q", label: () => "Quit", onUse: () => exit() },
  50. ]
  51. const [selected, setSelected] = createSignal(0)
  52. const move = (delta: number) => setSelected((prev) => (prev + delta + actions.length) % actions.length)
  53. let scroll: ScrollBoxRenderable | undefined
  54. useKeyboard((evt) => {
  55. if (evt.ctrl && evt.name === "c") return exit()
  56. if (evt.name === "return") {
  57. evt.preventDefault()
  58. evt.stopPropagation()
  59. return actions[selected()].onUse()
  60. }
  61. if (evt.name === "left") {
  62. evt.preventDefault()
  63. evt.stopPropagation()
  64. return move(-1)
  65. }
  66. if (evt.name === "right") {
  67. evt.preventDefault()
  68. evt.stopPropagation()
  69. return move(1)
  70. }
  71. if (evt.name === "tab") {
  72. evt.preventDefault()
  73. evt.stopPropagation()
  74. return move(evt.shift ? -1 : 1)
  75. }
  76. // Vertical keys scroll the stack trace; buttons navigate horizontally.
  77. if (evt.name === "up") return scroll?.scrollBy(-1)
  78. if (evt.name === "down") return scroll?.scrollBy(1)
  79. if (evt.name === "pageup" && scroll) return scroll.scrollBy(-scroll.height)
  80. if (evt.name === "pagedown" && scroll) return scroll.scrollBy(scroll.height)
  81. if (evt.name === "home" && scroll) return scroll.scrollTo(0)
  82. if (evt.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
  83. if (evt.name === "q") return exit()
  84. if (evt.name === "c") return copyReport()
  85. if (evt.name === "r") return props.reset()
  86. })
  87. // Responsive thresholds.
  88. const contentWidth = () => Math.min(84, Math.max(24, term().width - 4))
  89. const showSubtext = () => term().height >= 18
  90. const showFooter = () => term().height >= 20
  91. return (
  92. <box
  93. width={term().width}
  94. height={term().height}
  95. backgroundColor={colors.bg}
  96. flexDirection="column"
  97. alignItems="center"
  98. >
  99. <box width={contentWidth()} flexGrow={1} flexDirection="column" paddingTop={1} paddingBottom={1} gap={1}>
  100. {/* Headline */}
  101. <box flexDirection="column" alignItems="center" flexShrink={0}>
  102. <text attributes={TextAttributes.BOLD} fg={colors.text}>
  103. opencode crashed
  104. </text>
  105. <Show when={showSubtext()}>
  106. <text fg={colors.muted}>An unexpected error stopped the session.</text>
  107. </Show>
  108. </box>
  109. {/* Error message panel */}
  110. <box
  111. flexShrink={0}
  112. border
  113. borderStyle="rounded"
  114. borderColor={colors.error}
  115. title=" Error "
  116. titleColor={colors.error}
  117. paddingLeft={2}
  118. paddingRight={2}
  119. >
  120. <text fg={colors.text}>{message}</text>
  121. </box>
  122. {/* Actions */}
  123. <box flexDirection="row" flexWrap="wrap" justifyContent="center" gap={2} rowGap={1} flexShrink={0}>
  124. <For each={actions}>
  125. {(action, index) => {
  126. const isSelected = () => selected() === index()
  127. const isCopied = () => action.copy && copied()
  128. return (
  129. <box flexDirection="column" alignItems="center" flexShrink={0}>
  130. <box
  131. onMouseDown={() => setSelected(index())}
  132. onMouseUp={() => action.onUse()}
  133. backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
  134. minWidth={15}
  135. alignItems="center"
  136. paddingLeft={2}
  137. paddingRight={2}
  138. >
  139. <text
  140. attributes={TextAttributes.BOLD}
  141. fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
  142. >
  143. {action.label()}
  144. </text>
  145. </box>
  146. <text fg={isSelected() ? colors.primary : colors.muted}>{action.key}</text>
  147. </box>
  148. )
  149. }}
  150. </For>
  151. </box>
  152. {/* Stack trace */}
  153. <box
  154. flexGrow={1}
  155. flexBasis={0}
  156. minHeight={3}
  157. border
  158. borderStyle="rounded"
  159. borderColor={colors.borderSubtle}
  160. title=" Stack trace "
  161. titleColor={colors.muted}
  162. bottomTitle=" ↑↓ scroll "
  163. bottomTitleAlignment="right"
  164. paddingLeft={1}
  165. paddingRight={1}
  166. >
  167. <scrollbox
  168. ref={(element: ScrollBoxRenderable) => (scroll = element)}
  169. flexGrow={1}
  170. scrollAcceleration={getScrollAcceleration()}
  171. >
  172. <text fg={colors.muted}>{stack}</text>
  173. </scrollbox>
  174. </box>
  175. {/* Footer */}
  176. <Show when={showFooter()}>
  177. <box flexDirection="column" alignItems="center" flexShrink={0}>
  178. <text fg={colors.muted}>
  179. {copied()
  180. ? "Report copied — paste it into a new GitHub issue."
  181. : "Copy the report and open a GitHub issue to help us fix this."}
  182. </text>
  183. <text fg={colors.muted}>opencode {InstallationVersion}</text>
  184. </box>
  185. </Show>
  186. </box>
  187. </box>
  188. )
  189. }
  190. function buildIssueURL(message: string, stack: string) {
  191. // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue
  192. // form opens pre-filled. Populating os/terminal/reproduce keeps the report past
  193. // the contributing-guidelines compliance check, which pushes for system info.
  194. const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
  195. url.searchParams.set("title", `TUI crash: ${message}`)
  196. url.searchParams.set("opencode-version", InstallationVersion)
  197. url.searchParams.set("os", describeOS())
  198. url.searchParams.set("terminal", describeTerminal())
  199. url.searchParams.set(
  200. "reproduce",
  201. "Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
  202. )
  203. // Budget the stack against the fully URL-encoded length (not the raw length) so
  204. // the final link stays under GitHub's practical limit; flag truncation so a
  205. // clipped trace is obvious. searchParams.set handles encoding without throwing,
  206. // so measuring url.toString() is both correct and safe on any input.
  207. const MAX_URL_LENGTH = 6000
  208. const marker = "\n... (truncated)"
  209. const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
  210. const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
  211. setBody(stack)
  212. if (url.toString().length <= MAX_URL_LENGTH) return url
  213. // Largest raw stack prefix whose encoded URL (with the marker) still fits.
  214. let lo = 0
  215. let hi = stack.length
  216. while (lo < hi) {
  217. const mid = Math.ceil((lo + hi) / 2)
  218. setBody(stack.slice(0, mid) + marker)
  219. if (url.toString().length <= MAX_URL_LENGTH) lo = mid
  220. else hi = mid - 1
  221. }
  222. setBody(stack.slice(0, lo) + marker)
  223. return url
  224. }