From 06290907a9cd638a9e26f5bf715c92a6b2a8e0f2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 28 Jul 2026 21:18:40 -0400 Subject: [PATCH] feat(tui): restore plugin manager dialog --- packages/plugin/src/tui/context.ts | 8 +- packages/tui/package.json | 4 - packages/tui/src/app.tsx | 88 ++- packages/tui/src/config/v1/keybind.ts | 28 - packages/tui/src/feature-plugins/builtins.ts | 49 -- .../src/feature-plugins/system/plugins.tsx | 316 ++------- .../src/feature-plugins/system/which-key.tsx | 607 ------------------ packages/tui/src/plugin/api.ts | 40 -- packages/tui/src/plugin/builtins.ts | 2 + packages/tui/src/plugin/command-shim.ts | 108 ---- packages/tui/src/plugin/context.tsx | 48 +- packages/tui/src/plugin/runtime.tsx | 79 --- packages/tui/src/plugin/slots.tsx | 65 -- packages/tui/src/routes/home.tsx | 15 +- packages/tui/src/routes/session/index.tsx | 27 +- packages/tui/src/routes/session/sidebar.tsx | 25 +- packages/tui/test/plugin/runtime.test.ts | 50 -- packages/tui/test/plugin/slots.test.tsx | 38 -- 18 files changed, 147 insertions(+), 1450 deletions(-) delete mode 100644 packages/tui/src/feature-plugins/builtins.ts delete mode 100644 packages/tui/src/feature-plugins/system/which-key.tsx delete mode 100644 packages/tui/src/plugin/api.ts delete mode 100644 packages/tui/src/plugin/command-shim.ts delete mode 100644 packages/tui/src/plugin/runtime.tsx delete mode 100644 packages/tui/src/plugin/slots.tsx delete mode 100644 packages/tui/test/plugin/runtime.test.ts delete mode 100644 packages/tui/test/plugin/slots.test.tsx diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 0cde1200ec..9d092f7245 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -230,11 +230,11 @@ export interface DialogSelectOptions { } export interface Dialog { - /** Shows a dialog and returns a function that closes it. */ - show(render: () => JSX.Element, onClose?: () => void): () => void - /** Sets the presentation options for this plugin's active dialog. */ + /** Shows a dialog. */ + show(render: () => JSX.Element, onClose?: () => void): void + /** Sets the active dialog's presentation options. */ set(options: DialogOptions): void - /** Closes this plugin's active dialog. */ + /** Closes the active dialog. */ clear(): void alert(options: DialogAlertOptions): Promise confirm(options: DialogConfirmOptions): Promise diff --git a/packages/tui/package.json b/packages/tui/package.json index 95945eb5da..ac929f9d6e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -11,7 +11,6 @@ }, "exports": { ".": "./src/index.tsx", - "./builtins": "./src/feature-plugins/builtins.ts", "./config": "./src/config/index.tsx", "./config/keybind": "./src/config/keybind.ts", "./config/v1": "./src/config/v1/index.tsx", @@ -37,9 +36,6 @@ "./context/keymap": "./src/context/keymap.tsx", "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", - "./plugin/runtime": "./src/plugin/runtime.tsx", - "./plugin/slots": "./src/plugin/slots.tsx", - "./plugin/command-shim": "./src/plugin/command-shim.ts", "./parsers-config": "./src/parsers-config.ts", "./util/error": "./src/util/error.ts", "./util/filetype": "./src/util/filetype.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0e964ea463..d6c2c9f122 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -81,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" -import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime" import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap" @@ -275,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { () => Effect.sync(() => process.off("SIGHUP", onSighup)), ) renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) - const pluginRuntime = createPluginRuntime() - yield* Effect.tryPromise(async () => { // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) @@ -354,49 +351,47 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { : undefined } > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -453,7 +448,6 @@ function App(props: { pair?: DialogPairCredentials }) { const location = useLocation() const exit = useExit() const promptRef = usePromptRef() - const pluginRuntime = usePluginRuntime() const plugins = usePlugin() const clipboard = useClipboard() diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 50cf2c3d15..78561ab7f6 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -230,24 +230,9 @@ export const Definitions = { "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), "plugins.toggle": keybind("space", "Toggle plugin"), "dialog.mcp.toggle": keybind("space", "Toggle MCP server"), - "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), terminal_suspend: keybind("ctrl+z", "Suspend terminal"), terminal_title_toggle: keybind("none", "Toggle terminal title"), - plugin_manager: keybind("none", "Open plugin manager dialog"), - plugin_install: keybind("none", "Install plugin"), - - which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"), - which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"), - which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"), - which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"), - which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"), - which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"), - which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"), - which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"), - which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"), - which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"), - which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), } satisfies Record type KeybindName = keyof typeof Definitions @@ -425,19 +410,6 @@ export const CommandMap = { history_next: "prompt.history.next", terminal_suspend: "terminal.suspend", terminal_title_toggle: "terminal.title.toggle", - plugin_manager: "plugins.list", - plugin_install: "plugins.install", - which_key_toggle: "which-key.toggle", - which_key_layout_toggle: "which-key.layout.toggle", - which_key_pending_toggle: "which-key.pending.toggle", - which_key_group_previous: "which-key.group.previous", - which_key_group_next: "which-key.group.next", - which_key_scroll_up: "which-key.scroll.up", - which_key_scroll_down: "which-key.scroll.down", - which_key_page_up: "which-key.page.up", - which_key_page_down: "which-key.page.down", - which_key_home: "which-key.home", - which_key_end: "which-key.end", } satisfies BindingCommandMap const CommandDescriptions = Object.fromEntries( Object.entries(Definitions).map(([name, item]) => [ diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts deleted file mode 100644 index 0a5af4cd54..0000000000 --- a/packages/tui/src/feature-plugins/builtins.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui" -import type { PluginRuntime } from "../plugin/runtime" -import PluginManager from "./system/plugins" -import WhichKey from "./system/which-key" - -export type BuiltinTuiPlugin = Omit & { - id: string - tui: TuiPlugin - enabled?: boolean -} - -export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [PluginManager, WhichKey] -} - -export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { - const slots = runtime.setupSlots(api) - const dispose: Array<() => void> = [] - - for (const plugin of createBuiltinPlugins()) { - if (plugin.enabled === false) continue - const scoped = Object.assign(Object.create(api), { - slots: { - register(input: Parameters[0]) { - dispose.push(slots.register({ ...input, id: plugin.id })) - return plugin.id - }, - }, - }) as TuiPluginApi - const now = Date.now() - await plugin.tui(scoped, undefined, { - id: plugin.id, - source: "internal", - spec: plugin.id, - target: plugin.id, - first_time: now, - last_time: now, - time_changed: now, - load_count: 1, - fingerprint: plugin.id, - state: "first", - }) - } - - return () => { - for (const fn of dispose.reverse()) fn() - slots.dispose() - } -} diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index c04ac640a2..c0f3d20a47 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -1,280 +1,90 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui" -import type { BuiltinTuiPlugin } from "../builtins" -import { useTerminalDimensions } from "@opentui/solid" -import { fileURLToPath } from "url" +import { Plugin } from "@opencode-ai/plugin/tui" +import { createMemo, createSignal } from "solid-js" +import { usePlugin } from "../../plugin/context" import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" -import { Show, createEffect, createMemo, createSignal } from "solid-js" -import { Keymap } from "../../context/keymap" -const id = "internal:plugin-manager" +const id = "opencode.plugins" -function state(api: TuiPluginApi, item: TuiPluginStatus) { - if (!item.enabled) { - return disabled - } - - return ( - - {item.active ? "active" : "inactive"} - - ) -} - -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) - - Keymap.createLayer(() => ({ - mode: "modal", - enabled: !busy(), - commands: [ - { - bind: "tab", - title: "Toggle install scope", - group: "Plugins", - run: () => { - setGlobal((value) => !value) - }, - }, - ], - })) - - return ( - ( - - scope: - - {global() ? "global" : "local"} - - - (tab toggle) - - - )} - 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) - void 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 { - 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(() => ) -} - -function View(props: { api: TuiPluginApi }) { - const size = useTerminalDimensions() - const [list, setList] = createSignal(props.api.plugins.list()) - const [cur, setCur] = createSignal() - 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)), +function View(props: { context: Plugin.Context; plugins: ReturnType }) { + const [locked, setLocked] = createSignal(false) + const options = createMemo(() => + props.plugins + .registered() + .filter((plugin) => plugin.id !== id) + .sort((a, b) => a.id.localeCompare(b.id)) + .map( + (plugin): DialogSelectOption => ({ + title: plugin.id, + value: plugin.id, + category: plugin.source === "builtin" ? "Built-in" : "External", + footer: ( + + {plugin.active ? "active" : "inactive"} + + ), + }), + ), ) - 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) - void task + const toggle = (plugin: DialogSelectOption) => { + if (locked()) return + const current = props.plugins.registered().find((item) => item.id === plugin.value) + if (!current) return + setLocked(true) + void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id)) .then((ok) => { - if (!ok) { - props.api.ui.toast({ - variant: "error", - message: `Failed to update plugin ${item.id}`, - }) - } - setList(props.api.plugins.list()) + if (ok) return + props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` }) }) - .finally(() => { - setLock(false) + .catch((error) => { + props.context.ui.toast.show({ + variant: "error", + message: error instanceof Error ? error.message : String(error), + }) }) + .finally(() => setLocked(false)) } return ( setCur(item.value)} - actions={[ - { - title: "toggle", - command: "plugins.toggle", - hidden: lock(), - onTrigger: (item) => { - setCur(item.value) - flip(item.value) - }, - }, - { - title: "install", - command: "dialog.plugins.install", - selection: "none", - hidden: lock(), - onTrigger: () => { - showInstall(props.api) - }, - }, - ]} - onSelect={(item) => { - setCur(item.value) - flip(item.value) - }} + options={options()} + locked={locked()} + preserveSelection={true} + actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]} + onSelect={toggle} /> ) } -function show(api: TuiPluginApi) { - api.ui.dialog.replace(() => ) -} - -const tui: TuiPlugin = async (api) => { - api.keymap.registerLayer({ +function Commands(props: { context: Plugin.Context }) { + const plugins = usePlugin() + props.context.keymap.layer(() => ({ + mode: "global", commands: [ { - name: "plugins.list", + id: "plugins.list", title: "Plugins", - category: "System", - namespace: "palette", + group: "System", + palette: true, run() { - show(api) - }, - }, - { - name: "plugins.install", - title: "Install plugin", - category: "System", - namespace: "palette", - run() { - showInstall(api) + props.context.ui.dialog.show(() => ) }, }, ], - bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)), - }) + })) + return null } -const plugin: BuiltinTuiPlugin = { +export default Plugin.define({ id, - tui, -} - -export default plugin + setup(context) { + context.ui.slot("app", () => ) + }, +}) diff --git a/packages/tui/src/feature-plugins/system/which-key.tsx b/packages/tui/src/feature-plugins/system/which-key.tsx deleted file mode 100644 index 2617d27af4..0000000000 --- a/packages/tui/src/feature-plugins/system/which-key.tsx +++ /dev/null @@ -1,607 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core" -import { useTerminalDimensions } from "@opentui/solid" -import { createEffect, createMemo, createSignal, For, Show } from "solid-js" -import { Keymap } from "../../context/keymap" -import type { ActiveKey } from "@opentui/keymap" -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import type { BuiltinTuiPlugin } from "../builtins" - -const command = { - toggle: "which-key.toggle", - toggleLayout: "which-key.layout.toggle", - togglePending: "which-key.pending.toggle", - groupPrevious: "which-key.group.previous", - groupNext: "which-key.group.next", - scrollUp: "which-key.scroll.up", - scrollDown: "which-key.scroll.down", - pageUp: "which-key.page.up", - pageDown: "which-key.page.down", - home: "which-key.home", - end: "which-key.end", -} as const - -const LAYER_PRIORITY = 900 -const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const -const scrollCommands = [ - command.scrollUp, - command.scrollDown, - command.pageUp, - command.pageDown, - command.home, - command.end, -] as const -const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const -const COLUMN_GAP = 4 -const TAB_GAP = 3 -const MIN_TAB_GAP = 1 -const TAB_CONTENT_GAP = 1 -const MIN_COLUMN_WIDTH = 28 -const MAX_COLUMN_WIDTH = 44 -const PANEL_HEIGHT_RATIO = 0.3 -const MIN_PANEL_HEIGHT = 8 -const MAX_PANEL_HEIGHT = 16 -const PANEL_TOP_PADDING = 1 -const FOOTER_HEIGHT = 1 -const FOOTER_MARGIN = 1 -const UNKNOWN = "Unknown" - -type Layout = "dock" | "overlay" - -type Color = RGBA | string - -type Skin = { - panel: Color - text: Color - muted: Color - subtle: Color - key: Color - accent: Color - tab: Color - tabText: Color -} - -type Entry = { - type: "entry" - key: string - label: string - group: string - continues: boolean -} - -type Group = { - label: string - entries: Entry[] -} - -type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" } - -type GroupHeader = { - type: "group" - label: string -} - -type Item = Entry | GroupHeader - -function text(value: unknown) { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -function ink(api: TuiPluginApi, name: string, fallback: string): Color { - const value = Reflect.get(api.theme.current, name) - if (typeof value === "string") return value - if (value instanceof RGBA) return value - return fallback -} - -function skin(api: TuiPluginApi): Skin { - return { - panel: ink(api, "backgroundMenu", "#1c1c1c"), - text: ink(api, "text", "#f0f0f0"), - muted: ink(api, "textMuted", "#a5a5a5"), - subtle: ink(api, "borderSubtle", "#6f6f6f"), - key: ink(api, "warning", "#ffd75f"), - accent: ink(api, "primary", "#5f87ff"), - tab: ink(api, "primary", "#5f87ff"), - tabText: ink(api, "selectedListItemText", "#ffffff"), - } -} - -function activeKeyLabel(active: ActiveKey) { - if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN - return ( - text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN - ) -} - -function activeKeyGroup(active: ActiveKey) { - if (active.continues) return "System" - return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN -} - -function activeKeyEntry(api: TuiPluginApi, active: ActiveKey): Entry { - const key = api.keys.formatSequence([ - { - stroke: active.stroke, - display: active.display, - tokenName: active.tokenName, - }, - ]) - const label = activeKeyLabel(active) - return { - type: "entry", - key, - label: active.continues ? `+${label}` : label, - group: activeKeyGroup(active), - continues: active.continues, - } -} - -function grouped(entries: Entry[]): Group[] { - const map = new Map() - for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry]) - return [...map] - .map(([label, entries]) => ({ - label, - entries: entries.toSorted( - (a, b) => - Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key), - ), - })) - .toSorted((a, b) => a.label.localeCompare(b.label)) -} - -function commandShortcut(_api: TuiPluginApi, name: string) { - const shortcuts = Keymap.useShortcuts() - return () => shortcuts.get(name) ?? "" -} - -function layout(value: unknown): Layout { - if (value === "overlay") return "overlay" - return "dock" -} - -function HomeHint(props: { api: TuiPluginApi }) { - const trigger = commandShortcut(props.api, command.toggle) - const look = createMemo(() => skin(props.api)) - - return ( - - - Show keyboard shortcuts with {trigger() || command.toggle} - - - ) -} - -function WhichKeyPanel(props: { - api: TuiPluginApi - layout: Layout - mode: () => Layout - pendingPreview: () => boolean - pinned: () => boolean -}) { - const dimensions = useTerminalDimensions() - const [offset, setOffset] = createSignal(0) - const [activeGroup, setActiveGroup] = createSignal() - const pending = Keymap.usePendingSequence() - const active = Keymap.useActiveKeys() - const pendingActive = createMemo(() => pending().length > 0 && active().length > 0) - const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive()) - const visible = createMemo(() => props.pinned() || pendingAutoVisible()) - const pendingMode = createMemo(() => visible() && pendingActive()) - const left = 0 - const width = createMemo(() => Math.max(1, dimensions().width)) - const panelHeight = createMemo(() => - Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))), - ) - const contentWidth = createMemo(() => Math.max(1, width() - 2)) - const columns = createMemo(() => - Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)), - ) - const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item))) - const groups = createMemo(() => grouped(entries())) - const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0) - const headerVisible = createMemo(() => tabsVisible() || pendingMode()) - const footerVisible = createMemo(() => !pendingMode()) - const rows = createMemo(() => - Math.max( - 1, - panelHeight() - - PANEL_TOP_PADDING - - (headerVisible() ? 1 : 0) - - (tabsVisible() ? TAB_CONTENT_GAP : 0) - - (footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0), - ), - ) - const pageSize = createMemo(() => rows() * columns()) - const currentGroup = createMemo(() => { - const group = activeGroup() - return groups().find((item) => item.label === group) ?? groups()[0] - }) - const activeEntries = createMemo(() => currentGroup()?.entries ?? []) - const items = createMemo(() => { - if (!pendingMode()) return activeEntries() - return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries]) - }) - const maxOffset = createMemo(() => Math.max(0, items().length - pageSize())) - const shown = createMemo(() => { - const columnsItems: Item[][] = [] - let index = offset() - for (let column = 0; column < columns() && index < items().length; column++) { - const list: Item[] = [] - while (list.length < rows() && index < items().length) { - list.push(items()[index]!) - index += 1 - } - columnsItems.push(list) - } - return columnsItems - }) - const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index)) - const trigger = commandShortcut(props.api, command.toggle) - const modeTrigger = commandShortcut(props.api, command.toggleLayout) - const upActive = createMemo(() => offset() > 0) - const downActive = createMemo(() => offset() < maxOffset()) - const scrollable = createMemo(() => maxOffset() > 0) - const headerItems = createMemo(() => [ - ...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []), - ...(scrollable() ? [{ type: "scroll" as const }] : []), - ]) - const tabGap = createMemo(() => { - const itemCount = headerItems().length - if (itemCount <= 1) return 0 - const itemWidth = headerItems().reduce( - (sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3), - 0, - ) - return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1)))) - }) - const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock")) - const look = createMemo(() => skin(props.api)) - const columnWidth = createMemo(() => - Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))), - ) - const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value)) - const scroll = (delta: number) => setOffset((value) => clamp(value + delta)) - const moveGroup = (delta: number) => { - if (pendingMode()) return - const list = groups() - if (!list.length) return - const index = Math.max( - 0, - list.findIndex((item) => item.label === currentGroup()?.label), - ) - setActiveGroup(list[(index + delta + list.length) % list.length]!.label) - setOffset(0) - } - - Keymap.createLayer(() => ({ - priority: 1000, - enabled: visible(), - commands: [ - { - id: command.groupPrevious, - bind: false, - title: "Previous key binding group", - description: "Show the previous which-key group", - group: "System", - run() { - moveGroup(-1) - }, - }, - { - id: command.groupNext, - bind: false, - title: "Next key binding group", - description: "Show the next which-key group", - group: "System", - run() { - moveGroup(1) - }, - }, - { - id: command.scrollUp, - bind: false, - title: "Scroll key bindings up", - description: "Scroll the which-key panel up", - group: "System", - run() { - scroll(-columns()) - }, - }, - { - id: command.scrollDown, - bind: false, - title: "Scroll key bindings down", - description: "Scroll the which-key panel down", - group: "System", - run() { - scroll(columns()) - }, - }, - { - id: command.pageUp, - bind: false, - title: "Page key bindings up", - description: "Page the which-key panel up", - group: "System", - run() { - scroll(-pageSize()) - }, - }, - { - id: command.pageDown, - bind: false, - title: "Page key bindings down", - description: "Page the which-key panel down", - group: "System", - run() { - scroll(pageSize()) - }, - }, - { - id: command.home, - bind: false, - title: "First key binding", - description: "Jump to the first which-key binding", - group: "System", - run() { - setOffset(0) - }, - }, - { - id: command.end, - bind: false, - title: "Last key binding", - description: "Jump to the last which-key binding", - group: "System", - run() { - setOffset(maxOffset()) - }, - }, - ], - bindings: pendingMode() ? scrollCommands : panelCommands, - })) - - createEffect(() => { - if (pendingMode()) return - const group = currentGroup() - if (group?.label === activeGroup()) return - setActiveGroup(group?.label) - }) - - createEffect(() => { - if (pendingMode()) return - activeGroup() - setOffset(0) - }) - - createEffect(() => { - if (!visible()) setOffset(0) - }) - - createEffect(() => { - pending() - setOffset(0) - }) - - createEffect(() => { - setOffset((value) => clamp(value)) - }) - - return ( - - - - - - {(item) => ( - - - - - - - - } - > - {(group) => { - const selected = createMemo(() => currentGroup()?.label === group().label) - return ( - { - setActiveGroup(group().label) - setOffset(0) - }} - > - - {group().label} - - - ) - }} - - )} - - - - - - - - 0} fallback={No reachable bindings}> - - {(row) => ( - - - {(column) => { - const item = createMemo(() => column[row]) - const entry = createMemo(() => { - const value = item() - if (value?.type !== "entry") return undefined - return value - }) - return ( - - - {(value) => ( - - {value().label} - - } - > - {(binding) => ( - <> - - - {binding().label} - - - - - {binding().key} - - - - )} - - )} - - - ) - }} - - - )} - - - - - - - - - toggle {trigger() || command.toggle} - - - - - {nextMode()} {modeTrigger() || command.toggleLayout} - - - - - - - ) -} - -const tui: TuiPlugin = async (api) => { - const [pinned, setPinned] = createSignal(false) - const [mode, setMode] = createSignal(layout("dock")) - const [pendingPreview, setPendingPreview] = createSignal(false) - - api.keymap.registerLayer({ - priority: LAYER_PRIORITY, - commands: [ - { - name: command.toggle, - title: "Show key bindings", - desc: "Toggle which-key overlay", - category: "System", - run() { - setPinned((value) => !value) - }, - }, - { - name: command.toggleLayout, - title: "Toggle key bindings layout", - desc: "Switch which-key between dock and overlay mode", - category: "System", - run() { - setMode((value) => { - const next = value === "dock" ? "overlay" : "dock" - return next - }) - }, - }, - { - name: command.togglePending, - title: "Toggle pending key preview", - desc: "Automatically show which-key for pending key sequences in overlay mode", - category: "System", - run() { - setPendingPreview((value) => { - return !value - }) - }, - }, - ], - bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)), - }) - - api.slots.register({ - order: 200, - slots: { - home_bottom() { - return - }, - app() { - return ( - - - - ) - }, - app_bottom() { - return ( - - - - ) - }, - }, - }) -} - -const plugin: BuiltinTuiPlugin = { - id: "which-key", - enabled: false, - tui, -} - -export default plugin diff --git a/packages/tui/src/plugin/api.ts b/packages/tui/src/plugin/api.ts deleted file mode 100644 index 1a9335dd6f..0000000000 --- a/packages/tui/src/plugin/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui" -import { createSignal } from "solid-js" - -type RouteEntry = { - key: symbol - render: TuiRouteDefinition["render"] -} - -export type RouteMap = Map - -export function createPluginRoutes() { - const routes: RouteMap = new Map() - const [revision, setRevision] = createSignal(0) - - return { - register(list: TuiRouteDefinition[]) { - const key = Symbol() - list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }])) - setRevision((value) => value + 1) - - return () => { - list.forEach((item) => { - const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? [] - if (next.length) { - routes.set(item.name, next) - return - } - routes.delete(item.name) - }) - setRevision((value) => value + 1) - } - }, - get(name: string) { - revision() - return routes.get(name)?.at(-1)?.render - }, - } -} - -export type PluginRoutes = ReturnType diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 5c2cb7cbc3..79765b0658 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarMcp from "../feature-plugins/sidebar/mcp" import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" +import Plugins from "../feature-plugins/system/plugins" import Scrap from "../feature-plugins/system/scrap" export const builtins = [ @@ -14,6 +15,7 @@ export const builtins = [ SidebarLsp, SidebarFooter, Notifications, + Plugins, Scrap, DiffViewer, ] diff --git a/packages/tui/src/plugin/command-shim.ts b/packages/tui/src/plugin/command-shim.ts deleted file mode 100644 index 6141cbebd5..0000000000 --- a/packages/tui/src/plugin/command-shim.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Legacy `api.command` bridge for v1 plugins; remove in v2. -import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import { TuiKeybind } from "../config/keybind" -import type { DialogContext } from "../ui/dialog" - -const COMMAND_PALETTE_SHOW = "command.palette.show" -const warned = new Set() - -type Warn = (api: string, replacement: string) => void -type LegacyDialog = TuiPluginApi["ui"]["dialog"] -type CommandShimDialog = DialogContext | LegacyDialog -type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] - -function warnCommandShim(api: string, replacement: string) { - // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. - console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) -} - -function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { - if (!("stack" in dialog)) return dialog - return { - replace(render, onClose) { - dialog.replace(render, onClose) - }, - clear() { - dialog.clear() - }, - setSize(size) { - dialog.setSize(size) - }, - get size() { - return dialog.size - }, - get depth() { - return dialog.stack.length - }, - get open() { - return dialog.stack.length > 0 - }, - } -} - -function warnOnce(api: string, replacement: string, warn: Warn) { - if (warned.has(api)) return - warned.add(api) - warn(api, replacement) -} - -function toCommand(item: TuiCommand, dialog: LegacyDialog) { - return { - namespace: "palette", - name: item.value, - title: item.title, - desc: item.description, - category: item.category, - suggested: item.suggested, - hidden: item.hidden, - enabled: item.enabled, - slash: item.slash, - run() { - return item.onSelect?.(dialog) - }, - } -} - -function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { - return commands.flatMap((item) => - item.keybind - ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) - ? keybinds - .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) - .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) - : [ - { - key: item.keybind, - cmd: item.value, - desc: item.title, - }, - ] - : [], - ) -} - -export function createCommandShim( - keymap: TuiPluginApi["keymap"], - dialog: CommandShimDialog, - keybinds: LegacyKeybinds, -): TuiPluginApi["command"] { - const shimDialog = createCommandShimDialog(dialog) - return { - register(cb) { - warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) - const commands = cb() - return keymap.registerLayer({ - commands: commands.map((item) => toCommand(item, shimDialog)), - bindings: toBindings(commands, keybinds), - }) - }, - trigger(value) { - warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) - keymap.dispatchCommand(value) - }, - show() { - warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) - keymap.dispatchCommand(COMMAND_PALETTE_SHOW) - }, - } -} diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a0cd4ac6d1..a5e23d0ec7 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -48,9 +48,16 @@ type State = | { readonly target: string; readonly status: "unsupported" } | { readonly target: string; readonly status: "failed"; readonly error: string } +type RegisteredPlugin = { + readonly id: string + readonly source: "builtin" | "external" + readonly active: boolean +} + type Value = { readonly ready: () => boolean readonly list: () => ReadonlyArray + readonly registered: () => ReadonlyArray readonly route: (id: string, name: string) => Page["render"] | undefined readonly slot: (name: Name) => ReadonlyArray> readonly activate: (id: string) => Promise @@ -59,8 +66,8 @@ type Value = { type Dispose = () => Promise type Registration = { - target: string plugin: Plugin.Definition + source: RegisteredPlugin["source"] options?: Readonly> active: boolean routes: Record @@ -107,28 +114,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> }) const owned: Dispose[] = [] let context: Context - let activeDialog: symbol | undefined const dialogApi: Dialog = { show(render, onClose) { - const token = Symbol() - let closed = false - activeDialog = token - dialog.replace( - () => {render()}, - () => { - if (closed) return - closed = true - if (activeDialog === token) activeDialog = undefined - onClose?.() - }, - ) - return () => { - if (closed || activeDialog !== token) return - dialog.clear() - } + dialog.replace(() => {render()}, onClose) }, set(options) { - if (!activeDialog) return dialog.setSize(options.size ?? "medium") dialog.setCentered(options.centered ?? false) }, @@ -224,7 +214,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> toast.show({ ...options, variant: options.variant ?? "info" }) }, } - owned.push(async () => dialogApi.clear()) context = { options: item.options ?? {}, get location() { @@ -365,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> for (const plugin of builtins) { setStore("registrations", plugin.id, { - target: plugin.id, plugin, + source: "builtin", active: false, routes: {}, slots: {}, @@ -410,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> continue } - const item = { target, plugin, options } - setStore("registrations", item.plugin.id, { - ...item, + setStore("registrations", plugin.id, { + plugin, + source: "external", + options, active: false, routes: {}, slots: {}, cleanups: [], }) - const error = await activate(item.plugin.id).then( + const error = await activate(plugin.id).then( () => undefined, (error) => (error instanceof Error ? error.message : String(error)), ) setStore("states", (items) => [ - ...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)), + ...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)), error - ? { target: item.target, status: "failed", error } - : { target: item.target, id: item.plugin.id, status: "active" }, + ? { target, status: "failed", error } + : { target, id: plugin.id, status: "active" }, ]) } } @@ -460,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> value={{ ready: () => store.ready, list: () => store.states, + registered: () => + Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })), route: (id, name) => store.registrations[id]?.routes[name]?.render, slot: (name) => Object.values(store.registrations).flatMap((registration) => diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx deleted file mode 100644 index 0d248545cd..0000000000 --- a/packages/tui/src/plugin/runtime.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { - TuiPluginApi, - TuiPluginInstallOptions, - TuiPluginInstallResult, - TuiPluginStatus, -} from "@opencode-ai/plugin/v1/tui" -import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js" -import { createPluginRoutes } from "./api" -import { createSlots, type HostSlots } from "./slots" - -export function createPluginRuntime() { - const [commands, setCommands] = createSignal(emptyCommands) - const [status, setStatus] = createSignal>([]) - const slots = createSlots() - - return { - Slot: slots.Slot, - routes: createPluginRoutes(), - commands, - status, - update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray }) { - if (input.commands) setCommands(input.commands) - if (input.status) setStatus(input.status) - }, - clear() { - setCommands(emptyCommands) - setStatus([]) - slots.clear() - }, - setupSlots(api: TuiPluginApi): HostSlots { - return slots.setup(api) - }, - } -} - -export type PluginRuntimeCommands = { - activate: (id: string) => Promise - deactivate: (id: string) => Promise - add: (spec: string) => Promise - install: (spec: string, options?: TuiPluginInstallOptions) => Promise -} - -const emptyCommands: PluginRuntimeCommands = { - async activate() { - return false - }, - async deactivate() { - return false - }, - async add() { - return false - }, - async install() { - return { ok: false, message: "Plugin runtime is not available." } - }, -} - -export type PluginRuntime = ReturnType - -export type TuiPluginHost = { - start(input: { - api: TuiPluginApi - runtime: PluginRuntime - dispose?: () => void - }): Promise - dispose(): Promise -} - -const Context = createContext() - -export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element { - return {props.children} -} - -export function usePluginRuntime() { - const runtime = useContext(Context) - if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider") - return runtime -} diff --git a/packages/tui/src/plugin/slots.tsx b/packages/tui/src/plugin/slots.tsx deleted file mode 100644 index a40037532b..0000000000 --- a/packages/tui/src/plugin/slots.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui" -import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid" -import { createSignal } from "solid-js" -import { isRecord } from "../util/record" - -type RuntimeSlotMap = TuiSlotMap> -type SlotView = (props: TuiSlotProps) => JSX.Element | null - -export type HostSlotPlugin = {}> = SolidPlugin, TuiSlotContext> -export type HostPluginApi = TuiPluginApi -export type HostSlots = { - register: { - (plugin: HostSlotPlugin): () => void - >(plugin: HostSlotPlugin): () => void - } - dispose: () => void -} - -function isHostSlotPlugin(value: unknown): value is HostSlotPlugin> { - if (!isRecord(value)) return false - if (typeof value.id !== "string") return false - return isRecord(value.slots) -} - -export function createSlots() { - const empty: SlotView = (props) => props.children ?? null - const [view, setView] = createSignal(empty) - const Slot: SlotView = (props) => view()(props) - - return { - Slot, - setup(api: HostPluginApi): HostSlots { - const registry = createSolidSlotRegistry( - api.renderer, - { theme: api.theme }, - { - onPluginError(event) { - console.error("[tui.slot] plugin error", { - plugin: event.pluginId, - slot: event.slot, - phase: event.phase, - source: event.source, - message: event.error.message, - }) - }, - }, - ) - const slot = createSlot(registry) - setView(() => (props: TuiSlotProps) => slot(props)) - - return { - register(plugin: HostSlotPlugin) { - if (!isHostSlotPlugin(plugin)) return () => {} - return registry.register(plugin) - }, - dispose() { - setView(() => empty) - }, - } - }, - clear() { - setView(() => empty) - }, - } -} diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index a489c9e43b..befc641270 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -5,7 +5,6 @@ import { useArgs } from "../context/args" import { useRouteData } from "../context/route" import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" -import { usePluginRuntime } from "../plugin/runtime" import { useEditorContext } from "../context/editor" import { useData } from "../context/data" import { useLocation } from "../context/location" @@ -19,7 +18,6 @@ const placeholder = { } export function Home() { - const pluginRuntime = usePluginRuntime() const route = useRouteData("home") const promptRef = usePromptRef() const [ref, setRef] = createSignal() @@ -70,20 +68,11 @@ export function Home() { - - - + - - } - placeholders={placeholder} - disabled={forms().length > 0} - /> - + 0} /> diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 38149f1348..fedda0d98c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -76,7 +76,6 @@ import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" -import { usePluginRuntime } from "../../plugin/runtime" import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" @@ -124,7 +123,6 @@ export function Session() { await mkdir(path.dirname(file), { recursive: true }) await writeFile(file, content) } - const pluginRuntime = usePluginRuntime() const route = useRouteData("session") const { navigate } = useRoute() const data = useData() @@ -986,26 +984,15 @@ export function Session() { - - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - + disabled={false} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 338a8c1ea2..44c0130f6c 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -2,13 +2,11 @@ import { useData } from "../../context/data" import { createMemo, Show } from "solid-js" import { useThemes } from "../../context/theme" import { useConfig } from "../../config" -import { usePluginRuntime } from "../../plugin/runtime" import { PluginSlot } from "../../plugin/context" import { getScrollAcceleration } from "../../util/scroll" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - const pluginRuntime = usePluginRuntime() const data = useData() const theme = useThemes().contextual("elevated") const config = useConfig().data @@ -38,21 +36,14 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { }} > - - - - {session()!.title} - - - {session()!.location.workspaceID} - - - + + + {session()!.title} + + + {session()!.location.workspaceID} + + diff --git a/packages/tui/test/plugin/runtime.test.ts b/packages/tui/test/plugin/runtime.test.ts deleted file mode 100644 index 4df1c1080f..0000000000 --- a/packages/tui/test/plugin/runtime.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "bun:test" -import { createPluginRuntime } from "../../src/plugin/runtime" - -test("routes use the latest registration and restore previous registrations", () => { - const runtime = createPluginRuntime() - const first = () => "first" - const second = () => "second" - runtime.routes.register([{ name: "demo", render: first }]) - const dispose = runtime.routes.register([{ name: "demo", render: second }]) - - expect(runtime.routes.get("demo")).toBe(second) - dispose() - expect(runtime.routes.get("demo")).toBe(first) -}) - -test("facade publishes and clears presentation state", async () => { - const runtime = createPluginRuntime() - runtime.update({ - commands: { - async activate() { - return true - }, - async deactivate() { - return true - }, - async add() { - return true - }, - async install() { - return { ok: true, dir: "/tmp", tui: true } - }, - }, - status: [ - { - id: "demo", - source: "internal", - spec: "demo", - target: "demo", - enabled: true, - active: true, - }, - ], - }) - - expect(await runtime.commands().activate("demo")).toBe(true) - expect(runtime.status()).toHaveLength(1) - runtime.clear() - expect(await runtime.commands().activate("demo")).toBe(false) - expect(runtime.status()).toEqual([]) -}) diff --git a/packages/tui/test/plugin/slots.test.tsx b/packages/tui/test/plugin/slots.test.tsx deleted file mode 100644 index 4dceb3dd19..0000000000 --- a/packages/tui/test/plugin/slots.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { expect, test } from "bun:test" -import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid" -import { onMount } from "solid-js" - -type Slots = { - prompt: {} -} - -test("replace slot mounts plugin content once", async () => { - let mounts = 0 - - const Probe = () => { - onMount(() => { - mounts += 1 - }) - return - } - - const App = () => { - const registry = createSolidSlotRegistry(useRenderer(), {}) - const Slot = createSlot(registry) - registry.register({ id: "plugin", slots: { prompt: () => } }) - - return ( - - - - ) - } - - const app = await testRender(() => ) - try { - expect(mounts).toBe(1) - } finally { - app.renderer.destroy() - } -})