app.tsx 16 KB

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