diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 67e6d530df..4552bed5aa 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -89,11 +89,6 @@ const ui = { type Color = RGBA | string -const cash = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - const ink = (map: Record, name: string, fallback: string): Color => { const value = map[name] if (typeof value === "string") return value @@ -607,7 +602,7 @@ const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; param ) } -const slot = (input: Cfg): TuiSlotPlugin => ({ +const home = (input: Cfg): TuiSlotPlugin => ({ slots: { home_logo(ctx) { const map = ctx.theme.current @@ -680,7 +675,13 @@ const slot = (input: Cfg): TuiSlotPlugin => ({ ) }, - sidebar_top(ctx, value) { + }, +}) + +const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({ + order, + slots: { + sidebar_content(ctx, value) { const skin = look(ctx.theme.current) return ( @@ -696,125 +697,11 @@ const slot = (input: Cfg): TuiSlotPlugin => ({ gap={1} > - {input.label} - - sidebar slot active - session {value.session_id.slice(0, 8)} - - ) - }, - sidebar_title(ctx, value) { - const skin = look(ctx.theme.current) - - return ( - - - - {value.title} - - plugin - - session {value.session_id.slice(0, 8)} - {value.share_url ? {value.share_url} : null} - - ) - }, - sidebar_context(ctx, value) { - const skin = look(ctx.theme.current) - const used = value.percentage === null ? "n/a" : `${value.percentage}%` - const bar = - value.percentage === null ? "" : "■".repeat(Math.max(1, Math.min(10, Math.round(value.percentage / 10)))) - - return ( - - - - Context - - slot - - {value.tokens.toLocaleString()} tokens - {bar ? `${used} · ${bar}` : used} - {cash.format(value.cost)} spent - - ) - }, - sidebar_files(ctx, value) { - if (!value.items.length) return null - const map = ctx.theme.current - const skin = look(map) - const add = ink(map, "diffAdded", "#7bd389") - const del = ink(map, "diffRemoved", "#ff8e8e") - const list = value.items.slice(0, 3) - - return ( - - - - Working Tree - - {value.items.length} - - {list.map((item) => ( - - - {item.file} - - - {item.additions ? +{item.additions} : null} - {item.deletions ? -{item.deletions} : null} - - - ))} - {value.items.length > list.length ? ( - +{value.items.length - list.length} more file(s) - ) : null} - - ) - }, - sidebar_bottom(ctx, value) { - const skin = look(ctx.theme.current) - - return ( - - - {input.label} footer slot + {title} + {text} - append demo after {value.show_getting_started ? "welcome card" : "default footer"} - - - {value.directory_name} · {value.version} + {input.label} order {order} · session {value.session_id.slice(0, 8)} ) @@ -822,6 +709,13 @@ const slot = (input: Cfg): TuiSlotPlugin => ({ }, }) +const slot = (input: Cfg): TuiSlotPlugin[] => [ + home(input), + block(input, 50, "Smoke above", "renders above internal sidebar blocks"), + block(input, 250, "Smoke between", "renders between internal sidebar blocks"), + block(input, 650, "Smoke below", "renders below internal sidebar blocks"), +] + const reg = (api: TuiApi, input: Cfg, keys: Keys) => { const route = names(input) api.command.register(() => [ @@ -957,7 +851,9 @@ const tui = async (api: TuiPluginApi, options: Record | null, m ]) reg(api, value, keys) - api.slots.register(slot(value)) + for (const item of slot(value)) { + api.slots.register(item) + } } export default { 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 new file mode 100644 index 0000000000..47d29b9c94 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -0,0 +1,61 @@ +import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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] ?? []) + const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) + + const state = createMemo(() => { + const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + if (!last) { + return { + tokens: 0, + percent: null as number | null, + } + } + + 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] + return { + tokens, + percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, + } + }) + + return ( + + + Context + + {state().tokens.toLocaleString()} tokens + {state().percent ?? 0}% used + {money.format(cost())} spent + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..5b10adc03c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx @@ -0,0 +1,60 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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() + const [open, setOpen] = createSignal(true) + const list = createMemo(() => sync.data.session_diff[props.session_id] ?? []) + + return ( + 0}> + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + Modified Files + + + + + {(item) => ( + + + {item.file} + + + + +{item.additions} + + + -{item.deletions} + + + + )} + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 500, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..f2889a89a7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -0,0 +1,94 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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() + + const has = createMemo(() => + sync.data.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 show = createMemo(() => !has() && !done()) + const path = createMemo(() => { + const value = dir() + const list = value.split("/") + return { + parent: list.slice(0, -1).join("/"), + name: list.at(-1) ?? "", + } + }) + + return ( + + + + + ⬖ + + + + + Getting started + + kv.set("dismissed_getting_started", true)}> + ✕ + + + 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 + + + + + + {path().parent}/ + {path().name} + + + Open + + Code + {" "} + {Installation.VERSION} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + sidebar_footer() { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..14ff7a3dbb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -0,0 +1,68 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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() + const [open, setOpen] = createSignal(true) + const list = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))) + + return ( + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + LSP + + + + + + {sync.data.config.lsp === false + ? "LSPs have been disabled in settings" + : "LSPs will activate as files are read"} + + + + {(item) => ( + + + • + + + {item.id} {item.root} + + + )} + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 300, + slots: { + sidebar_content() { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..51b3d8a1a3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx @@ -0,0 +1,103 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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() + 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 on = createMemo(() => list().filter((item) => item.status === "connected").length) + const bad = createMemo( + () => + list().filter( + (item) => + item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration", + ).length, + ) + + const dot: Record = { + connected: theme.success, + failed: theme.error, + disabled: theme.textMuted, + needs_auth: theme.warning, + needs_client_registration: theme.error, + } + + return ( + 0}> + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + MCP + + + {" "} + ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""}) + + + + + + + {(item) => ( + + + • + + + {item.name}{" "} + + + Connected + + {item.error} + + Disabled + Needs auth + Needs client ID + + + + + )} + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 200, + slots: { + sidebar_content() { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..9792332379 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/title.tsx @@ -0,0 +1,33 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import { Show } from "solid-js" +import { useTheme } from "../../context/theme" + +function View(props: { title: string; share_url?: string }) { + const { theme } = useTheme() + + return ( + + + {props.title} + + + {props.share_url} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + sidebar_title(_ctx, props) { + return + }, + }, + }) +} + +export default { + tui, +} 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 new file mode 100644 index 0000000000..7eb6c86fdf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx @@ -0,0 +1,46 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +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() + const [open, setOpen] = createSignal(true) + const list = createMemo(() => sync.data.todo[props.session_id] ?? []) + const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed")) + + return ( + + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + Todo + + + + {(item) => } + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 400, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +export default { + tui, +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 33e091c8e0..cf40e405f8 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -1,7 +1,44 @@ +import * as SidebarTitle from "../feature-plugins/sidebar/title" +import * as SidebarContext from "../feature-plugins/sidebar/context" +import * as SidebarMcp from "../feature-plugins/sidebar/mcp" +import * as SidebarLsp from "../feature-plugins/sidebar/lsp" +import * as SidebarTodo from "../feature-plugins/sidebar/todo" +import * as SidebarFiles from "../feature-plugins/sidebar/files" +import * as SidebarFooter from "../feature-plugins/sidebar/footer" + export type InternalTuiPlugin = { name: string module: Record root?: string } -export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [] +export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ + { + name: "sidebar-title", + module: SidebarTitle, + }, + { + name: "sidebar-content-context", + module: SidebarContext, + }, + { + name: "sidebar-content-mcp", + module: SidebarMcp, + }, + { + name: "sidebar-content-lsp", + module: SidebarLsp, + }, + { + name: "sidebar-content-todo", + module: SidebarTodo, + }, + { + name: "sidebar-content-files", + module: SidebarFiles, + }, + { + name: "sidebar-footer", + module: SidebarFooter, + }, +] diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 0e2745c890..004e529831 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,93 +1,13 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Installation } from "@/installation" -import { useDirectory } from "../../context/directory" -import { useKV } from "../../context/kv" -import { TodoItem } from "../../component/todo-item" import { TuiPluginRuntime } from "../../plugin" -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() - const session = createMemo(() => sync.session.get(props.sessionID)!) - const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) - const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) - const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) - - const [expanded, setExpanded] = createStore({ - mcp: true, - diff: true, - todo: true, - lsp: true, - }) - - const mcp = 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 lsp = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))) - - const connectedMcpCount = createMemo(() => mcp().filter((item) => item.status === "connected").length) - const errorMcpCount = createMemo( - () => - mcp().filter( - (item) => - item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration", - ).length, - ) - - const cost = createMemo(() => messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)) - const mcpStatusColor: Record = { - connected: theme.success, - failed: theme.error, - disabled: theme.textMuted, - needs_auth: theme.warning, - needs_client_registration: theme.error, - } - - const context = createMemo(() => { - const last = messages().findLast((x): x is AssistantMessage => x.role === "assistant" && x.tokens.output > 0) - if (!last) return { tokens: 0, percentage: null } - 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((x) => x.id === last.providerID)?.models[last.modelID] - return { - tokens, - percentage: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, - } - }) - - const directory = useDirectory() - const kv = useKV() - - const hasProviders = createMemo(() => - sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), - ) - const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) - const showGettingStarted = createMemo(() => !hasProviders() && !gettingStartedDismissed()) - const dir = createMemo(() => { - const value = directory() - const parts = value.split("/") - return { - value, - parent: parts.slice(0, -1).join("/"), - name: parts.at(-1) ?? "", - } - }) + const session = createMemo(() => sync.session.get(props.sessionID)) return ( @@ -101,6 +21,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { paddingRight={2} position={props.overlay ? "absolute" : "relative"} > + + + + + {session()!.title} + + + {session()!.share!.url} + + + + - - - - - {session().title} - - - {session().share!.url} - - - - - - - Context - - {context().tokens.toLocaleString()} tokens - {context().percentage ?? 0}% used - {money.format(cost())} spent - - - - 0}> - - mcp().length > 2 && setExpanded("mcp", !expanded.mcp)} - > - 2}> - {expanded.mcp ? "▼" : "▶"} - - - MCP - - - {" "} - ({connectedMcpCount()} active - {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) - - - - - - - {(item) => ( - - - • - - - {item.name}{" "} - - - Connected - - {item.error} - - Disabled - Needs auth - Needs client ID - - - - - )} - - - - - - - - lsp().length > 2 && setExpanded("lsp", !expanded.lsp)} - > - 2}> - {expanded.lsp ? "▼" : "▶"} - - - LSP - - - - - - {sync.data.config.lsp === false - ? "LSPs have been disabled in settings" - : "LSPs will activate as files are read"} - - - - {(item) => ( - - - • - - - {item.id} {item.root} - - - )} - - - - - - 0 && todo().some((item) => item.status !== "completed")}> - - todo().length > 2 && setExpanded("todo", !expanded.todo)} - > - 2}> - {expanded.todo ? "▼" : "▶"} - - - Todo - - - - {(item) => } - - - - - - 0}> - - diff().length > 2 && setExpanded("diff", !expanded.diff)} - > - 2}> - {expanded.diff ? "▼" : "▶"} - - - Modified Files - - - - - {(item) => ( - - - {item.file} - - - - +{item.additions} - - - -{item.deletions} - - - - )} - - - - - + - - - - - ⬖ - - - - - Getting started - - kv.set("dismissed_getting_started", true)}> - ✕ - - - 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 - - - - - - - - {dir().parent}/ - {dir().name} - - - + Open @@ -389,17 +63,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {Installation.VERSION} - diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index e5e1e78f6d..2a9bfa1ddd 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -220,64 +220,16 @@ export type TuiSlotMap = { tips_hidden: boolean first_time_user: boolean } - sidebar_top: { - session_id: string - } sidebar_title: { session_id: string title: string share_url?: string } - sidebar_context: { + sidebar_content: { session_id: string - tokens: number - percentage: number | null - cost: number } - sidebar_mcp: { + sidebar_footer: { session_id: string - items: TuiSidebarMcpItem[] - connected: number - errors: number - } - sidebar_lsp: { - session_id: string - items: TuiSidebarLspItem[] - disabled: boolean - } - sidebar_todo: { - session_id: string - items: TuiSidebarTodoItem[] - } - sidebar_files: { - session_id: string - items: TuiSidebarFileItem[] - } - sidebar_getting_started: { - session_id: string - show_getting_started: boolean - has_providers: boolean - dismissed: boolean - } - sidebar_directory: { - session_id: string - directory: string - directory_parent: string - directory_name: string - } - sidebar_version: { - session_id: string - version: string - } - sidebar_bottom: { - session_id: string - directory: string - directory_parent: string - directory_name: string - version: string - show_getting_started: boolean - has_providers: boolean - dismissed: boolean } }