app.tsx 16 KB

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