tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions

View File

@@ -0,0 +1,152 @@
import { For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeCount = Object.keys(DEFAULT_THEMES).length
const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
type TipPart = { text: string; highlight: boolean }
function parse(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
const found = Array.from(tip.matchAll(regex))
const state = found.reduce(
(acc, match) => {
const start = match.index ?? 0
if (start > acc.index) {
acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
}
acc.parts.push({ text: match[1], highlight: true })
acc.index = start + match[0].length
return acc
},
{ parts, index: 0 },
)
if (state.index < tip.length) {
parts.push({ text: tip.slice(state.index), highlight: false })
}
return parts
}
export function Tips() {
const theme = useTheme().theme
const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
return (
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
Tip{" "}
</text>
<text flexShrink={1}>
<For each={parts}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
)
}
const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
"Press {highlight}Tab{/highlight} to cycle between Build and Plan agents",
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
"Drag and drop images into the terminal to add them as context",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor",
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models",
themeTip,
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session",
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations",
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
"The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions",
"Press {highlight}F2{/highlight} to quickly switch between recently used models",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel",
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history",
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation",
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt",
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field",
"Press {highlight}Escape{/highlight} to stop the AI mid-response",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
"OpenCode auto-formats files using prettier, gofmt, ruff, and more",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
"Define custom formatter commands with file extensions in config",
"OpenCode uses LSP servers for intelligent code analysis",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Reference ANSI colors 0-255 in custom themes",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
"Run {highlight}/unshare{/highlight} to remove a session from public access",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
"Permission {highlight}external_directory{/highlight} protects files outside project",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"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",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
"Use {highlight}/rename{/highlight} to rename the current session",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
]

View File

@@ -0,0 +1,48 @@
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
const id = "internal:home-tips"
function View(props: { show: boolean }) {
return (
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
<Tips />
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
value: "tips.toggle",
keybind: "tips_toggle",
category: "System",
hidden: api.route.current.name !== "home",
onSelect() {
api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
api.ui.dialog.clear()
},
},
])
api.slots.register({
order: 100,
slots: {
home_bottom() {
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
const first = createMemo(() => api.state.session.count() === 0)
const show = createMemo(() => !first() && !hidden())
return <View show={show()} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,61 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
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 theme = () => props.api.theme.current
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(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) {
return {
tokens: 0,
percent: null,
}
}
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
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,
}
})
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 api={api} session_id={props.session_id} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,60 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-files"
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.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 api={api} session_id={props.session_id} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,91 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi }) {
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(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
const list = text.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={() => props.api.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>{props.api.app.version}</span>
</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer() {
return <View api={api} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,64 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
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 === false)
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 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: item.status === "connected" ? theme().success : theme().error,
}}
>
</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 api={api} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,94 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp"
function View(props: { api: TuiPluginApi }) {
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 bad = createMemo(
() =>
list().filter(
(item) =>
item.status === "failed" || item.status === "needs_auth" || item.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
}
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 api={api} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,46 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
import { TodoItem } from "../../component/todo-item"
const id = "internal:sidebar-todo"
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.session.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 api={api} session_id={props.session_id} />
},
},
})
}
export default {
id,
tui,
}

View File

@@ -0,0 +1,262 @@
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createEffect, createMemo, createSignal } from "solid-js"
const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0)
const add = Keybind.parse("shift+i").at(0)
const tab = Keybind.parse("tab").at(0)
function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) {
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
}
return (
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
{item.active ? "active" : "inactive"}
</span>
)
}
function source(spec: string) {
if (!spec.startsWith("file://")) return
return fileURLToPath(spec)
}
function meta(item: TuiPluginStatus, width: number) {
if (item.source === "internal") {
if (width >= 120) return "Built-in plugin"
return "Built-in"
}
const next = source(item.spec)
if (next) return next
return item.spec
}
function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
useKeyboard((evt) => {
if (evt.name !== "tab") return
evt.preventDefault()
evt.stopPropagation()
if (busy()) return
setGlobal((x) => !x)
})
return (
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
</box>
)}
onConfirm={(raw) => {
if (busy()) return
const mod = raw.trim()
if (!mod) {
props.api.ui.toast({
variant: "error",
message: "Plugin package name is required",
})
return
}
setBusy(true)
props.api.plugins
.install(mod, { global: global() })
.then((out) => {
if (!out.ok) {
props.api.ui.toast({
variant: "error",
message: out.message,
})
if (out.missing) {
props.api.ui.toast({
variant: "info",
message: "Check npm registry/auth settings and try again.",
})
}
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
})
if (!out.tui) {
props.api.ui.toast({
variant: "info",
message: "Package has no TUI target to load in this app.",
})
show(props.api)
return
}
return props.api.plugins.add(mod).then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "warning",
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
})
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Loaded ${mod} in current session.`,
})
show(props.api)
})
})
.finally(() => {
setBusy(false)
})
}}
onCancel={() => {
show(props.api)
}}
/>
)
}
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
return {
title: item.id,
value: item.id,
category: item.source === "internal" ? "Internal" : "External",
description: meta(item, width),
footer: state(api, item),
disabled: item.id === id,
}
}
function showInstall(api: TuiPluginApi) {
api.ui.dialog.replace(() => <Install api={api} />)
}
function View(props: { api: TuiPluginApi }) {
const size = useTerminalDimensions()
const [list, setList] = createSignal(props.api.plugins.list())
const [cur, setCur] = createSignal<string | undefined>()
const [lock, setLock] = createSignal(false)
createEffect(() => {
const width = size().width
if (width >= 128) {
props.api.ui.dialog.setSize("xlarge")
return
}
if (width >= 96) {
props.api.ui.dialog.setSize("large")
return
}
props.api.ui.dialog.setSize("medium")
})
const rows = createMemo(() =>
[...list()]
.sort((a, b) => {
const x = a.source === "internal" ? 1 : 0
const y = b.source === "internal" ? 1 : 0
if (x !== y) return x - y
return a.id.localeCompare(b.id)
})
.map((item) => row(props.api, item, size().width)),
)
const flip = (x: string) => {
if (lock()) return
const item = list().find((entry) => entry.id === x)
if (!item) return
setLock(true)
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
task
.then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "error",
message: `Failed to update plugin ${item.id}`,
})
}
setList(props.api.plugins.list())
})
.finally(() => {
setLock(false)
})
}
return (
<DialogSelect
title="Plugins"
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
keybind={[
{
title: "toggle",
keybind: key,
disabled: lock(),
onTrigger: (item) => {
setCur(item.value)
flip(item.value)
},
},
{
title: "install",
keybind: add,
disabled: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
}}
/>
)
}
function show(api: TuiPluginApi) {
api.ui.dialog.replace(() => <View api={api} />)
}
const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: "Plugins",
value: "plugins.list",
keybind: "plugin_manager",
category: "System",
onSelect() {
show(api)
},
},
{
title: "Install plugin",
value: "plugins.install",
category: "System",
onSelect() {
showInstall(api)
},
},
])
}
export default {
id,
tui,
}