import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, createMemo, ErrorBoundary, createSignal, onMount, batch, Show, on, onCleanup, } from "solid-js" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" import semver from "semver" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" 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 { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } 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 { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" 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 { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" 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) }) } import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) return { externalOutputMode: "passthrough", targetFps: 60, gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: {}, autoFocus: false, openConsoleOnError: false, useMouse: mouseEnabled, 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 errorMessage(error: unknown) { const formatted = FormatError(error) if (formatted !== undefined) return formatted if ( typeof error === "object" && error !== null && "data" in error && typeof error.data === "object" && error.data !== null && "message" in error.data && typeof error.data.message === "string" ) { return error.data.message } return FormatUnknownError(error) } export function tui(input: { url: string args: Args config: TuiConfig.Info onSnapshot?: () => Promise directory?: string fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource }) { // promise to prevent immediate exit return new Promise(async (resolve) => { const unguard = win32InstallCtrlCGuard() win32DisableProcessedInput() const mode = await getTerminalBackgroundColor() // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores // the original console mode which re-enables ENABLE_PROCESSED_INPUT. win32DisableProcessedInput() const onExit = async () => { unguard?.() resolve() } const onBeforeExit = async () => { await TuiPluginRuntime.dispose() } const renderer = await createCliRenderer(rendererConfig(input.config)) await render(() => { return ( ( )} > ) }, renderer) }) } function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() const dialog = useDialog() const local = useLocal() const kv = useKV() const command = useCommandDialog() const keybind = useKeybind() const sdk = useSDK() const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() const exit = useExit() const promptRef = usePromptRef() const routes: RouteMap = new Map() const [routeRev, setRouteRev] = createSignal(0) const routeView = (name: string) => { routeRev() return routes.get(name)?.at(-1)?.render } const api = createTuiApi({ command, tuiConfig, dialog, keybind, kv, route, routes, bump: () => setRouteRev((x) => x + 1), sdk, sync, theme: themeState, toast, renderer, }) onCleanup(() => { api.dispose() }) const [ready, setReady] = createSignal(false) TuiPluginRuntime.init(api) .catch((error) => { console.error("Failed to load TUI plugins", error) }) .finally(() => { setReady(true) }) useKeyboard((evt) => { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return const sel = renderer.getSelection() if (!sel) return // Windows Terminal-like behavior: // - Ctrl+C copies and dismisses selection // - Esc dismisses selection // - Most other key input dismisses selection and is passed through if (evt.ctrl && evt.name === "c") { if (!Selection.copy(renderer, toast)) { renderer.clearSelection() return } evt.preventDefault() evt.stopPropagation() return } if (evt.name === "escape") { renderer.clearSelection() evt.preventDefault() evt.stopPropagation() return } const focus = renderer.currentFocusedRenderable if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) { return } renderer.clearSelection() }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return 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)) // 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 } const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title renderer.setTerminalTitle(`OC | ${title}`) return } if (route.data.type === "plugin") { renderer.setTerminalTitle(`OC | ${route.data.id}`) } }) 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 }) } // Handle --session without --fork immediately (fork is handled in createEffect below) if (args.sessionID && !args.fork) { 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 if (args.fork) { sdk.client.session.fork({ sessionID: match }).then((result) => { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { toast.show({ message: "Failed to fork session", variant: "error" }) } }) } else { route.navigate({ type: "session", sessionID: match }) } } }) // Handle --session with --fork: wait for sync to be fully complete before forking // (session list loads in non-blocking phase for --session, so we must wait for "complete" // to avoid a race where reconcile overwrites the newly forked session) let forked = false createEffect(() => { if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return forked = true sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { toast.show({ message: "Failed to fork session", variant: "error" }) } }) }) 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, slash: { name: "sessions", aliases: ["resume", "continue"], }, onSelect: () => { dialog.replace(() => ) }, }, ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? [ { title: "Manage workspaces", value: "workspace.list", category: "Workspace", suggested: true, slash: { name: "workspaces", }, onSelect: () => { dialog.replace(() => ) }, }, ] : []), { title: "New session", suggested: route.data.type === "session", value: "session.new", keybind: "session_new", category: "Session", slash: { name: "new", aliases: ["clear"], }, onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined const workspaceID = route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined route.navigate({ type: "home", initialPrompt: currentPrompt, workspaceID, }) dialog.clear() }, }, { title: "Switch model", value: "model.list", keybind: "model_list", suggested: true, category: "Agent", slash: { name: "models", }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", hidden: true, onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", hidden: true, onSelect: () => { local.model.cycle(-1) }, }, { title: "Favorite cycle", value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", hidden: true, onSelect: () => { local.model.cycleFavorite(1) }, }, { title: "Favorite cycle reverse", value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", hidden: true, onSelect: () => { local.model.cycleFavorite(-1) }, }, { title: "Switch agent", value: "agent.list", keybind: "agent_list", category: "Agent", slash: { name: "agents", }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Toggle MCPs", value: "mcp.list", category: "Agent", slash: { name: "mcps", }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Agent cycle", value: "agent.cycle", keybind: "agent_cycle", category: "Agent", hidden: true, onSelect: () => { local.agent.move(1) }, }, { title: "Variant cycle", value: "variant.cycle", keybind: "variant_cycle", category: "Agent", onSelect: () => { local.model.variant.cycle() }, }, { title: "Switch model variant", value: "variant.list", category: "Agent", hidden: local.model.variant.list().length === 0, slash: { name: "variants", }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Agent cycle reverse", value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", hidden: true, onSelect: () => { local.agent.move(-1) }, }, { title: "Connect provider", value: "provider.connect", suggested: !connected(), slash: { name: "connect", }, onSelect: () => { dialog.replace(() => ) }, category: "Provider", }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ { title: "Switch org", value: "console.org.switch", suggested: Boolean(sync.data.console_state.activeOrgName), slash: { name: "org", aliases: ["orgs", "switch-org"], }, onSelect: () => { dialog.replace(() => ) }, category: "Provider", }, ] : []), { title: "View status", keybind: "status_view", value: "opencode.status", slash: { name: "status", }, onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Switch theme", value: "theme.switch", keybind: "theme_list", slash: { name: "themes", }, onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Toggle Theme Mode", value: "theme.switch_mode", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() }, category: "System", }, { title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode", value: "theme.mode.lock", onSelect: (dialog) => { if (locked()) unlock() else lock() dialog.clear() }, category: "System", }, { title: "Help", value: "help.show", slash: { name: "help", }, onSelect: () => { dialog.replace(() => ) }, category: "System", }, { title: "Open docs", value: "docs.open", onSelect: () => { open("https://opencode.ai/docs").catch(() => {}) dialog.clear() }, category: "System", }, { title: "Exit the app", value: "app.exit", slash: { name: "exit", aliases: ["quit", "q"], }, 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: "Write heap snapshot", category: "System", value: "app.heap_snapshot", onSelect: async (dialog) => { const files = await props.onSnapshot?.() toast.show({ variant: "info", message: `Heap snapshot written to ${files?.join(", ")}`, duration: 5000, }) dialog.clear() }, }, { title: "Suspend terminal", value: "terminal.suspend", keybind: "terminal_suspend", category: "System", hidden: true, enabled: tuiConfig.keybinds?.terminal_suspend !== "none", 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() }, }, { title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", value: "app.toggle.animations", category: "System", onSelect: (dialog) => { kv.set("animations_enabled", !kv.get("animations_enabled", true)) dialog.clear() }, }, { title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", value: "app.toggle.diffwrap", category: "System", onSelect: (dialog) => { const current = kv.get("diff_wrap_mode", "word") kv.set("diff_wrap_mode", current === "word" ? "none" : "word") dialog.clear() }, }, ]) 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(TuiEvent.SessionSelect.type, (evt) => { route.navigate({ type: "session", sessionID: evt.properties.sessionID, }) }) sdk.event.on("session.deleted", (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("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) toast.show({ variant: "error", message, duration: 5000, }) }) sdk.event.on("installation.update-available", async (evt) => { const version = evt.properties.version const skipped = kv.get("skipped_version") if (skipped && !semver.gt(version, skipped)) return const choice = await DialogConfirm.show( dialog, `Update Available`, `A new release v${version} is available. Would you like to update now?`, "skip", ) if (choice === false) { kv.set("skipped_version", version) return } if (choice !== true) return toast.show({ variant: "info", message: `Updating to v${version}...`, duration: 30000, }) const result = await sdk.client.global.upgrade({ target: version }) if (result.error || !result.data?.success) { toast.show({ variant: "error", title: "Update Failed", message: "Update failed", duration: 10000, }) return } await DialogAlert.show( dialog, "Update Complete", `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, ) exit() }) const plugin = createMemo(() => { if (!ready()) return if (route.data.type !== "plugin") return const render = routeView(route.data.id) if (!render) return route.navigate({ type: "home" })} /> return render({ params: route.data.data }) }) return ( { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return if (!Selection.copy(renderer, toast)) return evt.preventDefault() evt.stopPropagation() }} onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)} > {plugin()} ) }