app.tsx 15 KB

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