Kaynağa Gözat

feat(tui): restore plugin manager dialog

Dax Raad 2 hafta önce
ebeveyn
işleme
06290907a9

+ 4 - 4
packages/plugin/src/tui/context.ts

@@ -230,11 +230,11 @@ export interface DialogSelectOptions<Value> {
 }
 
 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<void>
   confirm(options: DialogConfirmOptions): Promise<boolean | undefined>

+ 0 - 4
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",

+ 41 - 47
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
                                           }
                                         >
-                                          <PluginRuntimeProvider value={pluginRuntime}>
-                                            <ClientProvider api={api} service={service}>
-                                              <PermissionProvider>
-                                                <DataProvider>
-                                                  <LocationProvider>
-                                                    <SessionTabsProvider>
-                                                      <ThemeProvider mode={mode}>
-                                                        <ThemeErrorToast />
-                                                        <LocalProvider>
-                                                          <PromptStashProvider>
-                                                            <DialogProvider>
-                                                              <FrecencyProvider>
-                                                                <PromptHistoryProvider>
-                                                                  <PromptRefProvider>
-                                                                    <EditorContextProvider>
-                                                                      <AttentionProvider>
-                                                                        <PluginProvider packages={input.packages}>
-                                                                          <App
-                                                                            pair={
-                                                                              input.server.endpoint.auth
-                                                                                ? input.server.endpoint.auth
-                                                                                : {
-                                                                                    username: "opencode",
-                                                                                    password: "",
-                                                                                  }
-                                                                            }
-                                                                          />
-                                                                        </PluginProvider>
-                                                                      </AttentionProvider>
-                                                                    </EditorContextProvider>
-                                                                  </PromptRefProvider>
-                                                                </PromptHistoryProvider>
-                                                              </FrecencyProvider>
-                                                            </DialogProvider>
-                                                          </PromptStashProvider>
-                                                        </LocalProvider>
-                                                      </ThemeProvider>
-                                                    </SessionTabsProvider>
-                                                  </LocationProvider>
-                                                </DataProvider>
-                                              </PermissionProvider>
-                                            </ClientProvider>
-                                          </PluginRuntimeProvider>
+                                          <ClientProvider api={api} service={service}>
+                                            <PermissionProvider>
+                                              <DataProvider>
+                                                <LocationProvider>
+                                                  <SessionTabsProvider>
+                                                    <ThemeProvider mode={mode}>
+                                                      <ThemeErrorToast />
+                                                      <LocalProvider>
+                                                        <PromptStashProvider>
+                                                          <DialogProvider>
+                                                            <FrecencyProvider>
+                                                              <PromptHistoryProvider>
+                                                                <PromptRefProvider>
+                                                                  <EditorContextProvider>
+                                                                    <AttentionProvider>
+                                                                      <PluginProvider packages={input.packages}>
+                                                                        <App
+                                                                          pair={
+                                                                            input.server.endpoint.auth
+                                                                              ? input.server.endpoint.auth
+                                                                              : {
+                                                                                  username: "opencode",
+                                                                                  password: "",
+                                                                                }
+                                                                          }
+                                                                        />
+                                                                      </PluginProvider>
+                                                                    </AttentionProvider>
+                                                                  </EditorContextProvider>
+                                                                </PromptRefProvider>
+                                                              </PromptHistoryProvider>
+                                                            </FrecencyProvider>
+                                                          </DialogProvider>
+                                                        </PromptStashProvider>
+                                                      </LocalProvider>
+                                                    </ThemeProvider>
+                                                  </SessionTabsProvider>
+                                                </LocationProvider>
+                                              </DataProvider>
+                                            </PermissionProvider>
+                                          </ClientProvider>
                                         </RouteProvider>
                                       </ToastProvider>
                                     </Keymap.Provider>
@@ -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()
 

+ 0 - 28
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<string, Definition>
 
 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]) => [

+ 0 - 49
packages/tui/src/feature-plugins/builtins.ts

@@ -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<TuiPluginModule, "id"> & {
-  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<typeof slots.register>[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()
-  }
-}

+ 64 - 254
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"
-
-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)
-
-  Keymap.createLayer(() => ({
-    mode: "modal",
-    enabled: !busy(),
-    commands: [
-      {
-        bind: "tab",
-        title: "Toggle install scope",
-        group: "Plugins",
-        run: () => {
-          setGlobal((value) => !value)
-        },
-      },
-    ],
-  }))
-
-  return (
-    <props.api.ui.DialogPrompt
-      title="Install plugin"
-      placeholder="npm package name"
-      busy={busy()}
-      busyText="Installing plugin..."
-      description={() => (
-        <box flexDirection="row" gap={1}>
-          <text fg={props.api.theme.current.textMuted}>scope:</text>
-          <text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
-            {global() ? "global" : "local"}
-          </text>
-          <Show when={!busy()}>
-            <text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
-          </Show>
-        </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)
-        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<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 id = "opencode.plugins"
+
+function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
+  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<string> => ({
+          title: plugin.id,
+          value: plugin.id,
+          category: plugin.source === "builtin" ? "Built-in" : "External",
+          footer: (
+            <span
+              style={{
+                fg: plugin.active
+                  ? props.context.theme.text.feedback.success.default
+                  : props.context.theme.text.subdued,
+              }}
+            >
+              {plugin.active ? "active" : "inactive"}
+            </span>
+          ),
+        }),
+      ),
   )
 
-  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<string>) => {
+    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 (
     <DialogSelect
       title="Plugins"
-      options={rows()}
-      current={cur()}
-      onMove={(item) => 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(() => <View api={api} />)
-}
-
-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",
-        run() {
-          show(api)
-        },
-      },
-      {
-        name: "plugins.install",
-        title: "Install plugin",
-        category: "System",
-        namespace: "palette",
+        group: "System",
+        palette: true,
         run() {
-          showInstall(api)
+          props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
         },
       },
     ],
-    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", () => <Commands context={context} />)
+  },
+})

+ 0 - 607
packages/tui/src/feature-plugins/system/which-key.tsx

@@ -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<Renderable, KeyEvent>) {
-  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<Renderable, KeyEvent>) {
-  if (active.continues) return "System"
-  return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
-}
-
-function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): 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<string, Entry[]>()
-  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 (
-    <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
-      <text fg={look().muted} wrapMode="none">
-        Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
-      </text>
-    </box>
-  )
-}
-
-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<string | undefined>()
-  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<Item[]>(() => {
-    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<HeaderItem[]>(() => [
-    ...(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 (
-    <Show when={visible()}>
-      <box
-        position={props.layout === "overlay" ? "absolute" : "relative"}
-        zIndex={3500}
-        left={left}
-        bottom={props.layout === "overlay" ? 0 : undefined}
-        width={dimensions().width}
-        height={panelHeight()}
-        backgroundColor={look().panel}
-        paddingLeft={1}
-        paddingRight={1}
-        paddingTop={1}
-        flexShrink={0}
-        flexDirection="column"
-      >
-        <Show when={headerVisible()}>
-          <box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
-            <For each={headerItems()}>
-              {(item) => (
-                <Show
-                  when={item.type === "tab" ? item.group : undefined}
-                  fallback={
-                    <box flexShrink={0}>
-                      <text wrapMode="none">
-                        <span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
-                        <span style={{ fg: look().muted }}> </span>
-                        <span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
-                      </text>
-                    </box>
-                  }
-                >
-                  {(group) => {
-                    const selected = createMemo(() => currentGroup()?.label === group().label)
-                    return (
-                      <box
-                        paddingLeft={1}
-                        paddingRight={1}
-                        flexShrink={0}
-                        backgroundColor={selected() ? look().tab : undefined}
-                        onMouseDown={() => {
-                          setActiveGroup(group().label)
-                          setOffset(0)
-                        }}
-                      >
-                        <text
-                          fg={selected() ? look().tabText : look().muted}
-                          attributes={selected() ? TextAttributes.BOLD : undefined}
-                          wrapMode="none"
-                        >
-                          {group().label}
-                        </text>
-                      </box>
-                    )
-                  }}
-                </Show>
-              )}
-            </For>
-          </box>
-        </Show>
-        <Show when={tabsVisible()}>
-          <box height={TAB_CONTENT_GAP} flexShrink={0} />
-        </Show>
-        <box height={rows()} flexShrink={0} flexDirection="column">
-          <Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
-            <For each={rowIndexes()}>
-              {(row) => (
-                <box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
-                  <For each={shown()}>
-                    {(column) => {
-                      const item = createMemo(() => column[row])
-                      const entry = createMemo(() => {
-                        const value = item()
-                        if (value?.type !== "entry") return undefined
-                        return value
-                      })
-                      return (
-                        <box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
-                          <Show when={item()}>
-                            {(value) => (
-                              <Show
-                                when={entry()}
-                                fallback={
-                                  <text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
-                                    {value().label}
-                                  </text>
-                                }
-                              >
-                                {(binding) => (
-                                  <>
-                                    <box flexGrow={1} minWidth={0}>
-                                      <text
-                                        fg={binding().continues ? look().accent : look().muted}
-                                        wrapMode="none"
-                                        truncate
-                                      >
-                                        {binding().label}
-                                      </text>
-                                    </box>
-                                    <box flexShrink={0}>
-                                      <text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
-                                        {binding().key}
-                                      </text>
-                                    </box>
-                                  </>
-                                )}
-                              </Show>
-                            )}
-                          </Show>
-                        </box>
-                      )
-                    }}
-                  </For>
-                </box>
-              )}
-            </For>
-          </Show>
-        </box>
-        <Show when={footerVisible()}>
-          <box height={FOOTER_MARGIN} flexShrink={0} />
-          <box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
-            <box>
-              <text fg={look().text} wrapMode="none">
-                toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
-              </text>
-            </box>
-            <box>
-              <text fg={look().text} wrapMode="none">
-                {nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
-              </text>
-            </box>
-          </box>
-        </Show>
-      </box>
-    </Show>
-  )
-}
-
-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 <HomeHint api={api} />
-      },
-      app() {
-        return (
-          <Show when={mode() === "overlay"}>
-            <WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
-          </Show>
-        )
-      },
-      app_bottom() {
-        return (
-          <Show when={mode() === "dock"}>
-            <WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
-          </Show>
-        )
-      },
-    },
-  })
-}
-
-const plugin: BuiltinTuiPlugin = {
-  id: "which-key",
-  enabled: false,
-  tui,
-}
-
-export default plugin

+ 0 - 40
packages/tui/src/plugin/api.ts

@@ -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<string, RouteEntry[]>
-
-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<typeof createPluginRoutes>

+ 2 - 0
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,
 ]

+ 0 - 108
packages/tui/src/plugin/command-shim.ts

@@ -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<string>()
-
-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)
-    },
-  }
-}

+ 20 - 28
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<State>
+  readonly registered: () => ReadonlyArray<RegisteredPlugin>
   readonly route: (id: string, name: string) => Page["render"] | undefined
   readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
   readonly activate: (id: string) => Promise<boolean>
@@ -59,8 +66,8 @@ type Value = {
 
 type Dispose = () => Promise<void>
 type Registration = {
-  target: string
   plugin: Plugin.Definition
+  source: RegisteredPlugin["source"]
   options?: Readonly<Record<string, any>>
   active: boolean
   routes: Record<string, Page>
@@ -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(
-          () => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
-          () => {
-            if (closed) return
-            closed = true
-            if (activeDialog === token) activeDialog = undefined
-            onClose?.()
-          },
-        )
-        return () => {
-          if (closed || activeDialog !== token) return
-          dialog.clear()
-        }
+        dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, 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) =>

+ 0 - 79
packages/tui/src/plugin/runtime.tsx

@@ -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<PluginRuntimeCommands>(emptyCommands)
-  const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
-  const slots = createSlots()
-
-  return {
-    Slot: slots.Slot,
-    routes: createPluginRoutes(),
-    commands,
-    status,
-    update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
-      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<boolean>
-  deactivate: (id: string) => Promise<boolean>
-  add: (spec: string) => Promise<boolean>
-  install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
-}
-
-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<typeof createPluginRuntime>
-
-export type TuiPluginHost = {
-  start(input: {
-    api: TuiPluginApi
-    runtime: PluginRuntime
-    dispose?: () => void
-  }): Promise<void>
-  dispose(): Promise<void>
-}
-
-const Context = createContext<PluginRuntime>()
-
-export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
-  return <Context.Provider value={props.value}>{props.children}</Context.Provider>
-}
-
-export function usePluginRuntime() {
-  const runtime = useContext(Context)
-  if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
-  return runtime
-}

+ 0 - 65
packages/tui/src/plugin/slots.tsx

@@ -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<Record<string, object>>
-type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
-
-export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
-export type HostPluginApi = TuiPluginApi
-export type HostSlots = {
-  register: {
-    (plugin: HostSlotPlugin): () => void
-    <Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
-  }
-  dispose: () => void
-}
-
-function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
-  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<SlotView>(empty)
-  const Slot: SlotView = (props) => view()(props)
-
-  return {
-    Slot,
-    setup(api: HostPluginApi): HostSlots {
-      const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
-        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<RuntimeSlotMap, TuiSlotContext>(registry)
-      setView(() => (props: TuiSlotProps<string>) => slot(props))
-
-      return {
-        register(plugin: HostSlotPlugin) {
-          if (!isHostSlotPlugin(plugin)) return () => {}
-          return registry.register(plugin)
-        },
-        dispose() {
-          setView(() => empty)
-        },
-      }
-    },
-    clear() {
-      setView(() => empty)
-    },
-  }
-}

+ 2 - 13
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<PromptRef | undefined>()
@@ -70,20 +68,11 @@ export function Home() {
         <box flexGrow={1} minHeight={0} />
         <box height={4} minHeight={0} flexShrink={1} />
         <box flexShrink={0}>
-          <pluginRuntime.Slot name="home_logo" mode="replace">
-            <Logo />
-          </pluginRuntime.Slot>
+          <Logo />
         </box>
         <box height={1} minHeight={0} flexShrink={1} />
         <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
-          <pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
-            <Prompt
-              ref={bind}
-              right={<pluginRuntime.Slot name="home_prompt_right" />}
-              placeholders={placeholder}
-              disabled={forms().length > 0}
-            />
-          </pluginRuntime.Slot>
+          <Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
         </box>
         <box flexGrow={1} minHeight={0} />
       </box>

+ 7 - 20
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() {
                   </Show>
                 </Match>
                 <Match when={!disabled()}>
-                  <pluginRuntime.Slot
-                    name="session_prompt"
-                    mode="replace"
-                    session_id={route.sessionID}
+                  <Prompt
                     visible={true}
-                    disabled={false}
-                    on_submit={toBottom}
                     ref={bind}
-                  >
-                    <Prompt
-                      visible={true}
-                      ref={bind}
-                      disabled={false}
-                      onSubmit={() => {
-                        toBottom()
-                      }}
-                      sessionID={route.sessionID}
-                      right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
-                    />
-                  </pluginRuntime.Slot>
+                    disabled={false}
+                    onSubmit={() => {
+                      toBottom()
+                    }}
+                    sessionID={route.sessionID}
+                  />
                 </Match>
               </Switch>
             </box>

+ 8 - 17
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 }) {
           }}
         >
           <box flexShrink={0} gap={1} paddingRight={1}>
-            <pluginRuntime.Slot
-              name="sidebar_title"
-              mode="single_winner"
-              session_id={props.sessionID}
-              title={session()!.title}
-            >
-              <box paddingRight={1}>
-                <text fg={theme.text.default}>
-                  <b>{session()!.title}</b>
-                </text>
-                <Show when={session()!.location.workspaceID}>
-                  <text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
-                </Show>
-              </box>
-            </pluginRuntime.Slot>
+            <box paddingRight={1}>
+              <text fg={theme.text.default}>
+                <b>{session()!.title}</b>
+              </text>
+              <Show when={session()!.location.workspaceID}>
+                <text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
+              </Show>
+            </box>
             <PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
           </box>
         </scrollbox>

+ 0 - 50
packages/tui/test/plugin/runtime.test.ts

@@ -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([])
-})

+ 0 - 38
packages/tui/test/plugin/slots.test.tsx

@@ -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 <box />
-  }
-
-  const App = () => {
-    const registry = createSolidSlotRegistry<Slots>(useRenderer(), {})
-    const Slot = createSlot(registry)
-    registry.register({ id: "plugin", slots: { prompt: () => <Probe /> } })
-
-    return (
-      <Slot name="prompt" mode="replace">
-        <box />
-      </Slot>
-    )
-  }
-
-  const app = await testRender(() => <App />)
-  try {
-    expect(mounts).toBe(1)
-  } finally {
-    app.renderer.destroy()
-  }
-})