app.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  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 } from "@tui/component/dialog-model"
  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 { DialogAlert } from "./ui/dialog-alert"
  27. import { ToastProvider, useToast } from "./ui/toast"
  28. import { ExitProvider, useExit } from "./context/exit"
  29. import { Session as SessionApi } from "@/session"
  30. import { TuiEvent } from "./event"
  31. import { KVProvider, useKV } from "./context/kv"
  32. import { Provider } from "@/provider/provider"
  33. import { ArgsProvider, useArgs, type Args } from "./context/args"
  34. import open from "open"
  35. import { PromptRefProvider, usePromptRef } from "./context/prompt"
  36. async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
  37. // can't set raw mode if not a TTY
  38. if (!process.stdin.isTTY) return "dark"
  39. return new Promise((resolve) => {
  40. let timeout: NodeJS.Timeout
  41. const cleanup = () => {
  42. process.stdin.setRawMode(false)
  43. process.stdin.removeListener("data", handler)
  44. clearTimeout(timeout)
  45. }
  46. const handler = (data: Buffer) => {
  47. const str = data.toString()
  48. const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
  49. if (match) {
  50. cleanup()
  51. const color = match[1]
  52. // Parse RGB values from color string
  53. // Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
  54. let r = 0,
  55. g = 0,
  56. b = 0
  57. if (color.startsWith("rgb:")) {
  58. const parts = color.substring(4).split("/")
  59. r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
  60. g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
  61. b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
  62. } else if (color.startsWith("#")) {
  63. r = parseInt(color.substring(1, 3), 16)
  64. g = parseInt(color.substring(3, 5), 16)
  65. b = parseInt(color.substring(5, 7), 16)
  66. } else if (color.startsWith("rgb(")) {
  67. const parts = color.substring(4, color.length - 1).split(",")
  68. r = parseInt(parts[0])
  69. g = parseInt(parts[1])
  70. b = parseInt(parts[2])
  71. }
  72. // Calculate luminance using relative luminance formula
  73. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  74. // Determine if dark or light based on luminance threshold
  75. resolve(luminance > 0.5 ? "light" : "dark")
  76. }
  77. }
  78. process.stdin.setRawMode(true)
  79. process.stdin.on("data", handler)
  80. process.stdout.write("\x1b]11;?\x07")
  81. timeout = setTimeout(() => {
  82. cleanup()
  83. resolve("dark")
  84. }, 1000)
  85. })
  86. }
  87. export function tui(input: { url: string; args: Args; onExit?: () => Promise<void> }) {
  88. // promise to prevent immediate exit
  89. return new Promise<void>(async (resolve) => {
  90. const mode = await getTerminalBackgroundColor()
  91. const onExit = async () => {
  92. await input.onExit?.()
  93. resolve()
  94. }
  95. render(
  96. () => {
  97. return (
  98. <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} />}>
  99. <ArgsProvider {...input.args}>
  100. <ExitProvider onExit={onExit}>
  101. <KVProvider>
  102. <ToastProvider>
  103. <RouteProvider>
  104. <SDKProvider url={input.url}>
  105. <SyncProvider>
  106. <ThemeProvider mode={mode}>
  107. <LocalProvider>
  108. <KeybindProvider>
  109. <DialogProvider>
  110. <CommandProvider>
  111. <PromptHistoryProvider>
  112. <PromptRefProvider>
  113. <App />
  114. </PromptRefProvider>
  115. </PromptHistoryProvider>
  116. </CommandProvider>
  117. </DialogProvider>
  118. </KeybindProvider>
  119. </LocalProvider>
  120. </ThemeProvider>
  121. </SyncProvider>
  122. </SDKProvider>
  123. </RouteProvider>
  124. </ToastProvider>
  125. </KVProvider>
  126. </ExitProvider>
  127. </ArgsProvider>
  128. </ErrorBoundary>
  129. )
  130. },
  131. {
  132. targetFps: 60,
  133. gatherStats: false,
  134. exitOnCtrlC: false,
  135. useKittyKeyboard: true,
  136. },
  137. )
  138. })
  139. }
  140. function App() {
  141. const route = useRoute()
  142. const dimensions = useTerminalDimensions()
  143. const renderer = useRenderer()
  144. renderer.disableStdoutInterception()
  145. const dialog = useDialog()
  146. const local = useLocal()
  147. const kv = useKV()
  148. const command = useCommandDialog()
  149. const { event } = useSDK()
  150. const toast = useToast()
  151. const { theme, mode, setMode } = useTheme()
  152. const sync = useSync()
  153. const exit = useExit()
  154. const promptRef = usePromptRef()
  155. createEffect(() => {
  156. console.log(JSON.stringify(route.data))
  157. })
  158. const args = useArgs()
  159. onMount(() => {
  160. batch(() => {
  161. if (args.agent) local.agent.set(args.agent)
  162. if (args.model) {
  163. const { providerID, modelID } = Provider.parseModel(args.model)
  164. if (!providerID || !modelID)
  165. return toast.show({
  166. variant: "warning",
  167. message: `Invalid model format: ${args.model}`,
  168. duration: 3000,
  169. })
  170. local.model.set({ providerID, modelID }, { recent: true })
  171. }
  172. if (args.sessionID) {
  173. route.navigate({
  174. type: "session",
  175. sessionID: args.sessionID,
  176. })
  177. }
  178. })
  179. })
  180. let continued = false
  181. createEffect(() => {
  182. if (continued || sync.status !== "complete" || !args.continue) return
  183. const match = sync.data.session.at(0)?.id
  184. if (match) {
  185. continued = true
  186. route.navigate({ type: "session", sessionID: match })
  187. }
  188. })
  189. createEffect(
  190. on(
  191. () => sync.status === "complete" && sync.data.provider.length === 0,
  192. (isEmpty, wasEmpty) => {
  193. // only trigger when we transition into an empty-provider state
  194. if (!isEmpty || wasEmpty) return
  195. dialog.replace(() => <DialogProviderList />)
  196. },
  197. ),
  198. )
  199. command.register(() => [
  200. {
  201. title: "Switch session",
  202. value: "session.list",
  203. keybind: "session_list",
  204. category: "Session",
  205. onSelect: () => {
  206. dialog.replace(() => <DialogSessionList />)
  207. },
  208. },
  209. {
  210. title: "New session",
  211. value: "session.new",
  212. keybind: "session_new",
  213. category: "Session",
  214. onSelect: () => {
  215. const current = promptRef.current
  216. // Don't require focus - if there's any text, preserve it
  217. const currentPrompt = current?.current?.input ? current.current : undefined
  218. route.navigate({
  219. type: "home",
  220. initialPrompt: currentPrompt,
  221. })
  222. dialog.clear()
  223. },
  224. },
  225. {
  226. title: "Switch model",
  227. value: "model.list",
  228. keybind: "model_list",
  229. category: "Agent",
  230. onSelect: () => {
  231. dialog.replace(() => <DialogModel />)
  232. },
  233. },
  234. {
  235. title: "Model cycle",
  236. value: "model.cycle_recent",
  237. keybind: "model_cycle_recent",
  238. category: "Agent",
  239. onSelect: () => {
  240. local.model.cycle(1)
  241. },
  242. },
  243. {
  244. title: "Model cycle reverse",
  245. value: "model.cycle_recent_reverse",
  246. keybind: "model_cycle_recent_reverse",
  247. category: "Agent",
  248. onSelect: () => {
  249. local.model.cycle(-1)
  250. },
  251. },
  252. {
  253. title: "Switch agent",
  254. value: "agent.list",
  255. keybind: "agent_list",
  256. category: "Agent",
  257. onSelect: () => {
  258. dialog.replace(() => <DialogAgent />)
  259. },
  260. },
  261. {
  262. title: "Agent cycle",
  263. value: "agent.cycle",
  264. keybind: "agent_cycle",
  265. category: "Agent",
  266. disabled: true,
  267. onSelect: () => {
  268. local.agent.move(1)
  269. },
  270. },
  271. {
  272. title: "Agent cycle reverse",
  273. value: "agent.cycle.reverse",
  274. keybind: "agent_cycle_reverse",
  275. category: "Agent",
  276. disabled: true,
  277. onSelect: () => {
  278. local.agent.move(-1)
  279. },
  280. },
  281. {
  282. title: "View status",
  283. keybind: "status_view",
  284. value: "opencode.status",
  285. onSelect: () => {
  286. dialog.replace(() => <DialogStatus />)
  287. },
  288. category: "System",
  289. },
  290. {
  291. title: "Switch theme",
  292. value: "theme.switch",
  293. onSelect: () => {
  294. dialog.replace(() => <DialogThemeList />)
  295. },
  296. category: "System",
  297. },
  298. {
  299. title: "Connect provider",
  300. value: "provider.connect",
  301. onSelect: () => {
  302. dialog.replace(() => <DialogProviderList />)
  303. },
  304. category: "System",
  305. },
  306. {
  307. title: "Toggle appearance",
  308. value: "theme.switch_mode",
  309. onSelect: (dialog) => {
  310. setMode(mode() === "dark" ? "light" : "dark")
  311. dialog.clear()
  312. },
  313. category: "System",
  314. },
  315. {
  316. title: "Help",
  317. value: "help.show",
  318. onSelect: () => {
  319. dialog.replace(() => <DialogHelp />)
  320. },
  321. category: "System",
  322. },
  323. {
  324. title: "Open docs",
  325. value: "docs.open",
  326. onSelect: () => {
  327. open("https://opencode.ai/docs").catch(() => {})
  328. dialog.clear()
  329. },
  330. category: "System",
  331. },
  332. {
  333. title: "Exit the app",
  334. value: "app.exit",
  335. onSelect: () => exit(),
  336. category: "System",
  337. },
  338. {
  339. title: "Toggle debug panel",
  340. category: "System",
  341. value: "app.debug",
  342. onSelect: (dialog) => {
  343. renderer.toggleDebugOverlay()
  344. dialog.clear()
  345. },
  346. },
  347. {
  348. title: "Toggle console",
  349. category: "System",
  350. value: "app.fps",
  351. onSelect: (dialog) => {
  352. renderer.console.toggle()
  353. dialog.clear()
  354. },
  355. },
  356. {
  357. title: "Suspend terminal",
  358. value: "terminal.suspend",
  359. keybind: "terminal_suspend",
  360. category: "System",
  361. onSelect: () => {
  362. process.once("SIGCONT", () => {
  363. renderer.resume()
  364. })
  365. renderer.suspend()
  366. // pid=0 means send the signal to all processes in the process group
  367. process.kill(0, "SIGTSTP")
  368. },
  369. },
  370. ])
  371. createEffect(() => {
  372. const currentModel = local.model.current()
  373. if (!currentModel) return
  374. if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
  375. untrack(() => {
  376. DialogAlert.show(
  377. dialog,
  378. "Warning",
  379. "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",
  380. ).then(() => kv.set("openrouter_warning", true))
  381. })
  382. }
  383. })
  384. event.on(TuiEvent.CommandExecute.type, (evt) => {
  385. command.trigger(evt.properties.command)
  386. })
  387. event.on(TuiEvent.ToastShow.type, (evt) => {
  388. toast.show({
  389. title: evt.properties.title,
  390. message: evt.properties.message,
  391. variant: evt.properties.variant,
  392. duration: evt.properties.duration,
  393. })
  394. })
  395. event.on(SessionApi.Event.Deleted.type, (evt) => {
  396. if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
  397. dialog.clear()
  398. route.navigate({ type: "home" })
  399. toast.show({
  400. variant: "info",
  401. message: "The current session was deleted",
  402. })
  403. }
  404. })
  405. event.on(SessionApi.Event.Error.type, (evt) => {
  406. const error = evt.properties.error
  407. const message = (() => {
  408. if (!error) return "An error occured"
  409. if (typeof error === "object") {
  410. const data = error.data
  411. if ("message" in data && typeof data.message === "string") {
  412. return data.message
  413. }
  414. }
  415. return String(error)
  416. })()
  417. toast.show({
  418. variant: "error",
  419. message,
  420. duration: 5000,
  421. })
  422. })
  423. event.on(Installation.Event.Updated.type, (evt) => {
  424. toast.show({
  425. variant: "success",
  426. title: "Update Complete",
  427. message: `OpenCode updated to v${evt.properties.version}`,
  428. duration: 5000,
  429. })
  430. })
  431. event.on(Installation.Event.UpdateAvailable.type, (evt) => {
  432. toast.show({
  433. variant: "info",
  434. title: "Update Available",
  435. message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
  436. duration: 10000,
  437. })
  438. })
  439. return (
  440. <box
  441. width={dimensions().width}
  442. height={dimensions().height}
  443. backgroundColor={theme.background}
  444. onMouseUp={async () => {
  445. if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
  446. renderer.clearSelection()
  447. return
  448. }
  449. const text = renderer.getSelection()?.getSelectedText()
  450. if (text && text.length > 0) {
  451. const base64 = Buffer.from(text).toString("base64")
  452. const osc52 = `\x1b]52;c;${base64}\x07`
  453. const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
  454. /* @ts-expect-error */
  455. renderer.writeOut(finalOsc52)
  456. await Clipboard.copy(text)
  457. .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
  458. .catch(toast.error)
  459. renderer.clearSelection()
  460. }
  461. }}
  462. >
  463. <Switch>
  464. <Match when={route.data.type === "home"}>
  465. <Home />
  466. </Match>
  467. <Match when={route.data.type === "session"}>
  468. <Session />
  469. </Match>
  470. </Switch>
  471. </box>
  472. )
  473. }
  474. function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
  475. const term = useTerminalDimensions()
  476. useKeyboard((evt) => {
  477. if (evt.ctrl && evt.name === "c") {
  478. props.onExit()
  479. }
  480. })
  481. const [copied, setCopied] = createSignal(false)
  482. const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
  483. if (props.error.message) {
  484. issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
  485. }
  486. if (props.error.stack) {
  487. issueURL.searchParams.set(
  488. "description",
  489. "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
  490. )
  491. }
  492. issueURL.searchParams.set("opencode-version", Installation.VERSION)
  493. const copyIssueURL = () => {
  494. Clipboard.copy(issueURL.toString()).then(() => {
  495. setCopied(true)
  496. })
  497. }
  498. return (
  499. <box flexDirection="column" gap={1}>
  500. <box flexDirection="row" gap={1} alignItems="center">
  501. <text attributes={TextAttributes.BOLD}>Please report an issue.</text>
  502. <box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
  503. <text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
  504. </box>
  505. {copied() && <text>Successfully copied</text>}
  506. </box>
  507. <box flexDirection="row" gap={2} alignItems="center">
  508. <text>A fatal error occurred!</text>
  509. <box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
  510. <text>Reset TUI</text>
  511. </box>
  512. <box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
  513. <text>Exit</text>
  514. </box>
  515. </box>
  516. <scrollbox height={Math.floor(term().height * 0.7)}>
  517. <text>{props.error.stack}</text>
  518. </scrollbox>
  519. <text>{props.error.message}</text>
  520. </box>
  521. )
  522. }