diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index 47d29b9c94..92e9f09406 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -1,18 +1,16 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo } from "solid-js" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }) -function View(props: { session_id: string }) { - const sync = useSync() - const { theme } = useTheme() - const msg = createMemo(() => sync.data.message[props.session_id] ?? []) +function View(props: { api: TuiPluginApi; session_id: string }) { + const theme = () => props.api.theme.current as Record + const msg = createMemo(() => props.api.state.session.messages(props.session_id)) const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) const state = createMemo(() => { @@ -26,7 +24,7 @@ function View(props: { session_id: string }) { const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, @@ -35,12 +33,12 @@ function View(props: { session_id: string }) { return ( - + Context - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used - {money.format(cost())} spent + {state().tokens.toLocaleString()} tokens + {state().percent ?? 0}% used + {money.format(cost())} spent ) } @@ -50,7 +48,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_content(_ctx, props) { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx index 5b10adc03c..cb49e85cd5 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx @@ -1,22 +1,20 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo, For, Show, createSignal } from "solid-js" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" -function View(props: { session_id: string }) { - const sync = useSync() - const { theme } = useTheme() +function View(props: { api: TuiPluginApi; session_id: string }) { const [open, setOpen] = createSignal(true) - const list = createMemo(() => sync.data.session_diff[props.session_id] ?? []) + const theme = () => props.api.theme.current as Record + const list = createMemo(() => props.api.state.session.diff(props.session_id)) return ( 0}> list().length > 2 && setOpen((x) => !x)}> 2}> - {open() ? "▼" : "▶"} + {open() ? "▼" : "▶"} - + Modified Files @@ -24,15 +22,15 @@ function View(props: { session_id: string }) { {(item) => ( - + {item.file} - +{item.additions} + +{item.additions} - -{item.deletions} + -{item.deletions} @@ -49,7 +47,7 @@ const tui: TuiPlugin = async (api) => { order: 500, slots: { sidebar_content(_ctx, props) { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index f2889a89a7..0d2914436e 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -1,27 +1,19 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo, Show } from "solid-js" -import { Installation } from "@/installation" -import { useDirectory } from "../../context/directory" -import { useKV } from "../../context/kv" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" - -function View() { - const sync = useSync() - const kv = useKV() - const dir = useDirectory() - const { theme } = useTheme() +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current as Record const has = createMemo(() => - sync.data.provider.some( + props.api.state.provider.some( (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), ), ) - const done = createMemo(() => kv.get("dismissed_getting_started", false)) + const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const value = dir() - const list = value.split("/") + const dir = props.api.app.directory + const list = dir.split("/") return { parent: list.slice(0, -1).join("/"), name: list.at(-1) ?? "", @@ -32,7 +24,7 @@ function View() { - + - + Getting started - kv.set("dismissed_getting_started", true)}> + props.api.kv.set("dismissed_getting_started", true)}> ✕ - OpenCode includes free models so you can start immediately. - + OpenCode includes free models so you can start immediately. + Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc - Connect provider - /connect + Connect provider + /connect - {path().parent}/ - {path().name} + {path().parent}/ + {path().name} - - Open - + + Open + Code {" "} - {Installation.VERSION} + {props.api.app.version} ) @@ -83,7 +75,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_footer() { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index 14ff7a3dbb..2acca79272 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -1,30 +1,27 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo, For, Show, createSignal } from "solid-js" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" -function View() { - const sync = useSync() - const { theme } = useTheme() +function View(props: { api: TuiPluginApi }) { const [open, setOpen] = createSignal(true) - const list = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))) + const theme = () => props.api.theme.current as Record + const list = createMemo(() => props.api.state.lsp()) + const off = createMemo(() => props.api.state.config.lsp === false) return ( list().length > 2 && setOpen((x) => !x)}> 2}> - {open() ? "▼" : "▶"} + {open() ? "▼" : "▶"} - + LSP - - {sync.data.config.lsp === false - ? "LSPs have been disabled in settings" - : "LSPs will activate as files are read"} + + {off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"} @@ -33,15 +30,12 @@ function View() { - + {item.id} {item.root} @@ -57,7 +51,7 @@ const tui: TuiPlugin = async (api) => { order: 300, slots: { sidebar_content() { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx index 51b3d8a1a3..a18e6ff781 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx @@ -1,23 +1,11 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" -function View() { - const sync = useSync() - const { theme } = useTheme() +function View(props: { api: TuiPluginApi }) { const [open, setOpen] = createSignal(true) - - const list = createMemo(() => - Object.entries(sync.data.mcp) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, item]) => ({ - name, - status: item.status, - error: item.status === "failed" ? item.error : undefined, - })), - ) - + const theme = () => props.api.theme.current as Record + const list = createMemo(() => props.api.state.mcp()) const on = createMemo(() => list().filter((item) => item.status === "connected").length) const bad = createMemo( () => @@ -27,12 +15,15 @@ function View() { ).length, ) - const dot: Record = { - connected: theme.success, - failed: theme.error, - disabled: theme.textMuted, - needs_auth: theme.warning, - needs_client_registration: theme.error, + const dot = (status: string) => { + const map: Record = { + connected: theme().success, + failed: theme().error, + disabled: theme().textMuted, + needs_auth: theme().warning, + needs_client_registration: theme().error, + } + return map[status] ?? theme().textMuted } return ( @@ -40,12 +31,12 @@ function View() { list().length > 2 && setOpen((x) => !x)}> 2}> - {open() ? "▼" : "▶"} + {open() ? "▼" : "▶"} - + MCP - + {" "} ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""}) @@ -59,14 +50,14 @@ function View() { - + {item.name}{" "} - + Connected @@ -92,7 +83,7 @@ const tui: TuiPlugin = async (api) => { order: 200, slots: { sidebar_content() { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/title.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/title.tsx index 9792332379..ecb956fccc 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/title.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/title.tsx @@ -1,17 +1,17 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { Show } from "solid-js" -import { useTheme } from "../../context/theme" -function View(props: { title: string; share_url?: string }) { - const { theme } = useTheme() +function View(props: { api: TuiPluginApi; title: string; share_url?: string }) { + const theme = () => props.api.theme.current as Record return ( - + {props.title} - {props.share_url} + {props.share_url} ) @@ -22,7 +22,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_title(_ctx, props) { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx index 7eb6c86fdf..9fc174b5fd 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx @@ -1,14 +1,12 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" import { createMemo, For, Show, createSignal } from "solid-js" -import { useSync } from "../../context/sync" -import { useTheme } from "../../context/theme" import { TodoItem } from "../../component/todo-item" -function View(props: { session_id: string }) { - const sync = useSync() - const { theme } = useTheme() +function View(props: { api: TuiPluginApi; session_id: string }) { const [open, setOpen] = createSignal(true) - const list = createMemo(() => sync.data.todo[props.session_id] ?? []) + const theme = () => props.api.theme.current as Record + const list = createMemo(() => props.api.state.session.todo(props.session_id)) const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed")) return ( @@ -16,9 +14,9 @@ function View(props: { session_id: string }) { list().length > 2 && setOpen((x) => !x)}> 2}> - {open() ? "▼" : "▶"} + {open() ? "▼" : "▶"} - + Todo @@ -35,7 +33,7 @@ const tui: TuiPlugin = async (api) => { order: 400, slots: { sidebar_content(_ctx, props) { - return + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index f27ee5d636..d8afb8badb 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -14,6 +14,8 @@ import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select" import type { useToast } from "../ui/toast" +import { Global } from "@/global" +import { Installation } from "@/installation" type RouteEntry = { key: symbol @@ -119,6 +121,15 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { function stateApi(sync: ReturnType): TuiApi["state"] { return { + get ready() { + return sync.ready + }, + get config() { + return sync.data.config + }, + get provider() { + return sync.data.provider + }, session: { diff(sessionID) { return sync.data.session_diff[sessionID] ?? [] @@ -126,6 +137,9 @@ function stateApi(sync: ReturnType): TuiApi["state"] { todo(sessionID) { return sync.data.todo[sessionID] ?? [] }, + messages(sessionID) { + return sync.data.message[sessionID] ?? [] + }, }, lsp() { return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })) @@ -142,8 +156,23 @@ function stateApi(sync: ReturnType): TuiApi["state"] { } } +function appApi(sync: ReturnType): TuiApi["app"] { + return { + get version() { + return Installation.VERSION + }, + get directory() { + const dir = sync.data.path.directory || process.cwd() + const out = dir.replace(Global.Path.home, "~") + if (sync.data.vcs?.branch) return out + ":" + sync.data.vcs.branch + return out + }, + } +} + export function createTuiApi(input: Input): TuiApi { return { + app: appApi(input.sync), command: { register(cb) { return input.command.register(() => cb()) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index d5b570256d..77ecaa2d92 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -18,7 +18,11 @@ type Opts = { count?: Count keybind?: Partial tuiConfig?: HostPluginApi["tuiConfig"] + app?: Partial state?: { + ready?: HostPluginApi["state"]["ready"] + config?: HostPluginApi["state"]["config"] + provider?: HostPluginApi["state"]["provider"] session?: Partial lsp?: HostPluginApi["state"]["lsp"] mcp?: HostPluginApi["state"]["mcp"] @@ -68,6 +72,14 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { } return { + app: { + get version() { + return opts.app?.version ?? "0.0.0-test" + }, + get directory() { + return opts.app?.directory ?? "~" + }, + }, client: opts.client ?? createOpencodeClient({ @@ -154,9 +166,19 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, }, state: { + get ready() { + return opts.state?.ready ?? true + }, + get config() { + return opts.state?.config ?? {} + }, + get provider() { + return opts.state?.provider ?? [] + }, session: { diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), + messages: opts.state?.session?.messages ?? (() => []), }, lsp: opts.state?.lsp ?? (() => []), mcp: opts.state?.mcp ?? (() => []), diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 2a9bfa1ddd..4909e4e171 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -1,7 +1,16 @@ -import type { OpencodeClient, Event, LspStatus, McpStatus, Todo } from "@opencode-ai/sdk/v2" +import type { + OpencodeClient, + Event, + LspStatus, + McpStatus, + Todo, + Message, + Provider, + Config as SdkConfig, +} from "@opencode-ai/sdk/v2" import type { CliRenderer, ParsedKey } from "@opentui/core" import type { JSX, SolidPlugin } from "@opentui/solid" -import type { Config, Plugin, PluginOptions } from "./index.js" +import type { Config as PluginConfig, Plugin, PluginOptions } from "./index.js" export type { CliRenderer, SlotMode } from "@opentui/core" @@ -143,15 +152,24 @@ export type TuiKV = { } export type TuiState = { + readonly ready: boolean + readonly config: SdkConfig + readonly provider: ReadonlyArray session: { diff: (sessionID: string) => ReadonlyArray todo: (sessionID: string) => ReadonlyArray + messages: (sessionID: string) => ReadonlyArray } lsp: () => ReadonlyArray mcp: () => ReadonlyArray } -type TuiConfigView = Pick & NonNullable +type TuiConfigView = Pick & NonNullable + +export type TuiApp = { + readonly version: string + readonly directory: string +} type Frozen = Value extends (...args: never[]) => unknown ? Value @@ -162,6 +180,7 @@ type Frozen = Value extends (...args: never[]) => unknown : Value export type TuiApi = { + app: TuiApp command: { register: (cb: () => TuiCommand[]) => () => void trigger: (value: string) => void