initial feature plugins

This commit is contained in:
Sebastian Herrlinger
2026-03-24 23:05:04 +01:00
parent c22e63ddb3
commit ba758efb3f
11 changed files with 548 additions and 535 deletions

View File

@@ -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<string, unknown>, 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 => ({
</box>
)
},
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}
>
<text fg={skin.accent}>
<b>{input.label}</b>
</text>
<text fg={skin.text}>sidebar slot active</text>
<text fg={skin.muted}>session {value.session_id.slice(0, 8)}</text>
</box>
)
},
sidebar_title(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box paddingRight={1} flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>{value.title}</b>
</text>
<text fg={skin.accent}>plugin</text>
</box>
<text fg={skin.muted}>session {value.session_id.slice(0, 8)}</text>
{value.share_url ? <text fg={skin.muted}>{value.share_url}</text> : null}
</box>
)
},
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 (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>Context</b>
</text>
<text fg={skin.accent}>slot</text>
</box>
<text fg={skin.text}>{value.tokens.toLocaleString()} tokens</text>
<text fg={skin.muted}>{bar ? `${used} · ${bar}` : used}</text>
<text fg={skin.muted}>{cash.format(value.cost)} spent</text>
</box>
)
},
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 (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>Working Tree</b>
</text>
<text fg={skin.accent}>{value.items.length}</text>
</box>
{list.map((item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={skin.muted} wrapMode="none">
{item.file}
</text>
<text fg={skin.text}>
{item.additions ? <span style={{ fg: add }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: del }}> -{item.deletions}</span> : null}
</text>
</box>
))}
{value.items.length > list.length ? (
<text fg={skin.muted}>+{value.items.length - list.length} more file(s)</text>
) : null}
</box>
)
},
sidebar_bottom(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{input.label} footer slot</b>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
append demo after {value.show_getting_started ? "welcome card" : "default footer"}
</text>
<text fg={skin.text}>
{value.directory_name} · {value.version}
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
@@ -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<string, unknown> | null, m
])
reg(api, value, keys)
api.slots.register(slot(value))
for (const item of slot(value)) {
api.slots.register(item)
}
}
export default {

View File

@@ -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 (
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme.textMuted}>{state().percent ?? 0}% used</text>
<text fg={theme.textMuted}>{money.format(cost())} spent</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_content(_ctx, props) {
return <View session_id={props.session_id} />
},
},
})
}
export default {
tui,
}

View File

@@ -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 (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="none">
{item.file}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 500,
slots: {
sidebar_content(_ctx, props) {
return <View session_id={props.session_id} />
},
},
})
}
export default {
tui,
}

View File

@@ -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 (
<box gap={1}>
<Show when={show()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0} fg={theme.text}>
</text>
<box flexGrow={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<b>Getting started</b>
</text>
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
</text>
</box>
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme.textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.text}>Connect provider</text>
<text fg={theme.textMuted}>/connect</text>
</box>
</box>
</box>
</Show>
<text>
<span style={{ fg: theme.textMuted }}>{path().parent}/</span>
<span style={{ fg: theme.text }}>{path().name}</span>
</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{Installation.VERSION}</span>
</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer() {
return <View />
},
},
})
}
export default {
tui,
}

View File

@@ -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 (
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<Show when={list().length === 0}>
<text fg={theme.textMuted}>
{sync.data.config.lsp === false
? "LSPs have been disabled in settings"
: "LSPs will activate as files are read"}
</text>
</Show>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 300,
slots: {
sidebar_content() {
return <View />
},
},
})
}
export default {
tui,
}

View File

@@ -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<string, typeof theme.success> = {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
}
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: theme.textMuted }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
</Show>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: dot[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{item.name}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed"}>
<i>{item.error}</i>
</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={item.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
sidebar_content() {
return <View />
},
},
})
}
export default {
tui,
}

View File

@@ -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 (
<box paddingRight={1}>
<text fg={theme.text}>
<b>{props.title}</b>
</text>
<Show when={props.share_url}>
<text fg={theme.textMuted}>{props.share_url}</text>
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_title(_ctx, props) {
return <View title={props.title} share_url={props.share_url} />
},
},
})
}
export default {
tui,
}

View File

@@ -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 (
<Show when={show()}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 400,
slots: {
sidebar_content(_ctx, props) {
return <View session_id={props.session_id} />
},
},
})
}
export default {
tui,
}

View File

@@ -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<string, unknown>
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,
},
]

View File

@@ -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<string, typeof theme.success> = {
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 (
<Show when={session()}>
@@ -101,6 +21,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
paddingRight={2}
position={props.overlay ? "absolute" : "relative"}
>
<box flexShrink={0} paddingRight={1}>
<TuiPluginRuntime.Slot
name="sidebar_title"
mode="single_winner"
session_id={props.sessionID}
title={session()!.title}
share_url={session()!.share?.url}
>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
</box>
</TuiPluginRuntime.Slot>
</box>
<scrollbox
flexGrow={1}
verticalScrollbarOptions={{
@@ -111,276 +49,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<TuiPluginRuntime.Slot name="sidebar_top" session_id={props.sessionID} />
<TuiPluginRuntime.Slot
name="sidebar_title"
mode="replace"
session_id={props.sessionID}
title={session().title}
share_url={session().share?.url}
>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_context"
mode="replace"
session_id={props.sessionID}
tokens={context().tokens}
percentage={context().percentage}
cost={cost()}
>
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context().tokens.toLocaleString()} tokens</text>
<text fg={theme.textMuted}>{context().percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{money.format(cost())} spent</text>
</box>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_mcp"
mode="replace"
session_id={props.sessionID}
items={mcp()}
connected={connectedMcpCount()}
errors={errorMcpCount()}
>
<Show when={mcp().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcp().length > 2 && setExpanded("mcp", !expanded.mcp)}
>
<Show when={mcp().length > 2}>
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!expanded.mcp}>
<span style={{ fg: theme.textMuted }}>
{" "}
({connectedMcpCount()} active
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
</span>
</Show>
</text>
</box>
<Show when={mcp().length <= 2 || expanded.mcp}>
<For each={mcp()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: mcpStatusColor[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{item.name}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed"}>
<i>{item.error}</i>
</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={item.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_lsp"
mode="replace"
session_id={props.sessionID}
items={lsp()}
disabled={sync.data.config.lsp === false}
>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => lsp().length > 2 && setExpanded("lsp", !expanded.lsp)}
>
<Show when={lsp().length > 2}>
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={lsp().length <= 2 || expanded.lsp}>
<Show when={lsp().length === 0}>
<text fg={theme.textMuted}>
{sync.data.config.lsp === false
? "LSPs have been disabled in settings"
: "LSPs will activate as files are read"}
</text>
</Show>
<For each={lsp()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
</box>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot name="sidebar_todo" mode="replace" session_id={props.sessionID} items={todo()}>
<Show when={todo().length > 0 && todo().some((item) => item.status !== "completed")}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || expanded.todo}>
<For each={todo()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
</Show>
</box>
</Show>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot name="sidebar_files" mode="replace" session_id={props.sessionID} items={diff()}>
<Show when={diff().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || expanded.diff}>
<For each={diff()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="none">
{item.file}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)}
</For>
</Show>
</box>
</Show>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<TuiPluginRuntime.Slot
name="sidebar_getting_started"
mode="replace"
session_id={props.sessionID}
show_getting_started={showGettingStarted()}
has_providers={hasProviders()}
dismissed={gettingStartedDismissed()}
>
<Show when={showGettingStarted()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0} fg={theme.text}>
</text>
<box flexGrow={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<b>Getting started</b>
</text>
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
</text>
</box>
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme.textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.text}>Connect provider</text>
<text fg={theme.textMuted}>/connect</text>
</box>
</box>
</box>
</Show>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_directory"
mode="replace"
session_id={props.sessionID}
directory={dir().value}
directory_parent={dir().parent}
directory_name={dir().name}
>
<text>
<span style={{ fg: theme.textMuted }}>{dir().parent}/</span>
<span style={{ fg: theme.text }}>{dir().name}</span>
</text>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_version"
mode="replace"
session_id={props.sessionID}
version={Installation.VERSION}
>
<TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
@@ -389,17 +63,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<span>{Installation.VERSION}</span>
</text>
</TuiPluginRuntime.Slot>
<TuiPluginRuntime.Slot
name="sidebar_bottom"
session_id={props.sessionID}
directory={dir().value}
directory_parent={dir().parent}
directory_name={dir().name}
version={Installation.VERSION}
show_getting_started={showGettingStarted()}
has_providers={hasProviders()}
dismissed={gettingStartedDismissed()}
/>
</box>
</box>
</Show>

View File

@@ -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
}
}