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:
@@ -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 `<leader>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:<project>` in the prompt footer, next to
|
||||
|
||||
@@ -85,10 +85,13 @@ TUI plugins). It adds:
|
||||
|
||||
| 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-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 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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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: '<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', () => {
|
||||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'");
|
||||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'");
|
||||
|
||||
@@ -134,6 +134,16 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
|
||||
const token = state.tokens?.[project] ?? '';
|
||||
const headers: Record<string, string> = {};
|
||||
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<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
|
||||
@@ -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
|
||||
// *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 => (
|
||||
<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({
|
||||
order: 100,
|
||||
|
||||
Reference in New Issue
Block a user