app.tsx 17 KB

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