feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator

Three improvements taken from reading the sibling opencode branches
(feat/opencode-extension-abhishek in particular):

- `<leader>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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
This commit is contained in:
Michal
2026-08-09 18:02:52 +01:00
parent be2a5cb189
commit 99f881dd67
5 changed files with 55 additions and 8 deletions

View File

@@ -215,8 +215,8 @@ This mints (or reuses) the project's gateway token into
Inside opencode: Inside opencode:
- `/mcpctl`**switch project** from a filterable picker; takes effect on the - `/mcpctl` (or `<leader>m`)**switch project** from a filterable picker;
next turn, no restart takes effect on the next turn, no restart
- `/mcpctl-status` — active project, mount state, gateway URL - `/mcpctl-status` — active project, mount state, gateway URL
- `/mcpctl-skills` — re-sync this project's skills - `/mcpctl-skills` — re-sync this project's skills
- the active project shows as `mcpctl:<project>` in the prompt footer, next to - the active project shows as `mcpctl:<project>` in the prompt footer, next to

View File

@@ -85,10 +85,13 @@ TUI plugins). It adds:
| Command | What it does | | Command | What it does |
|---------|--------------| |---------|--------------|
| `/mcpctl` | Filterable project picker; switches live | | `/mcpctl` (or `<leader>m`) | Filterable project picker; switches live |
| `/mcpctl-status` | Active project, mount state, gateway URL | | `/mcpctl-status` | Active project, mount state, gateway URL |
| `/mcpctl-skills` | Re-sync this project's skills | | `/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:<project>` indicator in the prompt footer, next to the model name and a `mcpctl:<project>` indicator in the prompt footer, next to the model name
and one line above the token counter. 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 switchers): opencode's select dialog filters as you type, so the plugin only
has to order the list — active project first, then alphabetical. 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 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 it re-renders the slot with no signal plumbing, and it persists across sessions
so the label is right on the first frame. 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 | | `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 | | `app_bottom` | costs a whole extra terminal row |
There is no slot on the status-bar line itself. On the home screen the prompt There is no slot on the status-bar line itself. The home prompt box is narrow
box is narrow, so a long project name wraps onto a second line; in a session enough that the default wrap breaks `mcpctl:homeautomation` across two lines
(where the prompt is full width) it always fits on one. 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 ### Skills

File diff suppressed because one or more lines are too long

View File

@@ -67,6 +67,22 @@ describe('embedded opencode plugins', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); 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: '<leader>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', () => { 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-plugin'");
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'");

View File

@@ -134,6 +134,16 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
const token = state.tokens?.[project] ?? ''; const token = state.tokens?.[project] ?? '';
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token !== '') headers['Authorization'] = `Bearer ${token}`; 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({ await api.client.mcp.add({
name: SERVER_NAME, name: SERVER_NAME,
config: { config: {
@@ -266,6 +276,11 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
}, },
}, },
], ],
// 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: '<leader>m', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },
],
}); });
// The indicator. `session_prompt_right` and `home_prompt_right` are the only // The indicator. `session_prompt_right` and `home_prompt_right` are the only
@@ -274,8 +289,13 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
// above the token counter. (`home_footer` would sit on the counter's line but // 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 // *replaces* the cwd/version footer rather than adding to it, and
// `app_bottom` costs a whole extra terminal row.) // `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 => ( const Indicator = (): JSX.Element => (
<text fg={api.theme.current.textMuted}>{api.kv.get(KV_LABEL, indicatorLabel(null))}</text> <text fg={api.theme.current.textMuted} wrapMode="none" truncate>
{api.kv.get(KV_LABEL, indicatorLabel(null))}
</text>
); );
api.slots.register({ api.slots.register({
order: 100, order: 100,