From 99f881dd6708821e6aa6977abc973bc54a4201ec Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:02:52 +0100 Subject: [PATCH] feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements taken from reading the sibling opencode branches (feat/opencode-extension-abhishek in particular): - `m` opens the project picker. Switching is the repeated action and typing `/mcpctl` every time is friction; the other two commands stay palette-only. - A switch disconnects before re-adding. `mcp.add` under the same name does re-point the tools on its own, but leaves it to opencode whether the previous client is closed — and an abandoned one keeps its `mcp-session-id` alive on mcplocal, which is exactly what holds a gated project open. Best-effort, so a first mount still works. - The footer label renders `wrapMode="none" truncate`. The home prompt row is narrow enough that the default wrap broke `mcpctl:homeautomation` across two lines mid-word; clipping the tail of a long name reads far better. Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is now one line, and a disconnect-then-add switch still lands — the model called `mcpctl_begin_session` and listed the new project's tools. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 4 ++-- docs/opencode-extension.md | 19 ++++++++++++---- src/cli/src/config/opencode-extension.ts | 2 +- .../config/opencode-extension-embed.test.ts | 16 ++++++++++++++ src/opencode-ext/mcpctl-opencode-tui.tsx | 22 ++++++++++++++++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 60fa274..43fcd22 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ This mints (or reuses) the project's gateway token into Inside opencode: -- `/mcpctl` — **switch project** from a filterable picker; takes effect on the - next turn, no restart +- `/mcpctl` (or `m`) — **switch project** from a filterable picker; + takes effect on the next turn, no restart - `/mcpctl-status` — active project, mount state, gateway URL - `/mcpctl-skills` — re-sync this project's skills - the active project shows as `mcpctl:` in the prompt footer, next to diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md index 5a5430d..0690a8b 100644 --- a/docs/opencode-extension.md +++ b/docs/opencode-extension.md @@ -85,10 +85,13 @@ TUI plugins). It adds: | Command | What it does | |---------|--------------| -| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl` (or `m`) | Filterable project picker; switches live | | `/mcpctl-status` | Active project, mount state, gateway URL | | `/mcpctl-skills` | Re-sync this project's skills | +Switching is the thing you do repeatedly, so it gets a chord as well as a slash +command; the other two stay palette-only. + and a `mcpctl:` indicator in the prompt footer, next to the model name and one line above the token counter. @@ -103,6 +106,12 @@ The picker needs no pre-filter prompt (unlike the pi and prime-agent switchers): opencode's select dialog filters as you type, so the plugin only has to order the list — active project first, then alphabetical. +A switch **disconnects before re-adding**. `mcp.add` under the same name does +re-point the tools on its own, but leaves it to opencode whether the previous +client is closed, and an abandoned one keeps its `mcp-session-id` alive on +mcplocal — the very thing that holds a gated project open. Best-effort: on a +first mount there is nothing to disconnect. + The indicator is published through `api.kv`, which is a reactive store: writing it re-renders the slot with no signal plumbing, and it persists across sessions so the label is right on the first frame. @@ -118,9 +127,11 @@ options are: | `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | | `app_bottom` | costs a whole extra terminal row | -There is no slot on the status-bar line itself. On the home screen the prompt -box is narrow, so a long project name wraps onto a second line; in a session -(where the prompt is full width) it always fits on one. +There is no slot on the status-bar line itself. The home prompt box is narrow +enough that the default wrap breaks `mcpctl:homeautomation` across two lines +mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of +a long name reads better than a two-line footer. In a session the prompt is full +width and it always fits. ### Skills diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts index 4f60f81..fbc2f5d 100644 --- a/src/cli/src/config/opencode-extension.ts +++ b/src/cli/src/config/opencode-extension.ts @@ -24,4 +24,4 @@ export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; -export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts index 422ac8c..b772315 100644 --- a/src/cli/tests/config/opencode-extension-embed.test.ts +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -67,6 +67,22 @@ describe('embedded opencode plugins', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); }); + it('bind the switcher to a chord as well as a slash command', () => { + // Switching is the repeated action; typing /mcpctl every time is friction. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: 'm'"); + }); + + it('tear the outgoing mount down before re-pointing it', () => { + // An abandoned client keeps its mcp-session-id — and a gated project's + // unlocked state — alive on mcplocal. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })'); + }); + + it('clip rather than wrap the footer label on the narrow home prompt', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"'); + }); + it('switch without rewriting the plugin file opencode has already loaded', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx index bd067f7..40d38e3 100644 --- a/src/opencode-ext/mcpctl-opencode-tui.tsx +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -134,6 +134,16 @@ const tui = async (api: TuiPluginApi): Promise => { const token = state.tokens?.[project] ?? ''; const headers: Record = {}; if (token !== '') headers['Authorization'] = `Bearer ${token}`; + // Tear the outgoing mount down explicitly. `mcp.add` under the same name + // does re-point the tools, but leaves it to opencode whether the previous + // client is closed — and an abandoned one keeps its `mcp-session-id` alive + // on mcplocal, which is what holds a gated project open. Best-effort: on a + // first mount there is nothing to disconnect. + try { + await api.client.mcp.disconnect({ name: SERVER_NAME }); + } catch { + /* not mounted yet */ + } await api.client.mcp.add({ name: SERVER_NAME, config: { @@ -266,6 +276,11 @@ const tui = async (api: TuiPluginApi): Promise => { }, }, ], + // Switching is the thing you do repeatedly, so it gets a chord as well as + // `/mcpctl`. The other two commands stay palette-only — they are occasional. + bindings: [ + { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' }, + ], }); // The indicator. `session_prompt_right` and `home_prompt_right` are the only @@ -274,8 +289,13 @@ const tui = async (api: TuiPluginApi): Promise => { // above the token counter. (`home_footer` would sit on the counter's line but // *replaces* the cwd/version footer rather than adding to it, and // `app_bottom` costs a whole extra terminal row.) + // wrapMode="none" + truncate: the home prompt row is narrow, and the default + // wrap breaks "mcpctl:homeautomation" across two lines mid-word. Clipping the + // tail of a long name reads far better than a two-line footer. const Indicator = (): JSX.Element => ( - {api.kv.get(KV_LABEL, indicatorLabel(null))} + + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); api.slots.register({ order: 100,