feat(tui): add v2 plugin runtime

This commit is contained in:
Dax Raad 2026-07-14 12:43:53 -04:00
commit 4a93972a78
63 changed files with 1722 additions and 1701 deletions

View file

@ -1,16 +1,9 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime"
import HomeFooter from "./home/footer"
import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context"
import SidebarFooter from "./sidebar/footer"
import SidebarLsp from "./sidebar/lsp"
import SidebarMcp from "./sidebar/mcp"
import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
import Scrap from "./system/scrap"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
@ -19,25 +12,10 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
}
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
PluginManager,
WhichKey,
Scrap,
DiffViewer,
]
return [Notifications, PluginManager, WhichKey, DiffViewer]
}
export async function loadBuiltinPlugins(
api: TuiPluginApi,
runtime: PluginRuntime,
) {
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []

View file

@ -1,98 +1,66 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createMemo, Match, Show, Switch } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { FilePath } from "../../ui/file-path"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi; maxWidth: number }) {
const theme = () => props.api.theme.current
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const { theme } = useTheme()
const destination = useHomeSessionDestination()
const paths = useTuiPaths()
const dir = createMemo(() => {
const directory = createMemo(() => {
const selected = destination?.destination()
if (!selected || selected.type === "new") return
const branch =
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
return { path: abbreviateHome(selected.directory, paths.home), branch }
return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home)
})
return (
<Show when={dir()}>
{(value) => {
const suffix = () => (value().branch ? `:${value().branch}` : "")
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
return (
<box flexDirection="row" minWidth={0}>
<FilePath
value={value().path}
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
fg={theme().textMuted}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
)
}}
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={theme.textMuted} />}
</Show>
)
}
function Mcp(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const has = createMemo(() => list().length > 0)
const err = createMemo(() => list().some((item) => item.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
function Mcp(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const list = createMemo(() => props.context.data.location.mcp.server.list() ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
<Show when={has()}>
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}>
<text fg={theme.text}>
<Switch>
<Match when={err()}>
<span style={{ fg: theme().error }}> </span>
<Match when={failed()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span>
<span style={{ fg: count() > 0 ? theme.success : theme.textMuted }}> </span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme().textMuted}>/status</text>
<text fg={theme.textMuted}>/status</text>
</box>
</Show>
)
}
function Version(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
function View(props: { api: TuiPluginApi }) {
function View(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.api.state.mcp()
const list = props.context.data.location.mcp.server.list() ?? []
if (list.length === 0) return 0
const count = list.filter((item) => item.status === "connected").length
const count = list.filter((item) => item.status.status === "connected").length
return Bun.stringWidth(`${count} MCP /status`) + 2
})
const directoryWidth = createMemo(() =>
Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()),
)
return (
<box
width="100%"
@ -104,28 +72,22 @@ function View(props: { api: TuiPluginApi }) {
flexShrink={0}
gap={2}
>
<Directory api={props.api} maxWidth={directoryWidth()} />
<Mcp api={props.api} />
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<Version api={props.api} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{InstallationVersion}</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_footer() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.home-footer",
setup(context) {
context.ui.slot("home.footer", () => <View context={context} />)
},
})

View file

@ -1,12 +1,11 @@
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "../../context/theme"
import { useCommandShortcut } from "../../keymap"
import { Keymap } from "../../context/keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length
type TipPart = { text: string; highlight: boolean }
type TipShortcut = Accessor<string>
type TipShortcut = Accessor<string | undefined>
type Shortcuts = {
agentCycle: TipShortcut
childFirst: TipShortcut
@ -74,61 +73,54 @@ function shortcutText(value: string) {
return `{highlight}${value}{/highlight}`
}
function commandText(command: string, shortcut: string) {
function commandText(command: string, shortcut: string | undefined) {
if (!shortcut) return shortcutText(command)
return `${shortcutText(command)} or ${shortcutText(shortcut)}`
}
function press(shortcut: string, text: string) {
function press(shortcut: string | undefined, text: string) {
if (!shortcut) return undefined
return `Press ${shortcutText(shortcut)} ${text}`
}
function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
return () =>
api.tuiConfig.keybinds
.get(command)
.map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key))))
.filter(Boolean)
.join(", ")
}
export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
export function Tips(props: { connected?: boolean }) {
const theme = useTheme().theme
const keymap = Keymap.useShortcuts()
const tipOffset = Math.random()
const shortcut = (id: string) => () => keymap.get(id)
const shortcuts: Shortcuts = {
agentCycle: useCommandShortcut("agent.cycle"),
childFirst: configShortcut(props.api, "session.child.first"),
childNext: configShortcut(props.api, "session.child.next"),
childPrevious: configShortcut(props.api, "session.child.previous"),
commandList: useCommandShortcut("command.palette.show"),
editorOpen: useCommandShortcut("prompt.editor"),
helpShow: useCommandShortcut("help.show"),
inputClear: useCommandShortcut("prompt.clear"),
inputNewline: useCommandShortcut("input.newline"),
inputPaste: useCommandShortcut("prompt.paste"),
inputUndo: useCommandShortcut("input.undo"),
leader: configShortcut(props.api, "leader"),
messagesCopy: configShortcut(props.api, "messages.copy"),
messagesFirst: configShortcut(props.api, "session.first"),
messagesLast: configShortcut(props.api, "session.last"),
messagesPageDown: configShortcut(props.api, "session.page.down"),
messagesPageUp: configShortcut(props.api, "session.page.up"),
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
modelList: useCommandShortcut("model.list"),
sessionExport: configShortcut(props.api, "session.export"),
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
sessionList: useCommandShortcut("session.list"),
sessionNew: useCommandShortcut("session.new"),
sessionParent: configShortcut(props.api, "session.parent"),
sessionPinToggle: configShortcut(props.api, "session.pin.toggle"),
sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"),
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
sessionTimeline: configShortcut(props.api, "session.timeline"),
statusView: useCommandShortcut("opencode.status"),
terminalSuspend: useCommandShortcut("terminal.suspend"),
themeList: useCommandShortcut("theme.switch"),
agentCycle: shortcut("agent.cycle"),
childFirst: shortcut("session.child.first"),
childNext: shortcut("session.child.next"),
childPrevious: shortcut("session.child.previous"),
commandList: shortcut("command.palette.show"),
editorOpen: shortcut("prompt.editor"),
helpShow: shortcut("help.show"),
inputClear: shortcut("prompt.clear"),
inputNewline: shortcut("input.newline"),
inputPaste: shortcut("prompt.paste"),
inputUndo: shortcut("input.undo"),
leader: shortcut("leader"),
messagesCopy: shortcut("messages.copy"),
messagesFirst: shortcut("session.first"),
messagesLast: shortcut("session.last"),
messagesPageDown: shortcut("session.page.down"),
messagesPageUp: shortcut("session.page.up"),
modelCycleRecent: shortcut("model.cycle_recent"),
modelList: shortcut("model.list"),
sessionExport: shortcut("session.export"),
sessionInterrupt: shortcut("session.interrupt"),
sessionList: shortcut("session.list"),
sessionNew: shortcut("session.new"),
sessionParent: shortcut("session.parent"),
sessionPinToggle: shortcut("session.pin.toggle"),
sessionQuickSwitch1: shortcut("session.quick_switch.1"),
sessionQuickSwitch9: shortcut("session.quick_switch.9"),
sessionSidebarToggle: shortcut("session.sidebar.toggle"),
sessionTimeline: shortcut("session.timeline"),
statusView: shortcut("opencode.status"),
terminalSuspend: shortcut("terminal.suspend"),
themeList: shortcut("theme.switch"),
}
const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP
@ -175,22 +167,30 @@ const TIPS: Tip[] = [
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"),
(shortcuts) =>
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch pinned sessions`
: undefined,
(shortcuts) => {
const first = shortcuts.sessionQuickSwitch1()
const last = shortcuts.sessionQuickSwitch9()
if (!first || !last) return undefined
return `Use ${shortcutText(first)} through ${shortcutText(last)} to switch pinned sessions`
},
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
(shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"),
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
(shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`,
(shortcuts) => {
const leader = shortcuts.leader()
if (!leader) return undefined
return `The leader key is ${shortcutText(leader)}; combine with other keys for quick actions`
},
(shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"),
(shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"),
(shortcuts) =>
shortcuts.messagesPageUp() && shortcuts.messagesPageDown()
? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history`
: undefined,
(shortcuts) => {
const up = shortcuts.messagesPageUp()
const down = shortcuts.messagesPageDown()
if (!up || !down) return undefined
return `Use ${shortcutText(up)}/${shortcutText(down)} to navigate through conversation history`
},
(shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"),
(shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"),
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
@ -204,7 +204,7 @@ const TIPS: Tip[] = [
shortcuts.childFirst(),
shortcuts.childPrevious(),
shortcuts.childNext(),
].filter(Boolean)
].filter((item): item is string => Boolean(item))
if (!items.length) return undefined
return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions`
},
@ -267,10 +267,12 @@ const TIPS: Tip[] = [
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
(shortcuts) =>
shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
: "Toggle username display in chat via the command palette",
(shortcuts) => {
const commandList = shortcuts.commandList()
return commandList
? `Toggle username display in chat via the command palette (${shortcutText(commandList)})`
: "Toggle username display in chat via the command palette"
},
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",

View file

@ -1,66 +1,51 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { useData } from "../../context/data"
import { hasConnectedProvider } from "../../util/connected-provider"
import { useConfig } from "../../config"
import { useDialog } from "../../ui/dialog"
const id = "internal:home-tips"
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
function View() {
const config = useConfig()
useBindings(() => ({
const data = useData()
const dialog = useDialog()
const hidden = createMemo(() => !(config.data.hints?.tips ?? true))
const first = createMemo(() => data.session.list().length === 0)
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
const show = createMemo(() => (!first() || !connected()) && !hidden())
Keymap.createLayer(() => ({
commands: [
{
name: "tips.toggle",
title: props.hidden ? "Show tips" : "Hide tips",
category: "System",
namespace: "palette",
hidden: true,
id: "tips.toggle",
title: hidden() ? "Show tips" : "Hide tips",
group: "System",
run() {
void config
.update((draft) => {
draft.hints = { ...draft.hints, tips: props.hidden }
draft.hints = { ...draft.hints, tips: hidden() }
})
.catch(() => {})
props.api.ui.dialog.clear()
dialog.clear()
},
},
],
bindings: props.api.tuiConfig.keybinds.get("tips.toggle"),
}))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
<Tips api={props.api} connected={props.connected} />
<Show when={show()}>
<Tips connected={connected()} />
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_bottom() {
const data = useData()
const config = useConfig().data
const hidden = createMemo(() => !(config.hints?.tips ?? true))
const first = createMemo(() => api.state.session.count() === 0)
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:home-tips",
setup(context) {
context.ui.slot("home.bottom", () => <View />)
},
})

View file

@ -1,59 +1,46 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, Show } from "solid-js"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { contextUsage } from "../../util/session"
const id = "internal:sidebar-context"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => data.session.cost(props.session_id))
function View(props: { context: Plugin.Context; sessionID: string }) {
const { theme } = useTheme()
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
const state = createMemo(() =>
contextUsage(msg(), props.context.data.location.model.list(session()?.location), session()?.revert?.messageID),
)
return (
<box>
<text fg={theme().text}>
<text fg={theme.text}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
<Show when={state()} fallback={<text fg={theme.textMuted}>Not measured</text>}>
{(value) => (
<>
<text fg={theme().textMuted}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.textMuted}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme().textMuted}>{value().percent}% used</text>
<text fg={theme.textMuted}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme().textMuted}>{money.format(cost())} spent</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 api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})

View file

@ -1,113 +1,14 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { FilePath } from "../../ui/file-path"
import { useConfig } from "../../config"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTheme } from "../../context/theme"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths()
const config = useConfig()
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const done = createMemo(() => !(config.data.hints?.onboarding ?? true))
const show = createMemo(() => !has() && !done())
const location = createMemo(() => {
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
return { path: abbreviateHome(props.directory, paths.home), branch }
})
const suffix = createMemo(() => (location().branch ? `:${location().branch}` : ""))
const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36))
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={() =>
void config
.update((draft) => {
draft.hints = { ...draft.hints, onboarding: false }
})
.catch(() => {})
}
>
</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>
<box flexDirection="row" minWidth={0}>
<FilePath
value={location().path}
maxWidth={Math.max(2, 38 - suffixWidth())}
fg={theme().textMuted}
basenameFg={theme().text}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
<text fg={theme().textMuted}>
<span style={{ fg: theme().success }}></span> <b>Open</b>
<span style={{ fg: theme().text }}>
<b>Code</b>
</span>{" "}
<span>{props.api.app.version}</span>
</text>
</box>
)
function View() {
const { theme } = useTheme()
return <text fg={theme.textMuted}>Sidebar footer unavailable</text>
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer(_ctx, props) {
return <View api={api} directory={props.directory} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.sidebar-footer",
setup(context) {
context.ui.slot("sidebar.footer", () => <View />)
},
})

View file

@ -1,65 +1,21 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => !props.api.state.config.lsp)
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTheme } from "../../context/theme"
function View() {
const { theme } = useTheme()
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}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
</Show>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: item.status === "connected" ? theme().success : theme().error,
}}
>
</text>
<text fg={theme().textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
<text fg={theme.textMuted}>LSP status unavailable</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 300,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.sidebar-lsp",
setup(context) {
context.ui.slot("sidebar.content", () => <View />)
},
})

View file

@ -1,29 +1,30 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
const id = "internal:sidebar-mcp"
function View(props: { api: TuiPluginApi }) {
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const on = createMemo(() => list().filter((item) => item.status === "connected").length)
const { theme } = useTheme()
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() =>
list().filter(
(item) =>
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
)
const dot = (status: string) => {
if (status === "connected") return theme().success
if (status === "failed") return theme().error
if (status === "disabled") return theme().textMuted
if (status === "needs_auth") return theme().warning
if (status === "needs_client_registration") return theme().error
return theme().textMuted
if (status === "connected") return theme.success
if (status === "failed") return theme.error
if (status === "disabled") return theme.textMuted
if (status === "needs_auth") return theme.warning
if (status === "needs_client_registration") return theme.error
return theme.textMuted
}
return (
@ -31,12 +32,12 @@ function View(props: { api: TuiPluginApi }) {
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: theme().textMuted }}>
<span style={{ fg: theme.textMuted }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
@ -50,22 +51,22 @@ function View(props: { api: TuiPluginApi }) {
<text
flexShrink={0}
style={{
fg: dot(item.status),
fg: dot(item.status.status),
}}
>
</text>
<text fg={theme().text} wrapMode="word">
<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>
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>
<i>{item.status.status === "failed" ? item.status.error : undefined}</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>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
@ -78,20 +79,9 @@ function View(props: { api: TuiPluginApi }) {
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:sidebar-mcp",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})

View file

@ -732,10 +732,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
...props.api.tuiConfig.keybinds.gather(
"diff",
commands.map((command) => command.name),
),
...commands.flatMap((command) => props.api.tuiConfig.keybinds.get(command.name)),
],
}))
@ -1047,7 +1044,7 @@ const tui: TuiPlugin = async (api) => {
{
name: "diff.open",
title: "Open diff viewer",
slashName: "diff",
slash: { name: "diff" },
category: "VCS",
namespace: "palette",
run() {

View file

@ -258,7 +258,7 @@ const tui: TuiPlugin = async (api) => {
},
},
],
bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
}

View file

@ -1,24 +1,41 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap"
import type { BuiltinTuiPlugin } from "../builtins"
import { useDialog } from "../../ui/dialog"
const id = "internal:scrap"
const route = "scrap"
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
id: "app.scrap",
title: "Open scrap screen",
group: "Debug",
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
dialog.clear()
},
},
],
}))
return null
}
function Scrap(props: { api: TuiPluginApi }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
Keymap.createLayer(() => ({
commands: [
{
key: "escape",
desc: "Back home",
bind: "escape",
title: "Back home",
group: "Scrap",
cmd() {
props.api.route.navigate("home")
run() {
props.context.ui.router.navigate({ type: "home" })
},
},
],
@ -43,24 +60,10 @@ function Scrap(props: { api: TuiPluginApi }) {
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
api.keymap.registerLayer({
commands: [
{
name: "app.scrap",
title: "Open scrap screen",
category: "Debug",
namespace: "palette",
run() {
api.route.navigate(route)
api.ui.dialog.clear()
},
},
],
})
}
const plugin: BuiltinTuiPlugin = { id, tui }
export default plugin
export default Plugin.define({
id: "opencode.scrap",
setup(context) {
context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})

View file

@ -358,9 +358,9 @@ function WhichKeyPanel(props: {
},
},
],
bindings: pendingMode()
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) =>
props.api.tuiConfig.keybinds.get(command),
),
}))
createEffect(() => {
@ -568,7 +568,7 @@ const tui: TuiPlugin = async (api) => {
},
},
],
bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
api.slots.register({