app.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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 } 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. export function tui(input: {
  32. url: string
  33. sessionID?: string
  34. model?: string
  35. agent?: string
  36. onExit?: () => Promise<void>
  37. }) {
  38. // promise to prevent immediate exit
  39. return new Promise<void>((resolve) => {
  40. const routeData: Route | undefined = input.sessionID
  41. ? {
  42. type: "session",
  43. sessionID: input.sessionID,
  44. }
  45. : undefined
  46. const onExit = async () => {
  47. await input.onExit?.()
  48. resolve()
  49. }
  50. render(
  51. () => {
  52. return (
  53. <ErrorBoundary
  54. fallback={(error, reset) => (
  55. <ErrorComponent error={error} reset={reset} onExit={onExit} />
  56. )}
  57. >
  58. <ExitProvider onExit={onExit}>
  59. <KVProvider>
  60. <ToastProvider>
  61. <RouteProvider data={routeData}>
  62. <SDKProvider url={input.url}>
  63. <SyncProvider>
  64. <ThemeProvider>
  65. <LocalProvider initialModel={input.model} initialAgent={input.agent}>
  66. <KeybindProvider>
  67. <DialogProvider>
  68. <CommandProvider>
  69. <PromptHistoryProvider>
  70. <App />
  71. </PromptHistoryProvider>
  72. </CommandProvider>
  73. </DialogProvider>
  74. </KeybindProvider>
  75. </LocalProvider>
  76. </ThemeProvider>
  77. </SyncProvider>
  78. </SDKProvider>
  79. </RouteProvider>
  80. </ToastProvider>
  81. </KVProvider>
  82. </ExitProvider>
  83. </ErrorBoundary>
  84. )
  85. },
  86. {
  87. targetFps: 60,
  88. gatherStats: false,
  89. exitOnCtrlC: false,
  90. },
  91. )
  92. })
  93. }
  94. function App() {
  95. const route = useRoute()
  96. const dimensions = useTerminalDimensions()
  97. const renderer = useRenderer()
  98. renderer.disableStdoutInterception()
  99. const dialog = useDialog()
  100. const local = useLocal()
  101. const kv = useKV()
  102. const command = useCommandDialog()
  103. const { event } = useSDK()
  104. const sync = useSync()
  105. const toast = useToast()
  106. const [sessionExists, setSessionExists] = createSignal(false)
  107. const { theme } = useTheme()
  108. useKeyboard(async (evt) => {
  109. if (evt.meta && evt.name === "t") {
  110. renderer.toggleDebugOverlay()
  111. return
  112. }
  113. if (evt.meta && evt.name === "d") {
  114. renderer.console.toggle()
  115. return
  116. }
  117. })
  118. // Make sure session is valid, otherwise redirect to home
  119. createEffect(async () => {
  120. if (route.data.type === "session") {
  121. const data = route.data as SessionRoute
  122. await sync.session.sync(data.sessionID).catch(() => {
  123. toast.show({
  124. message: `Session not found: ${data.sessionID}`,
  125. variant: "error",
  126. })
  127. return route.navigate({ type: "home" })
  128. })
  129. setSessionExists(true)
  130. }
  131. })
  132. createEffect(() => {
  133. console.log(JSON.stringify(route.data))
  134. })
  135. command.register(() => [
  136. {
  137. title: "Switch session",
  138. value: "session.list",
  139. keybind: "session_list",
  140. category: "Session",
  141. onSelect: () => {
  142. dialog.replace(() => <DialogSessionList />)
  143. },
  144. },
  145. {
  146. title: "New session",
  147. value: "session.new",
  148. keybind: "session_new",
  149. category: "Session",
  150. onSelect: () => {
  151. route.navigate({
  152. type: "home",
  153. })
  154. dialog.clear()
  155. },
  156. },
  157. {
  158. title: "Switch model",
  159. value: "model.list",
  160. keybind: "model_list",
  161. category: "Agent",
  162. onSelect: () => {
  163. dialog.replace(() => <DialogModel />)
  164. },
  165. },
  166. {
  167. title: "Model cycle",
  168. value: "model.cycle_recent",
  169. keybind: "model_cycle_recent",
  170. category: "Agent",
  171. onSelect: () => {
  172. local.model.cycle(1)
  173. },
  174. },
  175. {
  176. title: "Model cycle reverse",
  177. value: "model.cycle_recent_reverse",
  178. keybind: "model_cycle_recent_reverse",
  179. category: "Agent",
  180. onSelect: () => {
  181. local.model.cycle(-1)
  182. },
  183. },
  184. {
  185. title: "Switch agent",
  186. value: "agent.list",
  187. keybind: "agent_list",
  188. category: "Agent",
  189. onSelect: () => {
  190. dialog.replace(() => <DialogAgent />)
  191. },
  192. },
  193. {
  194. title: "Agent cycle",
  195. value: "agent.cycle",
  196. keybind: "agent_cycle",
  197. category: "Agent",
  198. disabled: true,
  199. onSelect: () => {
  200. local.agent.move(1)
  201. },
  202. },
  203. {
  204. title: "Agent cycle reverse",
  205. value: "agent.cycle.reverse",
  206. keybind: "agent_cycle_reverse",
  207. category: "Agent",
  208. disabled: true,
  209. onSelect: () => {
  210. local.agent.move(-1)
  211. },
  212. },
  213. {
  214. title: "View status",
  215. keybind: "status_view",
  216. value: "opencode.status",
  217. onSelect: () => {
  218. dialog.replace(() => <DialogStatus />)
  219. },
  220. category: "System",
  221. },
  222. {
  223. title: "Switch theme",
  224. value: "theme.switch",
  225. onSelect: () => {
  226. dialog.replace(() => <DialogThemeList />)
  227. },
  228. category: "System",
  229. },
  230. {
  231. title: "Help",
  232. value: "help.show",
  233. onSelect: () => {
  234. dialog.replace(() => <DialogHelp />)
  235. },
  236. category: "System",
  237. },
  238. ])
  239. createEffect(() => {
  240. const providerID = local.model.current().providerID
  241. if (providerID === "openrouter" && !kv.data.openrouter_warning) {
  242. untrack(() => {
  243. DialogAlert.show(
  244. dialog,
  245. "Warning",
  246. "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",
  247. ).then(() => kv.set("openrouter_warning", true))
  248. })
  249. }
  250. })
  251. event.on(TuiEvent.CommandExecute.type, (evt) => {
  252. command.trigger(evt.properties.command)
  253. })
  254. event.on(TuiEvent.ToastShow.type, (evt) => {
  255. toast.show({
  256. title: evt.properties.title,
  257. message: evt.properties.message,
  258. variant: evt.properties.variant,
  259. duration: evt.properties.duration,
  260. })
  261. })
  262. event.on(SessionApi.Event.Deleted.type, (evt) => {
  263. if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
  264. route.navigate({ type: "home" })
  265. toast.show({
  266. variant: "info",
  267. message: "The current session was deleted",
  268. })
  269. }
  270. })
  271. return (
  272. <box
  273. width={dimensions().width}
  274. height={dimensions().height}
  275. backgroundColor={theme.background}
  276. onMouseUp={async () => {
  277. const text = renderer.getSelection()?.getSelectedText()
  278. if (text && text.length > 0) {
  279. const base64 = Buffer.from(text).toString("base64")
  280. const osc52 = `\x1b]52;c;${base64}\x07`
  281. const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
  282. /* @ts-expect-error */
  283. renderer.writeOut(finalOsc52)
  284. await Clipboard.copy(text)
  285. .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
  286. .catch(toast.error)
  287. renderer.clearSelection()
  288. }
  289. }}
  290. >
  291. <box flexDirection="column" flexGrow={1}>
  292. <Switch>
  293. <Match when={route.data.type === "home"}>
  294. <Home />
  295. </Match>
  296. <Match when={route.data.type === "session" && sessionExists()}>
  297. <Session />
  298. </Match>
  299. </Switch>
  300. </box>
  301. <box
  302. height={1}
  303. backgroundColor={theme.backgroundPanel}
  304. flexDirection="row"
  305. justifyContent="space-between"
  306. flexShrink={0}
  307. >
  308. <box flexDirection="row">
  309. <box
  310. flexDirection="row"
  311. backgroundColor={theme.backgroundElement}
  312. paddingLeft={1}
  313. paddingRight={1}
  314. >
  315. <text fg={theme.textMuted}>open</text>
  316. <text attributes={TextAttributes.BOLD}>code </text>
  317. <text fg={theme.textMuted}>v{Installation.VERSION}</text>
  318. </box>
  319. <box paddingLeft={1} paddingRight={1}>
  320. <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
  321. </box>
  322. </box>
  323. <box flexDirection="row" flexShrink={0}>
  324. <text fg={theme.textMuted} paddingRight={1}>
  325. tab
  326. </text>
  327. <text fg={local.agent.color(local.agent.current().name)}>{""}</text>
  328. <text
  329. bg={local.agent.color(local.agent.current().name)}
  330. fg={theme.background}
  331. wrapMode={undefined}
  332. >
  333. <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
  334. <span> AGENT </span>
  335. </text>
  336. </box>
  337. </box>
  338. </box>
  339. )
  340. }
  341. function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
  342. const term = useTerminalDimensions()
  343. useKeyboard((evt) => {
  344. if (evt.ctrl && evt.name === "c") {
  345. props.onExit()
  346. }
  347. })
  348. const [copied, setCopied] = createSignal(false)
  349. const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
  350. if (props.error.message) {
  351. issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
  352. }
  353. if (props.error.stack) {
  354. issueURL.searchParams.set(
  355. "description",
  356. "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
  357. )
  358. }
  359. const copyIssueURL = () => {
  360. Clipboard.copy(issueURL.toString()).then(() => {
  361. setCopied(true)
  362. })
  363. }
  364. return (
  365. <box flexDirection="column" gap={1}>
  366. <box flexDirection="row" gap={1} alignItems="center">
  367. <text attributes={TextAttributes.BOLD}>Please report an issue.</text>
  368. <box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
  369. <text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
  370. </box>
  371. {copied() && <text>Successfully copied</text>}
  372. </box>
  373. <box flexDirection="row" gap={2} alignItems="center">
  374. <text>A fatal error occurred!</text>
  375. <box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
  376. <text>Reset TUI</text>
  377. </box>
  378. <box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
  379. <text>Exit</text>
  380. </box>
  381. </box>
  382. <scrollbox height={Math.floor(term().height * 0.7)}>
  383. <text>{props.error.stack}</text>
  384. </scrollbox>
  385. <text>{props.error.message}</text>
  386. </box>
  387. )
  388. }