import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute, type Route } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" import { DialogAlert } from "./ui/dialog-alert" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" import { Session as SessionApi } from "@/session" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY if (!process.stdin.isTTY) return "dark" return new Promise((resolve) => { let timeout: NodeJS.Timeout const cleanup = () => { process.stdin.setRawMode(false) process.stdin.removeListener("data", handler) clearTimeout(timeout) } const handler = (data: Buffer) => { const str = data.toString() const match = str.match(/\x1b]11;([^\x07\x1b]+)/) if (match) { cleanup() const color = match[1] // Parse RGB values from color string // Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B) let r = 0, g = 0, b = 0 if (color.startsWith("rgb:")) { const parts = color.substring(4).split("/") r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit } else if (color.startsWith("#")) { r = parseInt(color.substring(1, 3), 16) g = parseInt(color.substring(3, 5), 16) b = parseInt(color.substring(5, 7), 16) } else if (color.startsWith("rgb(")) { const parts = color.substring(4, color.length - 1).split(",") r = parseInt(parts[0]) g = parseInt(parts[1]) b = parseInt(parts[2]) } // Calculate luminance using relative luminance formula const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 // Determine if dark or light based on luminance threshold resolve(luminance > 0.5 ? "light" : "dark") } } process.stdin.setRawMode(true) process.stdin.on("data", handler) process.stdout.write("\x1b]11;?\x07") timeout = setTimeout(() => { cleanup() resolve("dark") }, 1000) }) } export function tui(input: { url: string sessionID?: string model?: string agent?: string prompt?: string onExit?: () => Promise }) { // promise to prevent immediate exit return new Promise(async (resolve) => { const mode = await getTerminalBackgroundColor() const routeData: Route | undefined = input.sessionID ? { type: "session", sessionID: input.sessionID, } : undefined const onExit = async () => { await input.onExit?.() resolve() } render( () => { return ( ( )} > ) }, { targetFps: 60, gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: true, }, ) }) } function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() const kv = useKV() const command = useCommandDialog() const { event } = useSDK() const toast = useToast() const { theme, mode, setMode } = useTheme() const exit = useExit() createEffect(() => { console.log(JSON.stringify(route.data)) }) command.register(() => [ { title: "Switch session", value: "session.list", keybind: "session_list", category: "Session", onSelect: () => { dialog.replace(() => ) }, }, { title: "New session", value: "session.new", keybind: "session_new", category: "Session", onSelect: () => { route.navigate({ type: "home", }) dialog.clear() }, }, { title: "Switch model", value: "model.list", keybind: "model_list", category: "Agent", onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", onSelect: () => { local.model.cycle(-1) }, }, { title: "Switch agent", value: "agent.list", keybind: "agent_list", category: "Agent", onSelect: () => { dialog.replace(() => ) }, }, { title: "Agent cycle", value: "agent.cycle", keybind: "agent_cycle", category: "Agent", disabled: true, onSelect: () => { local.agent.move(1) }, }, { title: "Agent cycle reverse", value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", disabled: true, onSelect: () => { local.agent.move(-1) }, }, { title: "View status", keybind: "status_view", value: "opencode.status", onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Switch theme", value: "theme.switch", onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`, value: "theme.switch_mode", onSelect: () => { setMode(mode() === "dark" ? "light" : "dark") }, category: "System", }, { title: "Help", value: "help.show", onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Exit the app", value: "app.exit", onSelect: exit, category: "System", }, { title: "Toggle debug panel", category: "System", value: "app.debug", onSelect: (dialog) => { renderer.toggleDebugOverlay() dialog.clear() }, }, { title: "Toggle console", category: "System", value: "app.fps", onSelect: (dialog) => { renderer.console.toggle() dialog.clear() }, }, ]) createEffect(() => { const providerID = local.model.current().providerID if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) { untrack(() => { DialogAlert.show( dialog, "Warning", "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", ).then(() => kv.set("openrouter_warning", true)) }) } }) event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, variant: evt.properties.variant, duration: evt.properties.duration, }) }) event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { dialog.clear() route.navigate({ type: "home" }) toast.show({ variant: "info", message: "The current session was deleted", }) } }) event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { if (!error) return "An error occured" if (typeof error === "object") { const data = error.data if ("message" in data && typeof data.message === "string") { return data.message } } return String(error) })() toast.show({ variant: "error", message, duration: 5000, }) }) return ( { const text = renderer.getSelection()?.getSelectedText() if (text && text.length > 0) { const base64 = Buffer.from(text).toString("base64") const osc52 = `\x1b]52;c;${base64}\x07` const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 /* @ts-expect-error */ renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) renderer.clearSelection() } }} > open code{" "} v{Installation.VERSION} {process.cwd().replace(Global.Path.home, "~")} tab {""} {local.agent.current().name.toUpperCase()} AGENT ) } function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise }) { const term = useTerminalDimensions() useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { props.onExit() } }) const [copied, setCopied] = createSignal(false) const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml") if (props.error.message) { issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`) } if (props.error.stack) { issueURL.searchParams.set( "description", "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```", ) } const copyIssueURL = () => { Clipboard.copy(issueURL.toString()).then(() => { setCopied(true) }) } return ( Please report an issue. Copy issue URL (exception info pre-filled) {copied() && Successfully copied} A fatal error occurred! Reset TUI Exit {props.error.stack} {props.error.message} ) }