app.tsx 15 KB

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