import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" 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 { PromptStashProvider } from "./component/prompt/stash" 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" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" 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; args: Args; onExit?: () => Promise }) { // promise to prevent immediate exit return new Promise(async (resolve) => { const mode = await getTerminalBackgroundColor() const onExit = async () => { await input.onExit?.() resolve() } render( () => { return ( } > ) }, { targetFps: 60, gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: {}, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { Clipboard.copy(text).catch((error) => { console.error(`Failed to copy console selection to clipboard: ${error}`) }) }, }, }, ) }) } 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 sdk = useSDK() const toast = useToast() const { theme, mode, setMode } = useTheme() const sync = useSync() const exit = useExit() const promptRef = usePromptRef() // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return 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 writeOut is not in type definitions renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) renderer.clearSelection() } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) createEffect(() => { console.log(JSON.stringify(route.data)) }) // Update terminal window title based on current route and session createEffect(() => { if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (route.data.type === "home") { renderer.setTerminalTitle("OpenCode") return } if (route.data.type === "session") { const session = sync.session.get(route.data.sessionID) if (!session || SessionApi.isDefaultTitle(session.title)) { renderer.setTerminalTitle("OpenCode") return } // Truncate title to 40 chars max const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title renderer.setTerminalTitle(`OC | ${title}`) } }) const args = useArgs() onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) if (args.model) { const { providerID, modelID } = Provider.parseModel(args.model) if (!providerID || !modelID) return toast.show({ variant: "warning", message: `Invalid model format: ${args.model}`, duration: 3000, }) local.model.set({ providerID, modelID }, { recent: true }) } if (args.sessionID) { route.navigate({ type: "session", sessionID: args.sessionID, }) } }) }) let continued = false createEffect(() => { // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" if (continued || sync.status === "loading" || !args.continue) return const match = sync.data.session .toSorted((a, b) => b.time.updated - a.time.updated) .find((x) => x.parentID === undefined)?.id if (match) { continued = true route.navigate({ type: "session", sessionID: match }) } }) createEffect( on( () => sync.status === "complete" && sync.data.provider.length === 0, (isEmpty, wasEmpty) => { // only trigger when we transition into an empty-provider state if (!isEmpty || wasEmpty) return dialog.replace(() => ) }, ), ) const connected = useConnected() command.register(() => [ { title: "Switch session", value: "session.list", keybind: "session_list", category: "Session", suggested: sync.data.session.length > 0, onSelect: () => { dialog.replace(() => ) }, }, { title: "New session", suggested: route.data.type === "session", value: "session.new", keybind: "session_new", category: "Session", onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined route.navigate({ type: "home", initialPrompt: currentPrompt, }) dialog.clear() }, }, { title: "Switch model", value: "model.list", keybind: "model_list", suggested: true, category: "Agent", onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", disabled: true, value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", disabled: true, value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", onSelect: () => { local.model.cycle(-1) }, }, { title: "Favorite cycle", value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", onSelect: () => { local.model.cycleFavorite(1) }, }, { title: "Favorite cycle reverse", value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", onSelect: () => { local.model.cycleFavorite(-1) }, }, { title: "Switch agent", value: "agent.list", keybind: "agent_list", category: "Agent", onSelect: () => { dialog.replace(() => ) }, }, { title: "Toggle MCPs", value: "mcp.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: "Connect provider", value: "provider.connect", suggested: !connected(), onSelect: () => { dialog.replace(() => ) }, category: "Provider", }, { 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: "Toggle appearance", value: "theme.switch_mode", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() }, category: "System", }, { title: "Help", value: "help.show", onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Open docs", value: "docs.open", onSelect: () => { open("https://opencode.ai/docs").catch(() => {}) dialog.clear() }, category: "System", }, { title: "Open WebUI", value: "webui.open", onSelect: () => { open(sdk.url).catch(() => {}) dialog.clear() }, 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.console", onSelect: (dialog) => { renderer.console.toggle() dialog.clear() }, }, { title: "Suspend terminal", value: "terminal.suspend", keybind: "terminal_suspend", category: "System", onSelect: () => { process.once("SIGCONT", () => { renderer.resume() }) renderer.suspend() // pid=0 means send the signal to all processes in the process group process.kill(0, "SIGTSTP") }, }, { title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", value: "terminal.title.toggle", keybind: "terminal_title_toggle", category: "System", onSelect: (dialog) => { setTerminalTitleEnabled((prev) => { const next = !prev kv.set("terminal_title_enabled", next) if (!next) renderer.setTerminalTitle("") return next }) dialog.clear() }, }, ]) createEffect(() => { const currentModel = local.model.current() if (!currentModel) return if (currentModel.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)) }) } }) sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) sdk.event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, variant: evt.properties.variant, duration: evt.properties.duration, }) }) sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ variant: "info", message: "The current session was deleted", }) } }) sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { if (!error) return "An error occurred" 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, }) }) sdk.event.on(Installation.Event.Updated.type, (evt) => { toast.show({ variant: "success", title: "Update Complete", message: `OpenCode updated to v${evt.properties.version}`, duration: 5000, }) }) sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { toast.show({ variant: "info", title: "Update Available", message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`, duration: 10000, }) }) return ( { if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { renderer.clearSelection() 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() } }} > ) } function ErrorComponent(props: { error: Error reset: () => void onExit: () => Promise mode?: "dark" | "light" }) { 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") // Choose safe fallback colors per mode since theme context may not be available const isLight = props.mode === "light" const colors = { bg: isLight ? "#ffffff" : "#0a0a0a", text: isLight ? "#1a1a1a" : "#eeeeee", muted: isLight ? "#8a8a8a" : "#808080", primary: isLight ? "#3b7dd8" : "#fab283", } 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```", ) } issueURL.searchParams.set("opencode-version", Installation.VERSION) 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} ) }