app.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
  2. import { Clipboard } from "@tui/util/clipboard"
  3. import { TextAttributes } from "@opentui/core"
  4. import { RouteProvider, useRoute } from "@tui/context/route"
  5. import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
  6. import { Installation } from "@/installation"
  7. import { Global } from "@/global"
  8. import { Flag } from "@/flag/flag"
  9. import { DialogProvider, useDialog } from "@tui/ui/dialog"
  10. import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
  11. import { SDKProvider, useSDK } from "@tui/context/sdk"
  12. import { SyncProvider, useSync } from "@tui/context/sync"
  13. import { LocalProvider, useLocal } from "@tui/context/local"
  14. import { DialogModel, useConnected } from "@tui/component/dialog-model"
  15. import { DialogMcp } from "@tui/component/dialog-mcp"
  16. import { DialogStatus } from "@tui/component/dialog-status"
  17. import { DialogThemeList } from "@tui/component/dialog-theme-list"
  18. import { DialogHelp } from "./ui/dialog-help"
  19. import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
  20. import { DialogAgent } from "@tui/component/dialog-agent"
  21. import { DialogSessionList } from "@tui/component/dialog-session-list"
  22. import { KeybindProvider } from "@tui/context/keybind"
  23. import { ThemeProvider, useTheme } from "@tui/context/theme"
  24. import { Home } from "@tui/routes/home"
  25. import { Session } from "@tui/routes/session"
  26. import { PromptHistoryProvider } from "./component/prompt/history"
  27. import { PromptStashProvider } from "./component/prompt/stash"
  28. import { DialogAlert } from "./ui/dialog-alert"
  29. import { ToastProvider, useToast } from "./ui/toast"
  30. import { ExitProvider, useExit } from "./context/exit"
  31. import { Session as SessionApi } from "@/session"
  32. import { TuiEvent } from "./event"
  33. import { KVProvider, useKV } from "./context/kv"
  34. import { Provider } from "@/provider/provider"
  35. import { ArgsProvider, useArgs, type Args } from "./context/args"
  36. import open from "open"
  37. import { PromptRefProvider, usePromptRef } from "./context/prompt"
  38. async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
  39. // can't set raw mode if not a TTY
  40. if (!process.stdin.isTTY) return "dark"
  41. return new Promise((resolve) => {
  42. let timeout: NodeJS.Timeout
  43. const cleanup = () => {
  44. process.stdin.setRawMode(false)
  45. process.stdin.removeListener("data", handler)
  46. clearTimeout(timeout)
  47. }
  48. const handler = (data: Buffer) => {
  49. const str = data.toString()
  50. const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
  51. if (match) {
  52. cleanup()
  53. const color = match[1]
  54. // Parse RGB values from color string
  55. // Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
  56. let r = 0,
  57. g = 0,
  58. b = 0
  59. if (color.startsWith("rgb:")) {
  60. const parts = color.substring(4).split("/")
  61. r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
  62. g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
  63. b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
  64. } else if (color.startsWith("#")) {
  65. r = parseInt(color.substring(1, 3), 16)
  66. g = parseInt(color.substring(3, 5), 16)
  67. b = parseInt(color.substring(5, 7), 16)
  68. } else if (color.startsWith("rgb(")) {
  69. const parts = color.substring(4, color.length - 1).split(",")
  70. r = parseInt(parts[0])
  71. g = parseInt(parts[1])
  72. b = parseInt(parts[2])
  73. }
  74. // Calculate luminance using relative luminance formula
  75. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  76. // Determine if dark or light based on luminance threshold
  77. resolve(luminance > 0.5 ? "light" : "dark")
  78. }
  79. }
  80. process.stdin.setRawMode(true)
  81. process.stdin.on("data", handler)
  82. process.stdout.write("\x1b]11;?\x07")
  83. timeout = setTimeout(() => {
  84. cleanup()
  85. resolve("dark")
  86. }, 1000)
  87. })
  88. }
  89. export function tui(input: { url: string; args: Args; onExit?: () => Promise<void> }) {
  90. // promise to prevent immediate exit
  91. return new Promise<void>(async (resolve) => {
  92. const mode = await getTerminalBackgroundColor()
  93. const onExit = async () => {
  94. await input.onExit?.()
  95. resolve()
  96. }
  97. render(
  98. () => {
  99. return (
  100. <ErrorBoundary
  101. fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
  102. >
  103. <ArgsProvider {...input.args}>
  104. <ExitProvider onExit={onExit}>
  105. <KVProvider>
  106. <ToastProvider>
  107. <RouteProvider>
  108. <SDKProvider url={input.url}>
  109. <SyncProvider>
  110. <ThemeProvider mode={mode}>
  111. <LocalProvider>
  112. <KeybindProvider>
  113. <PromptStashProvider>
  114. <DialogProvider>
  115. <CommandProvider>
  116. <PromptHistoryProvider>
  117. <PromptRefProvider>
  118. <App />
  119. </PromptRefProvider>
  120. </PromptHistoryProvider>
  121. </CommandProvider>
  122. </DialogProvider>
  123. </PromptStashProvider>
  124. </KeybindProvider>
  125. </LocalProvider>
  126. </ThemeProvider>
  127. </SyncProvider>
  128. </SDKProvider>
  129. </RouteProvider>
  130. </ToastProvider>
  131. </KVProvider>
  132. </ExitProvider>
  133. </ArgsProvider>
  134. </ErrorBoundary>
  135. )
  136. },
  137. {
  138. targetFps: 60,
  139. gatherStats: false,
  140. exitOnCtrlC: false,
  141. useKittyKeyboard: {},
  142. consoleOptions: {
  143. keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
  144. onCopySelection: (text) => {
  145. Clipboard.copy(text).catch((error) => {
  146. console.error(`Failed to copy console selection to clipboard: ${error}`)
  147. })
  148. },
  149. },
  150. },
  151. )
  152. })
  153. }
  154. function App() {
  155. const route = useRoute()
  156. const dimensions = useTerminalDimensions()
  157. const renderer = useRenderer()
  158. renderer.disableStdoutInterception()
  159. const dialog = useDialog()
  160. const local = useLocal()
  161. const kv = useKV()
  162. const command = useCommandDialog()
  163. const sdk = useSDK()
  164. const toast = useToast()
  165. const { theme, mode, setMode } = useTheme()
  166. const sync = useSync()
  167. const exit = useExit()
  168. const promptRef = usePromptRef()
  169. // Wire up console copy-to-clipboard via opentui's onCopySelection callback
  170. renderer.console.onCopySelection = async (text: string) => {
  171. if (!text || text.length === 0) return
  172. const base64 = Buffer.from(text).toString("base64")
  173. const osc52 = `\x1b]52;c;${base64}\x07`
  174. const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
  175. // @ts-expect-error writeOut is not in type definitions
  176. renderer.writeOut(finalOsc52)
  177. await Clipboard.copy(text)
  178. .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
  179. .catch(toast.error)
  180. renderer.clearSelection()
  181. }
  182. const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
  183. createEffect(() => {
  184. console.log(JSON.stringify(route.data))
  185. })
  186. // Update terminal window title based on current route and session
  187. createEffect(() => {
  188. if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
  189. if (route.data.type === "home") {
  190. renderer.setTerminalTitle("OpenCode")
  191. return
  192. }
  193. if (route.data.type === "session") {
  194. const session = sync.session.get(route.data.sessionID)
  195. if (!session || SessionApi.isDefaultTitle(session.title)) {
  196. renderer.setTerminalTitle("OpenCode")
  197. return
  198. }
  199. // Truncate title to 40 chars max
  200. const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
  201. renderer.setTerminalTitle(`OC | ${title}`)
  202. }
  203. })
  204. const args = useArgs()
  205. onMount(() => {
  206. batch(() => {
  207. if (args.agent) local.agent.set(args.agent)
  208. if (args.model) {
  209. const { providerID, modelID } = Provider.parseModel(args.model)
  210. if (!providerID || !modelID)
  211. return toast.show({
  212. variant: "warning",
  213. message: `Invalid model format: ${args.model}`,
  214. duration: 3000,
  215. })
  216. local.model.set({ providerID, modelID }, { recent: true })
  217. }
  218. if (args.sessionID) {
  219. route.navigate({
  220. type: "session",
  221. sessionID: args.sessionID,
  222. })
  223. }
  224. })
  225. })
  226. let continued = false
  227. createEffect(() => {
  228. // When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
  229. if (continued || sync.status === "loading" || !args.continue) return
  230. const match = sync.data.session
  231. .toSorted((a, b) => b.time.updated - a.time.updated)
  232. .find((x) => x.parentID === undefined)?.id
  233. if (match) {
  234. continued = true
  235. route.navigate({ type: "session", sessionID: match })
  236. }
  237. })
  238. createEffect(
  239. on(
  240. () => sync.status === "complete" && sync.data.provider.length === 0,
  241. (isEmpty, wasEmpty) => {
  242. // only trigger when we transition into an empty-provider state
  243. if (!isEmpty || wasEmpty) return
  244. dialog.replace(() => <DialogProviderList />)
  245. },
  246. ),
  247. )
  248. const connected = useConnected()
  249. command.register(() => [
  250. {
  251. title: "Switch session",
  252. value: "session.list",
  253. keybind: "session_list",
  254. category: "Session",
  255. suggested: sync.data.session.length > 0,
  256. onSelect: () => {
  257. dialog.replace(() => <DialogSessionList />)
  258. },
  259. },
  260. {
  261. title: "New session",
  262. suggested: route.data.type === "session",
  263. value: "session.new",
  264. keybind: "session_new",
  265. category: "Session",
  266. onSelect: () => {
  267. const current = promptRef.current
  268. // Don't require focus - if there's any text, preserve it
  269. const currentPrompt = current?.current?.input ? current.current : undefined
  270. route.navigate({
  271. type: "home",
  272. initialPrompt: currentPrompt,
  273. })
  274. dialog.clear()
  275. },
  276. },
  277. {
  278. title: "Switch model",
  279. value: "model.list",
  280. keybind: "model_list",
  281. suggested: true,
  282. category: "Agent",
  283. onSelect: () => {
  284. dialog.replace(() => <DialogModel />)
  285. },
  286. },
  287. {
  288. title: "Model cycle",
  289. disabled: true,
  290. value: "model.cycle_recent",
  291. keybind: "model_cycle_recent",
  292. category: "Agent",
  293. onSelect: () => {
  294. local.model.cycle(1)
  295. },
  296. },
  297. {
  298. title: "Model cycle reverse",
  299. disabled: true,
  300. value: "model.cycle_recent_reverse",
  301. keybind: "model_cycle_recent_reverse",
  302. category: "Agent",
  303. onSelect: () => {
  304. local.model.cycle(-1)
  305. },
  306. },
  307. {
  308. title: "Favorite cycle",
  309. value: "model.cycle_favorite",
  310. keybind: "model_cycle_favorite",
  311. category: "Agent",
  312. onSelect: () => {
  313. local.model.cycleFavorite(1)
  314. },
  315. },
  316. {
  317. title: "Favorite cycle reverse",
  318. value: "model.cycle_favorite_reverse",
  319. keybind: "model_cycle_favorite_reverse",
  320. category: "Agent",
  321. onSelect: () => {
  322. local.model.cycleFavorite(-1)
  323. },
  324. },
  325. {
  326. title: "Switch agent",
  327. value: "agent.list",
  328. keybind: "agent_list",
  329. category: "Agent",
  330. onSelect: () => {
  331. dialog.replace(() => <DialogAgent />)
  332. },
  333. },
  334. {
  335. title: "Toggle MCPs",
  336. value: "mcp.list",
  337. category: "Agent",
  338. onSelect: () => {
  339. dialog.replace(() => <DialogMcp />)
  340. },
  341. },
  342. {
  343. title: "Agent cycle",
  344. value: "agent.cycle",
  345. keybind: "agent_cycle",
  346. category: "Agent",
  347. disabled: true,
  348. onSelect: () => {
  349. local.agent.move(1)
  350. },
  351. },
  352. {
  353. title: "Agent cycle reverse",
  354. value: "agent.cycle.reverse",
  355. keybind: "agent_cycle_reverse",
  356. category: "Agent",
  357. disabled: true,
  358. onSelect: () => {
  359. local.agent.move(-1)
  360. },
  361. },
  362. {
  363. title: "Connect provider",
  364. value: "provider.connect",
  365. suggested: !connected(),
  366. onSelect: () => {
  367. dialog.replace(() => <DialogProviderList />)
  368. },
  369. category: "Provider",
  370. },
  371. {
  372. title: "View status",
  373. keybind: "status_view",
  374. value: "opencode.status",
  375. onSelect: () => {
  376. dialog.replace(() => <DialogStatus />)
  377. },
  378. category: "System",
  379. },
  380. {
  381. title: "Switch theme",
  382. value: "theme.switch",
  383. onSelect: () => {
  384. dialog.replace(() => <DialogThemeList />)
  385. },
  386. category: "System",
  387. },
  388. {
  389. title: "Toggle appearance",
  390. value: "theme.switch_mode",
  391. onSelect: (dialog) => {
  392. setMode(mode() === "dark" ? "light" : "dark")
  393. dialog.clear()
  394. },
  395. category: "System",
  396. },
  397. {
  398. title: "Help",
  399. value: "help.show",
  400. onSelect: () => {
  401. dialog.replace(() => <DialogHelp />)
  402. },
  403. category: "System",
  404. },
  405. {
  406. title: "Open docs",
  407. value: "docs.open",
  408. onSelect: () => {
  409. open("https://opencode.ai/docs").catch(() => {})
  410. dialog.clear()
  411. },
  412. category: "System",
  413. },
  414. {
  415. title: "Open WebUI",
  416. value: "webui.open",
  417. onSelect: () => {
  418. open(sdk.url).catch(() => {})
  419. dialog.clear()
  420. },
  421. category: "System",
  422. },
  423. {
  424. title: "Exit the app",
  425. value: "app.exit",
  426. onSelect: () => exit(),
  427. category: "System",
  428. },
  429. {
  430. title: "Toggle debug panel",
  431. category: "System",
  432. value: "app.debug",
  433. onSelect: (dialog) => {
  434. renderer.toggleDebugOverlay()
  435. dialog.clear()
  436. },
  437. },
  438. {
  439. title: "Toggle console",
  440. category: "System",
  441. value: "app.console",
  442. onSelect: (dialog) => {
  443. renderer.console.toggle()
  444. dialog.clear()
  445. },
  446. },
  447. {
  448. title: "Suspend terminal",
  449. value: "terminal.suspend",
  450. keybind: "terminal_suspend",
  451. category: "System",
  452. onSelect: () => {
  453. process.once("SIGCONT", () => {
  454. renderer.resume()
  455. })
  456. renderer.suspend()
  457. // pid=0 means send the signal to all processes in the process group
  458. process.kill(0, "SIGTSTP")
  459. },
  460. },
  461. {
  462. title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
  463. value: "terminal.title.toggle",
  464. keybind: "terminal_title_toggle",
  465. category: "System",
  466. onSelect: (dialog) => {
  467. setTerminalTitleEnabled((prev) => {
  468. const next = !prev
  469. kv.set("terminal_title_enabled", next)
  470. if (!next) renderer.setTerminalTitle("")
  471. return next
  472. })
  473. dialog.clear()
  474. },
  475. },
  476. ])
  477. createEffect(() => {
  478. const currentModel = local.model.current()
  479. if (!currentModel) return
  480. if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
  481. untrack(() => {
  482. DialogAlert.show(
  483. dialog,
  484. "Warning",
  485. "While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
  486. ).then(() => kv.set("openrouter_warning", true))
  487. })
  488. }
  489. })
  490. sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
  491. command.trigger(evt.properties.command)
  492. })
  493. sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
  494. toast.show({
  495. title: evt.properties.title,
  496. message: evt.properties.message,
  497. variant: evt.properties.variant,
  498. duration: evt.properties.duration,
  499. })
  500. })
  501. sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
  502. if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
  503. route.navigate({ type: "home" })
  504. toast.show({
  505. variant: "info",
  506. message: "The current session was deleted",
  507. })
  508. }
  509. })
  510. sdk.event.on(SessionApi.Event.Error.type, (evt) => {
  511. const error = evt.properties.error
  512. const message = (() => {
  513. if (!error) return "An error occurred"
  514. if (typeof error === "object") {
  515. const data = error.data
  516. if ("message" in data && typeof data.message === "string") {
  517. return data.message
  518. }
  519. }
  520. return String(error)
  521. })()
  522. toast.show({
  523. variant: "error",
  524. message,
  525. duration: 5000,
  526. })
  527. })
  528. sdk.event.on(Installation.Event.Updated.type, (evt) => {
  529. toast.show({
  530. variant: "success",
  531. title: "Update Complete",
  532. message: `OpenCode updated to v${evt.properties.version}`,
  533. duration: 5000,
  534. })
  535. })
  536. sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
  537. toast.show({
  538. variant: "info",
  539. title: "Update Available",
  540. message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
  541. duration: 10000,
  542. })
  543. })
  544. return (
  545. <box
  546. width={dimensions().width}
  547. height={dimensions().height}
  548. backgroundColor={theme.background}
  549. onMouseUp={async () => {
  550. if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
  551. renderer.clearSelection()
  552. return
  553. }
  554. const text = renderer.getSelection()?.getSelectedText()
  555. if (text && text.length > 0) {
  556. const base64 = Buffer.from(text).toString("base64")
  557. const osc52 = `\x1b]52;c;${base64}\x07`
  558. const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
  559. /* @ts-expect-error */
  560. renderer.writeOut(finalOsc52)
  561. await Clipboard.copy(text)
  562. .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
  563. .catch(toast.error)
  564. renderer.clearSelection()
  565. }
  566. }}
  567. >
  568. <Switch>
  569. <Match when={route.data.type === "home"}>
  570. <Home />
  571. </Match>
  572. <Match when={route.data.type === "session"}>
  573. <Session />
  574. </Match>
  575. </Switch>
  576. </box>
  577. )
  578. }
  579. function ErrorComponent(props: {
  580. error: Error
  581. reset: () => void
  582. onExit: () => Promise<void>
  583. mode?: "dark" | "light"
  584. }) {
  585. const term = useTerminalDimensions()
  586. useKeyboard((evt) => {
  587. if (evt.ctrl && evt.name === "c") {
  588. props.onExit()
  589. }
  590. })
  591. const [copied, setCopied] = createSignal(false)
  592. const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
  593. // Choose safe fallback colors per mode since theme context may not be available
  594. const isLight = props.mode === "light"
  595. const colors = {
  596. bg: isLight ? "#ffffff" : "#0a0a0a",
  597. text: isLight ? "#1a1a1a" : "#eeeeee",
  598. muted: isLight ? "#8a8a8a" : "#808080",
  599. primary: isLight ? "#3b7dd8" : "#fab283",
  600. }
  601. if (props.error.message) {
  602. issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
  603. }
  604. if (props.error.stack) {
  605. issueURL.searchParams.set(
  606. "description",
  607. "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
  608. )
  609. }
  610. issueURL.searchParams.set("opencode-version", Installation.VERSION)
  611. const copyIssueURL = () => {
  612. Clipboard.copy(issueURL.toString()).then(() => {
  613. setCopied(true)
  614. })
  615. }
  616. return (
  617. <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
  618. <box flexDirection="row" gap={1} alignItems="center">
  619. <text attributes={TextAttributes.BOLD} fg={colors.text}>
  620. Please report an issue.
  621. </text>
  622. <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
  623. <text attributes={TextAttributes.BOLD} fg={colors.bg}>
  624. Copy issue URL (exception info pre-filled)
  625. </text>
  626. </box>
  627. {copied() && <text fg={colors.muted}>Successfully copied</text>}
  628. </box>
  629. <box flexDirection="row" gap={2} alignItems="center">
  630. <text fg={colors.text}>A fatal error occurred!</text>
  631. <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
  632. <text fg={colors.bg}>Reset TUI</text>
  633. </box>
  634. <box onMouseUp={props.onExit} backgroundColor={colors.primary} padding={1}>
  635. <text fg={colors.bg}>Exit</text>
  636. </box>
  637. </box>
  638. <scrollbox height={Math.floor(term().height * 0.7)}>
  639. <text fg={colors.muted}>{props.error.stack}</text>
  640. </scrollbox>
  641. <text fg={colors.text}>{props.error.message}</text>
  642. </box>
  643. )
  644. }