app.tsx 23 KB

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