| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919 |
- 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 { 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 {
- return {
- externalOutputMode: "passthrough",
- targetFps: 60,
- gatherStats: false,
- exitOnCtrlC: false,
- useKittyKeyboard: { events: process.platform === "win32" },
- autoFocus: false,
- openConsoleOnError: false,
- 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<string[]>
- directory?: string
- fetch?: typeof fetch
- headers?: RequestInit["headers"]
- events?: EventSource
- }) {
- // promise to prevent immediate exit
- return new Promise<void>(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 (
- <ErrorBoundary
- fallback={(error, reset) => (
- <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
- )}
- >
- <ArgsProvider {...input.args}>
- <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
- <KVProvider>
- <ToastProvider>
- <RouteProvider>
- <TuiConfigProvider config={input.config}>
- <SDKProvider
- url={input.url}
- directory={input.directory}
- fetch={input.fetch}
- headers={input.headers}
- events={input.events}
- >
- <SyncProvider>
- <ThemeProvider mode={mode}>
- <LocalProvider>
- <KeybindProvider>
- <PromptStashProvider>
- <DialogProvider>
- <CommandProvider>
- <FrecencyProvider>
- <PromptHistoryProvider>
- <PromptRefProvider>
- <App onSnapshot={input.onSnapshot} />
- </PromptRefProvider>
- </PromptHistoryProvider>
- </FrecencyProvider>
- </CommandProvider>
- </DialogProvider>
- </PromptStashProvider>
- </KeybindProvider>
- </LocalProvider>
- </ThemeProvider>
- </SyncProvider>
- </SDKProvider>
- </TuiConfigProvider>
- </RouteProvider>
- </ToastProvider>
- </KVProvider>
- </ExitProvider>
- </ArgsProvider>
- </ErrorBoundary>
- )
- }, renderer)
- })
- }
- function App(props: { onSnapshot?: () => Promise<string[]> }) {
- 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(() => <DialogProviderList />)
- },
- ),
- )
- 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(() => <DialogSessionList />)
- },
- },
- ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
- ? [
- {
- title: "Manage workspaces",
- value: "workspace.list",
- category: "Workspace",
- suggested: true,
- slash: {
- name: "workspaces",
- },
- onSelect: () => {
- dialog.replace(() => <DialogWorkspaceList />)
- },
- },
- ]
- : []),
- {
- 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(() => <DialogModel />)
- },
- },
- {
- 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(() => <DialogAgent />)
- },
- },
- {
- title: "Toggle MCPs",
- value: "mcp.list",
- category: "Agent",
- slash: {
- name: "mcps",
- },
- onSelect: () => {
- dialog.replace(() => <DialogMcp />)
- },
- },
- {
- 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(() => <DialogVariant />)
- },
- },
- {
- 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(() => <DialogProviderList />)
- },
- category: "Provider",
- },
- {
- title: "View status",
- keybind: "status_view",
- value: "opencode.status",
- slash: {
- name: "status",
- },
- onSelect: () => {
- dialog.replace(() => <DialogStatus />)
- },
- category: "System",
- },
- {
- title: "Switch theme",
- value: "theme.switch",
- keybind: "theme_list",
- slash: {
- name: "themes",
- },
- onSelect: () => {
- dialog.replace(() => <DialogThemeList />)
- },
- 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(() => <DialogHelp />)
- },
- 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,
- 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 <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
- return render({ params: route.data.data })
- })
- return (
- <box
- width={dimensions().width}
- height={dimensions().height}
- backgroundColor={theme.background}
- onMouseDown={(evt) => {
- 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)}
- >
- <Show when={Flag.OPENCODE_SHOW_TTFD}>
- <TimeToFirstDraw />
- </Show>
- <Show when={ready()}>
- <Switch>
- <Match when={route.data.type === "home"}>
- <Home />
- </Match>
- <Match when={route.data.type === "session"}>
- <Session />
- </Match>
- </Switch>
- </Show>
- {plugin()}
- <TuiPluginRuntime.Slot name="app" />
- <StartupLoading ready={ready} />
- </box>
- )
- }
|