app.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919
  1. import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
  2. import { Clipboard } from "@tui/util/clipboard"
  3. import { Selection } from "@tui/util/selection"
  4. import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
  5. import { RouteProvider, useRoute } from "@tui/context/route"
  6. import {
  7. Switch,
  8. Match,
  9. createEffect,
  10. createMemo,
  11. ErrorBoundary,
  12. createSignal,
  13. onMount,
  14. batch,
  15. Show,
  16. on,
  17. onCleanup,
  18. } from "solid-js"
  19. import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
  20. import { Flag } from "@/flag/flag"
  21. import semver from "semver"
  22. import { DialogProvider, useDialog } from "@tui/ui/dialog"
  23. import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
  24. import { ErrorComponent } from "@tui/component/error-component"
  25. import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
  26. import { SDKProvider, useSDK } from "@tui/context/sdk"
  27. import { StartupLoading } from "@tui/component/startup-loading"
  28. import { SyncProvider, useSync } from "@tui/context/sync"
  29. import { LocalProvider, useLocal } from "@tui/context/local"
  30. import { DialogModel, useConnected } from "@tui/component/dialog-model"
  31. import { DialogMcp } from "@tui/component/dialog-mcp"
  32. import { DialogStatus } from "@tui/component/dialog-status"
  33. import { DialogThemeList } from "@tui/component/dialog-theme-list"
  34. import { DialogHelp } from "./ui/dialog-help"
  35. import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
  36. import { DialogAgent } from "@tui/component/dialog-agent"
  37. import { DialogSessionList } from "@tui/component/dialog-session-list"
  38. import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
  39. import { KeybindProvider, useKeybind } from "@tui/context/keybind"
  40. import { ThemeProvider, useTheme } from "@tui/context/theme"
  41. import { Home } from "@tui/routes/home"
  42. import { Session } from "@tui/routes/session"
  43. import { PromptHistoryProvider } from "./component/prompt/history"
  44. import { FrecencyProvider } from "./component/prompt/frecency"
  45. import { PromptStashProvider } from "./component/prompt/stash"
  46. import { DialogAlert } from "./ui/dialog-alert"
  47. import { DialogConfirm } from "./ui/dialog-confirm"
  48. import { ToastProvider, useToast } from "./ui/toast"
  49. import { ExitProvider, useExit } from "./context/exit"
  50. import { Session as SessionApi } from "@/session"
  51. import { TuiEvent } from "./event"
  52. import { KVProvider, useKV } from "./context/kv"
  53. import { Provider } from "@/provider/provider"
  54. import { ArgsProvider, useArgs, type Args } from "./context/args"
  55. import open from "open"
  56. import { writeHeapSnapshot } from "v8"
  57. import { PromptRefProvider, usePromptRef } from "./context/prompt"
  58. import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
  59. import { TuiConfig } from "@/config/tui"
  60. import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
  61. import { FormatError, FormatUnknownError } from "@/cli/error"
  62. async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
  63. // can't set raw mode if not a TTY
  64. if (!process.stdin.isTTY) return "dark"
  65. return new Promise((resolve) => {
  66. let timeout: NodeJS.Timeout
  67. const cleanup = () => {
  68. process.stdin.setRawMode(false)
  69. process.stdin.removeListener("data", handler)
  70. clearTimeout(timeout)
  71. }
  72. const handler = (data: Buffer) => {
  73. const str = data.toString()
  74. const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
  75. if (match) {
  76. cleanup()
  77. const color = match[1]
  78. // Parse RGB values from color string
  79. // Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
  80. let r = 0,
  81. g = 0,
  82. b = 0
  83. if (color.startsWith("rgb:")) {
  84. const parts = color.substring(4).split("/")
  85. r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
  86. g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
  87. b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
  88. } else if (color.startsWith("#")) {
  89. r = parseInt(color.substring(1, 3), 16)
  90. g = parseInt(color.substring(3, 5), 16)
  91. b = parseInt(color.substring(5, 7), 16)
  92. } else if (color.startsWith("rgb(")) {
  93. const parts = color.substring(4, color.length - 1).split(",")
  94. r = parseInt(parts[0])
  95. g = parseInt(parts[1])
  96. b = parseInt(parts[2])
  97. }
  98. // Calculate luminance using relative luminance formula
  99. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  100. // Determine if dark or light based on luminance threshold
  101. resolve(luminance > 0.5 ? "light" : "dark")
  102. }
  103. }
  104. process.stdin.setRawMode(true)
  105. process.stdin.on("data", handler)
  106. process.stdout.write("\x1b]11;?\x07")
  107. timeout = setTimeout(() => {
  108. cleanup()
  109. resolve("dark")
  110. }, 1000)
  111. })
  112. }
  113. import type { EventSource } from "./context/sdk"
  114. import { DialogVariant } from "./component/dialog-variant"
  115. function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
  116. return {
  117. externalOutputMode: "passthrough",
  118. targetFps: 60,
  119. gatherStats: false,
  120. exitOnCtrlC: false,
  121. useKittyKeyboard: { events: process.platform === "win32" },
  122. autoFocus: false,
  123. openConsoleOnError: false,
  124. consoleOptions: {
  125. keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
  126. onCopySelection: (text) => {
  127. Clipboard.copy(text).catch((error) => {
  128. console.error(`Failed to copy console selection to clipboard: ${error}`)
  129. })
  130. },
  131. },
  132. }
  133. }
  134. function errorMessage(error: unknown) {
  135. const formatted = FormatError(error)
  136. if (formatted !== undefined) return formatted
  137. if (
  138. typeof error === "object" &&
  139. error !== null &&
  140. "data" in error &&
  141. typeof error.data === "object" &&
  142. error.data !== null &&
  143. "message" in error.data &&
  144. typeof error.data.message === "string"
  145. ) {
  146. return error.data.message
  147. }
  148. return FormatUnknownError(error)
  149. }
  150. export function tui(input: {
  151. url: string
  152. args: Args
  153. config: TuiConfig.Info
  154. onSnapshot?: () => Promise<string[]>
  155. directory?: string
  156. fetch?: typeof fetch
  157. headers?: RequestInit["headers"]
  158. events?: EventSource
  159. }) {
  160. // promise to prevent immediate exit
  161. return new Promise<void>(async (resolve) => {
  162. const unguard = win32InstallCtrlCGuard()
  163. win32DisableProcessedInput()
  164. const mode = await getTerminalBackgroundColor()
  165. // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
  166. // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
  167. win32DisableProcessedInput()
  168. const onExit = async () => {
  169. unguard?.()
  170. resolve()
  171. }
  172. const onBeforeExit = async () => {
  173. await TuiPluginRuntime.dispose()
  174. }
  175. const renderer = await createCliRenderer(rendererConfig(input.config))
  176. await render(() => {
  177. return (
  178. <ErrorBoundary
  179. fallback={(error, reset) => (
  180. <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
  181. )}
  182. >
  183. <ArgsProvider {...input.args}>
  184. <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
  185. <KVProvider>
  186. <ToastProvider>
  187. <RouteProvider>
  188. <TuiConfigProvider config={input.config}>
  189. <SDKProvider
  190. url={input.url}
  191. directory={input.directory}
  192. fetch={input.fetch}
  193. headers={input.headers}
  194. events={input.events}
  195. >
  196. <SyncProvider>
  197. <ThemeProvider mode={mode}>
  198. <LocalProvider>
  199. <KeybindProvider>
  200. <PromptStashProvider>
  201. <DialogProvider>
  202. <CommandProvider>
  203. <FrecencyProvider>
  204. <PromptHistoryProvider>
  205. <PromptRefProvider>
  206. <App onSnapshot={input.onSnapshot} />
  207. </PromptRefProvider>
  208. </PromptHistoryProvider>
  209. </FrecencyProvider>
  210. </CommandProvider>
  211. </DialogProvider>
  212. </PromptStashProvider>
  213. </KeybindProvider>
  214. </LocalProvider>
  215. </ThemeProvider>
  216. </SyncProvider>
  217. </SDKProvider>
  218. </TuiConfigProvider>
  219. </RouteProvider>
  220. </ToastProvider>
  221. </KVProvider>
  222. </ExitProvider>
  223. </ArgsProvider>
  224. </ErrorBoundary>
  225. )
  226. }, renderer)
  227. })
  228. }
  229. function App(props: { onSnapshot?: () => Promise<string[]> }) {
  230. const tuiConfig = useTuiConfig()
  231. const route = useRoute()
  232. const dimensions = useTerminalDimensions()
  233. const renderer = useRenderer()
  234. const dialog = useDialog()
  235. const local = useLocal()
  236. const kv = useKV()
  237. const command = useCommandDialog()
  238. const keybind = useKeybind()
  239. const sdk = useSDK()
  240. const toast = useToast()
  241. const themeState = useTheme()
  242. const { theme, mode, setMode, locked, lock, unlock } = themeState
  243. const sync = useSync()
  244. const exit = useExit()
  245. const promptRef = usePromptRef()
  246. const routes: RouteMap = new Map()
  247. const [routeRev, setRouteRev] = createSignal(0)
  248. const routeView = (name: string) => {
  249. routeRev()
  250. return routes.get(name)?.at(-1)?.render
  251. }
  252. const api = createTuiApi({
  253. command,
  254. tuiConfig,
  255. dialog,
  256. keybind,
  257. kv,
  258. route,
  259. routes,
  260. bump: () => setRouteRev((x) => x + 1),
  261. sdk,
  262. sync,
  263. theme: themeState,
  264. toast,
  265. renderer,
  266. })
  267. onCleanup(() => {
  268. api.dispose()
  269. })
  270. const [ready, setReady] = createSignal(false)
  271. TuiPluginRuntime.init(api)
  272. .catch((error) => {
  273. console.error("Failed to load TUI plugins", error)
  274. })
  275. .finally(() => {
  276. setReady(true)
  277. })
  278. useKeyboard((evt) => {
  279. if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
  280. const sel = renderer.getSelection()
  281. if (!sel) return
  282. // Windows Terminal-like behavior:
  283. // - Ctrl+C copies and dismisses selection
  284. // - Esc dismisses selection
  285. // - Most other key input dismisses selection and is passed through
  286. if (evt.ctrl && evt.name === "c") {
  287. if (!Selection.copy(renderer, toast)) {
  288. renderer.clearSelection()
  289. return
  290. }
  291. evt.preventDefault()
  292. evt.stopPropagation()
  293. return
  294. }
  295. if (evt.name === "escape") {
  296. renderer.clearSelection()
  297. evt.preventDefault()
  298. evt.stopPropagation()
  299. return
  300. }
  301. const focus = renderer.currentFocusedRenderable
  302. if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
  303. return
  304. }
  305. renderer.clearSelection()
  306. })
  307. // Wire up console copy-to-clipboard via opentui's onCopySelection callback
  308. renderer.console.onCopySelection = async (text: string) => {
  309. if (!text || text.length === 0) return
  310. await Clipboard.copy(text)
  311. .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
  312. .catch(toast.error)
  313. renderer.clearSelection()
  314. }
  315. const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
  316. // Update terminal window title based on current route and session
  317. createEffect(() => {
  318. if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
  319. if (route.data.type === "home") {
  320. renderer.setTerminalTitle("OpenCode")
  321. return
  322. }
  323. if (route.data.type === "session") {
  324. const session = sync.session.get(route.data.sessionID)
  325. if (!session || SessionApi.isDefaultTitle(session.title)) {
  326. renderer.setTerminalTitle("OpenCode")
  327. return
  328. }
  329. const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
  330. renderer.setTerminalTitle(`OC | ${title}`)
  331. return
  332. }
  333. if (route.data.type === "plugin") {
  334. renderer.setTerminalTitle(`OC | ${route.data.id}`)
  335. }
  336. })
  337. const args = useArgs()
  338. onMount(() => {
  339. batch(() => {
  340. if (args.agent) local.agent.set(args.agent)
  341. if (args.model) {
  342. const { providerID, modelID } = Provider.parseModel(args.model)
  343. if (!providerID || !modelID)
  344. return toast.show({
  345. variant: "warning",
  346. message: `Invalid model format: ${args.model}`,
  347. duration: 3000,
  348. })
  349. local.model.set({ providerID, modelID }, { recent: true })
  350. }
  351. // Handle --session without --fork immediately (fork is handled in createEffect below)
  352. if (args.sessionID && !args.fork) {
  353. route.navigate({
  354. type: "session",
  355. sessionID: args.sessionID,
  356. })
  357. }
  358. })
  359. })
  360. let continued = false
  361. createEffect(() => {
  362. // When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
  363. if (continued || sync.status === "loading" || !args.continue) return
  364. const match = sync.data.session
  365. .toSorted((a, b) => b.time.updated - a.time.updated)
  366. .find((x) => x.parentID === undefined)?.id
  367. if (match) {
  368. continued = true
  369. if (args.fork) {
  370. sdk.client.session.fork({ sessionID: match }).then((result) => {
  371. if (result.data?.id) {
  372. route.navigate({ type: "session", sessionID: result.data.id })
  373. } else {
  374. toast.show({ message: "Failed to fork session", variant: "error" })
  375. }
  376. })
  377. } else {
  378. route.navigate({ type: "session", sessionID: match })
  379. }
  380. }
  381. })
  382. // Handle --session with --fork: wait for sync to be fully complete before forking
  383. // (session list loads in non-blocking phase for --session, so we must wait for "complete"
  384. // to avoid a race where reconcile overwrites the newly forked session)
  385. let forked = false
  386. createEffect(() => {
  387. if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
  388. forked = true
  389. sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
  390. if (result.data?.id) {
  391. route.navigate({ type: "session", sessionID: result.data.id })
  392. } else {
  393. toast.show({ message: "Failed to fork session", variant: "error" })
  394. }
  395. })
  396. })
  397. createEffect(
  398. on(
  399. () => sync.status === "complete" && sync.data.provider.length === 0,
  400. (isEmpty, wasEmpty) => {
  401. // only trigger when we transition into an empty-provider state
  402. if (!isEmpty || wasEmpty) return
  403. dialog.replace(() => <DialogProviderList />)
  404. },
  405. ),
  406. )
  407. const connected = useConnected()
  408. command.register(() => [
  409. {
  410. title: "Switch session",
  411. value: "session.list",
  412. keybind: "session_list",
  413. category: "Session",
  414. suggested: sync.data.session.length > 0,
  415. slash: {
  416. name: "sessions",
  417. aliases: ["resume", "continue"],
  418. },
  419. onSelect: () => {
  420. dialog.replace(() => <DialogSessionList />)
  421. },
  422. },
  423. ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  424. ? [
  425. {
  426. title: "Manage workspaces",
  427. value: "workspace.list",
  428. category: "Workspace",
  429. suggested: true,
  430. slash: {
  431. name: "workspaces",
  432. },
  433. onSelect: () => {
  434. dialog.replace(() => <DialogWorkspaceList />)
  435. },
  436. },
  437. ]
  438. : []),
  439. {
  440. title: "New session",
  441. suggested: route.data.type === "session",
  442. value: "session.new",
  443. keybind: "session_new",
  444. category: "Session",
  445. slash: {
  446. name: "new",
  447. aliases: ["clear"],
  448. },
  449. onSelect: () => {
  450. const current = promptRef.current
  451. // Don't require focus - if there's any text, preserve it
  452. const currentPrompt = current?.current?.input ? current.current : undefined
  453. const workspaceID =
  454. route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
  455. route.navigate({
  456. type: "home",
  457. initialPrompt: currentPrompt,
  458. workspaceID,
  459. })
  460. dialog.clear()
  461. },
  462. },
  463. {
  464. title: "Switch model",
  465. value: "model.list",
  466. keybind: "model_list",
  467. suggested: true,
  468. category: "Agent",
  469. slash: {
  470. name: "models",
  471. },
  472. onSelect: () => {
  473. dialog.replace(() => <DialogModel />)
  474. },
  475. },
  476. {
  477. title: "Model cycle",
  478. value: "model.cycle_recent",
  479. keybind: "model_cycle_recent",
  480. category: "Agent",
  481. hidden: true,
  482. onSelect: () => {
  483. local.model.cycle(1)
  484. },
  485. },
  486. {
  487. title: "Model cycle reverse",
  488. value: "model.cycle_recent_reverse",
  489. keybind: "model_cycle_recent_reverse",
  490. category: "Agent",
  491. hidden: true,
  492. onSelect: () => {
  493. local.model.cycle(-1)
  494. },
  495. },
  496. {
  497. title: "Favorite cycle",
  498. value: "model.cycle_favorite",
  499. keybind: "model_cycle_favorite",
  500. category: "Agent",
  501. hidden: true,
  502. onSelect: () => {
  503. local.model.cycleFavorite(1)
  504. },
  505. },
  506. {
  507. title: "Favorite cycle reverse",
  508. value: "model.cycle_favorite_reverse",
  509. keybind: "model_cycle_favorite_reverse",
  510. category: "Agent",
  511. hidden: true,
  512. onSelect: () => {
  513. local.model.cycleFavorite(-1)
  514. },
  515. },
  516. {
  517. title: "Switch agent",
  518. value: "agent.list",
  519. keybind: "agent_list",
  520. category: "Agent",
  521. slash: {
  522. name: "agents",
  523. },
  524. onSelect: () => {
  525. dialog.replace(() => <DialogAgent />)
  526. },
  527. },
  528. {
  529. title: "Toggle MCPs",
  530. value: "mcp.list",
  531. category: "Agent",
  532. slash: {
  533. name: "mcps",
  534. },
  535. onSelect: () => {
  536. dialog.replace(() => <DialogMcp />)
  537. },
  538. },
  539. {
  540. title: "Agent cycle",
  541. value: "agent.cycle",
  542. keybind: "agent_cycle",
  543. category: "Agent",
  544. hidden: true,
  545. onSelect: () => {
  546. local.agent.move(1)
  547. },
  548. },
  549. {
  550. title: "Variant cycle",
  551. value: "variant.cycle",
  552. keybind: "variant_cycle",
  553. category: "Agent",
  554. onSelect: () => {
  555. local.model.variant.cycle()
  556. },
  557. },
  558. {
  559. title: "Switch model variant",
  560. value: "variant.list",
  561. category: "Agent",
  562. hidden: local.model.variant.list().length === 0,
  563. slash: {
  564. name: "variants",
  565. },
  566. onSelect: () => {
  567. dialog.replace(() => <DialogVariant />)
  568. },
  569. },
  570. {
  571. title: "Agent cycle reverse",
  572. value: "agent.cycle.reverse",
  573. keybind: "agent_cycle_reverse",
  574. category: "Agent",
  575. hidden: true,
  576. onSelect: () => {
  577. local.agent.move(-1)
  578. },
  579. },
  580. {
  581. title: "Connect provider",
  582. value: "provider.connect",
  583. suggested: !connected(),
  584. slash: {
  585. name: "connect",
  586. },
  587. onSelect: () => {
  588. dialog.replace(() => <DialogProviderList />)
  589. },
  590. category: "Provider",
  591. },
  592. {
  593. title: "View status",
  594. keybind: "status_view",
  595. value: "opencode.status",
  596. slash: {
  597. name: "status",
  598. },
  599. onSelect: () => {
  600. dialog.replace(() => <DialogStatus />)
  601. },
  602. category: "System",
  603. },
  604. {
  605. title: "Switch theme",
  606. value: "theme.switch",
  607. keybind: "theme_list",
  608. slash: {
  609. name: "themes",
  610. },
  611. onSelect: () => {
  612. dialog.replace(() => <DialogThemeList />)
  613. },
  614. category: "System",
  615. },
  616. {
  617. title: "Toggle Theme Mode",
  618. value: "theme.switch_mode",
  619. onSelect: (dialog) => {
  620. setMode(mode() === "dark" ? "light" : "dark")
  621. dialog.clear()
  622. },
  623. category: "System",
  624. },
  625. {
  626. title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
  627. value: "theme.mode.lock",
  628. onSelect: (dialog) => {
  629. if (locked()) unlock()
  630. else lock()
  631. dialog.clear()
  632. },
  633. category: "System",
  634. },
  635. {
  636. title: "Help",
  637. value: "help.show",
  638. slash: {
  639. name: "help",
  640. },
  641. onSelect: () => {
  642. dialog.replace(() => <DialogHelp />)
  643. },
  644. category: "System",
  645. },
  646. {
  647. title: "Open docs",
  648. value: "docs.open",
  649. onSelect: () => {
  650. open("https://opencode.ai/docs").catch(() => {})
  651. dialog.clear()
  652. },
  653. category: "System",
  654. },
  655. {
  656. title: "Exit the app",
  657. value: "app.exit",
  658. slash: {
  659. name: "exit",
  660. aliases: ["quit", "q"],
  661. },
  662. onSelect: () => exit(),
  663. category: "System",
  664. },
  665. {
  666. title: "Toggle debug panel",
  667. category: "System",
  668. value: "app.debug",
  669. onSelect: (dialog) => {
  670. renderer.toggleDebugOverlay()
  671. dialog.clear()
  672. },
  673. },
  674. {
  675. title: "Toggle console",
  676. category: "System",
  677. value: "app.console",
  678. onSelect: (dialog) => {
  679. renderer.console.toggle()
  680. dialog.clear()
  681. },
  682. },
  683. {
  684. title: "Write heap snapshot",
  685. category: "System",
  686. value: "app.heap_snapshot",
  687. onSelect: async (dialog) => {
  688. const files = await props.onSnapshot?.()
  689. toast.show({
  690. variant: "info",
  691. message: `Heap snapshot written to ${files?.join(", ")}`,
  692. duration: 5000,
  693. })
  694. dialog.clear()
  695. },
  696. },
  697. {
  698. title: "Suspend terminal",
  699. value: "terminal.suspend",
  700. keybind: "terminal_suspend",
  701. category: "System",
  702. hidden: true,
  703. onSelect: () => {
  704. process.once("SIGCONT", () => {
  705. renderer.resume()
  706. })
  707. renderer.suspend()
  708. // pid=0 means send the signal to all processes in the process group
  709. process.kill(0, "SIGTSTP")
  710. },
  711. },
  712. {
  713. title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
  714. value: "terminal.title.toggle",
  715. keybind: "terminal_title_toggle",
  716. category: "System",
  717. onSelect: (dialog) => {
  718. setTerminalTitleEnabled((prev) => {
  719. const next = !prev
  720. kv.set("terminal_title_enabled", next)
  721. if (!next) renderer.setTerminalTitle("")
  722. return next
  723. })
  724. dialog.clear()
  725. },
  726. },
  727. {
  728. title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
  729. value: "app.toggle.animations",
  730. category: "System",
  731. onSelect: (dialog) => {
  732. kv.set("animations_enabled", !kv.get("animations_enabled", true))
  733. dialog.clear()
  734. },
  735. },
  736. {
  737. title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
  738. value: "app.toggle.diffwrap",
  739. category: "System",
  740. onSelect: (dialog) => {
  741. const current = kv.get("diff_wrap_mode", "word")
  742. kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
  743. dialog.clear()
  744. },
  745. },
  746. ])
  747. sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
  748. command.trigger(evt.properties.command)
  749. })
  750. sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
  751. toast.show({
  752. title: evt.properties.title,
  753. message: evt.properties.message,
  754. variant: evt.properties.variant,
  755. duration: evt.properties.duration,
  756. })
  757. })
  758. sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
  759. route.navigate({
  760. type: "session",
  761. sessionID: evt.properties.sessionID,
  762. })
  763. })
  764. sdk.event.on("session.deleted", (evt) => {
  765. if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
  766. route.navigate({ type: "home" })
  767. toast.show({
  768. variant: "info",
  769. message: "The current session was deleted",
  770. })
  771. }
  772. })
  773. sdk.event.on("session.error", (evt) => {
  774. const error = evt.properties.error
  775. if (error && typeof error === "object" && error.name === "MessageAbortedError") return
  776. const message = errorMessage(error)
  777. toast.show({
  778. variant: "error",
  779. message,
  780. duration: 5000,
  781. })
  782. })
  783. sdk.event.on("installation.update-available", async (evt) => {
  784. const version = evt.properties.version
  785. const skipped = kv.get("skipped_version")
  786. if (skipped && !semver.gt(version, skipped)) return
  787. const choice = await DialogConfirm.show(
  788. dialog,
  789. `Update Available`,
  790. `A new release v${version} is available. Would you like to update now?`,
  791. "skip",
  792. )
  793. if (choice === false) {
  794. kv.set("skipped_version", version)
  795. return
  796. }
  797. if (choice !== true) return
  798. toast.show({
  799. variant: "info",
  800. message: `Updating to v${version}...`,
  801. duration: 30000,
  802. })
  803. const result = await sdk.client.global.upgrade({ target: version })
  804. if (result.error || !result.data?.success) {
  805. toast.show({
  806. variant: "error",
  807. title: "Update Failed",
  808. message: "Update failed",
  809. duration: 10000,
  810. })
  811. return
  812. }
  813. await DialogAlert.show(
  814. dialog,
  815. "Update Complete",
  816. `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
  817. )
  818. exit()
  819. })
  820. const plugin = createMemo(() => {
  821. if (!ready()) return
  822. if (route.data.type !== "plugin") return
  823. const render = routeView(route.data.id)
  824. if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
  825. return render({ params: route.data.data })
  826. })
  827. return (
  828. <box
  829. width={dimensions().width}
  830. height={dimensions().height}
  831. backgroundColor={theme.background}
  832. onMouseDown={(evt) => {
  833. if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
  834. if (evt.button !== MouseButton.RIGHT) return
  835. if (!Selection.copy(renderer, toast)) return
  836. evt.preventDefault()
  837. evt.stopPropagation()
  838. }}
  839. onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
  840. >
  841. <Show when={Flag.OPENCODE_SHOW_TTFD}>
  842. <TimeToFirstDraw />
  843. </Show>
  844. <Show when={ready()}>
  845. <Switch>
  846. <Match when={route.data.type === "home"}>
  847. <Home />
  848. </Match>
  849. <Match when={route.data.type === "session"}>
  850. <Session />
  851. </Match>
  852. </Switch>
  853. </Show>
  854. {plugin()}
  855. <TuiPluginRuntime.Slot name="app" />
  856. <StartupLoading ready={ready} />
  857. </box>
  858. )
  859. }