diff --git a/src/plugins/register.ts b/src/plugins/register.ts index 2363d6711..ffb11aabb 100644 --- a/src/plugins/register.ts +++ b/src/plugins/register.ts @@ -39,19 +39,20 @@ export function enablePluginConfig( return { ...config, [id]: { ...prev, enabled: true } }; } -export function isEnabledCommandPlugin( - mod: PluginModule, - config: Record, -): boolean { +function isCommandPluginModule(mod: PluginModule): boolean { if (mod.commandPlugin === undefined) return false; const kind = mod.manifest?.kind; // command/workflow plugins own their slash commands; agent plugins may also // contribute commands (e.g. a Claude marketplace plugin's tagged skills), so // commands wire as an added surface without changing the plugin's primary kind. - return ( - (kind === "command" || kind === "workflow" || kind === "agent") && - isPluginModuleEnabled(mod, config) - ); + return kind === "command" || kind === "workflow" || kind === "agent"; +} + +export function isEnabledCommandPlugin( + mod: PluginModule, + config: Record, +): boolean { + return isCommandPluginModule(mod) && isPluginModuleEnabled(mod, config); } export function isEnabledWorkflowPlugin( @@ -65,15 +66,27 @@ export function isEnabledWorkflowPlugin( ); } +export function registerCommandPluginModule( + mod: PluginModule, + getConfig: () => Record, +): boolean { + if (!isCommandPluginModule(mod)) return false; + const commandPlugin = mod.commandPlugin; + if (commandPlugin === undefined) return false; + registerCommandPlugin(commandPlugin, () => isPluginModuleEnabled(mod, getConfig())); + return true; +} + export function registerCommandPlugins( modules: PluginModule[], - config: Record, + config: Record | (() => Record), ): string[] { + const getConfig = typeof config === "function" ? config : () => config; const registered: string[] = []; for (const mod of modules) { - if (!isEnabledCommandPlugin(mod, config)) continue; - registerCommandPlugin(mod.commandPlugin!); - registered.push(mod.manifest!.id); + const id = mod.manifest?.id; + if (id === undefined || !registerCommandPluginModule(mod, getConfig)) continue; + if (isPluginModuleEnabled(mod, getConfig())) registered.push(id); } return registered; } diff --git a/src/tui/command-catalog.ts b/src/tui/command-catalog.ts index c1cf2c798..f6b2fc4fb 100644 --- a/src/tui/command-catalog.ts +++ b/src/tui/command-catalog.ts @@ -4,7 +4,7 @@ * Pure: host injects `listCommands()` results (or fixtures). No registry import * here — avoids circular / heavy deps from `src/tui/commands`. * - * setPaletteCatalog(shell, commandItemsFromRegistry(listCommands())) + * setPaletteCatalog(shell, () => commandItemsFromRegistry(listCommands())) */ import { sliceToWidth, stringWidth } from "./view/height.js"; diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index 5607718bf..c98712dfc 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -1,6 +1,8 @@ import { describe, test, expect } from "bun:test"; import { setUpCommandRegistry } from "./runner.js"; import { getCommand, listCommands } from "./commands/registry.js"; +import type { PluginConfig } from "../config/settings.js"; +import type { PluginModule } from "../plugins/loader.js"; // Built-in registration once rode on an import side effect; deleting its only // importer emptied the registry with no type error and no failing test. @@ -21,4 +23,60 @@ describe("session command registry setup", () => { expect(listCommands().map((c) => c.name)).not.toContain("help"); expect(getCommand("help")).toBeDefined(); }); + + test("resolves plugin command candidates against live canonical config", () => { + const plugin = ( + id: string, + description: string, + name = "live-config-command", + ): PluginModule => ({ + manifest: { id, name: id, kind: "command" }, + origin: "user", + commandPlugin: { + commands: [ + { + name, + description, + handler: () => ({ type: "message", text: description }), + }, + ], + }, + }); + let config: Record = { + "disabled-command-plugin": { enabled: false }, + "enabled-command-plugin": { enabled: true }, + "help-collision-plugin": { enabled: true }, + }; + + setUpCommandRegistry( + { providers: {}, plugins: config }, + [ + plugin("disabled-command-plugin", "disabled"), + plugin("enabled-command-plugin", "enabled"), + plugin("help-collision-plugin", "plugin help", "help"), + ], + () => config, + ); + + expect(getCommand("live-config-command")?.description).toBe("enabled"); + expect(getCommand("live-config-command")?.handler("", { signalClear: () => {} })).toEqual({ + type: "message", + text: "enabled", + }); + expect(getCommand("help")?.description).not.toBe("plugin help"); + + config = { + ...config, + "disabled-command-plugin": { enabled: true }, + "enabled-command-plugin": { enabled: false }, + }; + expect(getCommand("live-config-command")?.description).toBe("disabled"); + + config = { + ...config, + "disabled-command-plugin": { enabled: false }, + }; + expect(getCommand("live-config-command")).toBeUndefined(); + expect(listCommands().map((command) => command.name)).not.toContain("live-config-command"); + }); }); diff --git a/src/tui/commands/registry.test.ts b/src/tui/commands/registry.test.ts index 30bd2641a..ef4d319ce 100644 --- a/src/tui/commands/registry.test.ts +++ b/src/tui/commands/registry.test.ts @@ -69,6 +69,18 @@ describe("command registry", () => { expect(def?.handler("", ctx)).toEqual({ type: "message", text: "built-in" }); }); + it("keeps command-specific availability visibility-only", () => { + registerCommand({ + name: "unavailable-but-callable", + description: "visibility gated", + available: () => false, + handler: () => ({ type: "noop" }), + }); + + expect(listCommands().map((command) => command.name)).not.toContain("unavailable-but-callable"); + expect(getCommand("unavailable-but-callable")).toBeDefined(); + }); + it("invokes handler with args and context", () => { let receivedArgs = ""; let clearCalled = false; @@ -102,6 +114,141 @@ describe("registerCommandPlugin", () => { expect(getCommand("plugin-cmd-a")).toBeDefined(); expect(getCommand("plugin-cmd-b")).toBeDefined(); }); + + it("re-resolves activation for discovery and execution", () => { + let active = true; + registerCommandPlugin( + { + commands: [ + { + name: "live-plugin-cmd", + description: "live plugin", + handler: () => ({ type: "message", text: "ran" }), + }, + ], + }, + () => active, + ); + + expect(listCommands().map((command) => command.name)).toContain("live-plugin-cmd"); + expect(getCommand("live-plugin-cmd")?.handler("", ctx)).toEqual({ + type: "message", + text: "ran", + }); + + active = false; + expect(listCommands().map((command) => command.name)).not.toContain("live-plugin-cmd"); + expect(getCommand("live-plugin-cmd")).toBeUndefined(); + + active = true; + expect(listCommands().map((command) => command.name)).toContain("live-plugin-cmd"); + expect(getCommand("live-plugin-cmd")).toBeDefined(); + }); + + it("serves the next candidate when the first plugin deactivates", () => { + let firstActive = true; + registerCommandPlugin( + { + commands: [ + { + name: "plugin-live-fallback", + description: "first", + handler: () => ({ type: "noop" }), + }, + ], + }, + () => firstActive, + ); + registerCommandPlugin({ + commands: [ + { + name: "plugin-live-fallback", + description: "second", + handler: () => ({ type: "noop" }), + }, + ], + }); + + expect(getCommand("plugin-live-fallback")?.description).toBe("first"); + expect( + listCommands().find((command) => command.name === "plugin-live-fallback")?.description, + ).toBe("first"); + + firstActive = false; + expect(getCommand("plugin-live-fallback")?.description).toBe("second"); + expect( + listCommands().find((command) => command.name === "plugin-live-fallback")?.description, + ).toBe("second"); + }); + + it("does not execute a typed slash name after the plugin deactivates", () => { + let active = true; + registerCommandPlugin( + { + commands: [ + { + name: "typed-after-disable", + description: "typed", + handler: () => ({ type: "message", text: "ran" }), + }, + ], + }, + () => active, + ); + + const stalePaletteRow = "typed-after-disable"; + expect(getCommand(stalePaletteRow)).toBeDefined(); + active = false; + expect(getCommand(stalePaletteRow)).toBeUndefined(); + }); + + it("lets an enabled plugin claim a name ahead of a disabled candidate", () => { + registerCommandPlugin( + { + commands: [ + { + name: "plugin-candidate-collision", + description: "disabled candidate", + handler: () => ({ type: "noop" }), + }, + ], + }, + () => false, + ); + registerCommandPlugin( + { + commands: [ + { + name: "plugin-candidate-collision", + description: "enabled candidate", + handler: () => ({ type: "noop" }), + }, + ], + }, + () => true, + ); + + expect(getCommand("plugin-candidate-collision")?.description).toBe("enabled candidate"); + }); + + it("never lets a plugin collision replace a built-in command", () => { + registerCommand({ + name: "built-in-plugin-collision", + description: "built-in", + handler: () => ({ type: "noop" }), + }); + registerCommandPlugin({ + commands: [ + { + name: "built-in-plugin-collision", + description: "plugin", + handler: () => ({ type: "noop" }), + }, + ], + }); + + expect(getCommand("built-in-plugin-collision")?.description).toBe("built-in"); + }); }); describe("setHiddenCommands", () => { diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index e94ec7355..cb5faf2b3 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -65,7 +65,13 @@ export interface CommandPlugin { commands: CommandDefinition[]; } +interface PluginCommandCandidate { + command: CommandDefinition; + isActive: () => boolean; +} + const registry = new Map(); +const pluginCandidates = new Map(); const hidden = new Set(); export function registerCommand(def: CommandDefinition): void { @@ -75,9 +81,14 @@ export function registerCommand(def: CommandDefinition): void { registry.set(def.name, def); } -export function registerCommandPlugin(plugin: CommandPlugin): void { +export function registerCommandPlugin( + plugin: CommandPlugin, + isActive: () => boolean = () => true, +): void { for (const cmd of plugin.commands) { - registerCommand(cmd); + const candidates = pluginCandidates.get(cmd.name) ?? []; + candidates.push({ command: cmd, isActive }); + pluginCandidates.set(cmd.name, candidates); } } @@ -87,11 +98,19 @@ export function setHiddenCommands(names: string[]): void { } export function getCommand(name: string): CommandDefinition | undefined { - return registry.get(name); + const registered = registry.get(name); + if (registered !== undefined) return registered; + return pluginCandidates.get(name)?.find((candidate) => candidate.isActive())?.command; } export function listCommands(): CommandDefinition[] { - return [...registry.values()] + const commands = [...registry.values()]; + for (const name of pluginCandidates.keys()) { + if (registry.has(name)) continue; + const command = getCommand(name); + if (command !== undefined) commands.push(command); + } + return commands .filter((c) => !hidden.has(c.name) && (c.available === undefined || c.available())) .sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 915411e7b..cfca69d00 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -149,7 +149,7 @@ export interface ProductHostConfig { */ readonly addProviderChoices?: () => readonly ProductHostAddProviderChoice[]; /** Command palette catalog (registry-backed). */ - readonly commands?: readonly PaletteCommand[]; + readonly commands?: readonly PaletteCommand[] | (() => readonly PaletteCommand[]); readonly onCommand?: (name: string) => void; /** Optional initial chrome snapshot. */ readonly chrome?: ChromeLiveState | null; @@ -326,7 +326,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise 0) { + if (config.commands !== undefined) { setPaletteCatalog(shell, config.commands); } if (config.onCommand) { diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 51562fa67..4b1bcd989 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -10,6 +10,7 @@ import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection, + resolvePaletteCatalog, runOverlayAction, } from "./shell.js"; import { @@ -136,6 +137,34 @@ describe("observeSessionFromSubAgents", () => { }); describe("mountRunnerHost chrome wiring", () => { + test("reads the current command catalog on every palette access", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + let commands = [{ name: "first", description: "First command" }]; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: {}, + onModelSelect: () => {}, + commands: () => commands, + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }); + try { + expect(resolvePaletteCatalog(host.shell).map((command) => command.id)).toEqual(["first"]); + + commands = [{ name: "second", description: "Second command" }]; + expect(resolvePaletteCatalog(host.shell).map((command) => command.id)).toEqual(["second"]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + // CL-5731: subscribeChrome must stay wired end-to-end. formatChromeZones // now parks both chrome strips (always null), so a tasks push must not // paint the checklist — this test asserts the notify path still runs and diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index f97cc6205..fc64fc023 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -115,7 +115,7 @@ export interface RunnerHostDeps { * then hidden. Defaults to false (off) when omitted. */ readonly showPromptCost?: () => boolean; - readonly commands: readonly RegistryCommandSource[]; + readonly commands: readonly RegistryCommandSource[] | (() => readonly RegistryCommandSource[]); readonly onCommand: (name: string) => void; /** Live chrome snapshot source, read on mount and on every notify. */ readonly chrome: () => ChromeSessionInput; @@ -274,7 +274,10 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise }, onModelSelect, describeModel, - commands: commandItemsFromRegistry(deps.commands), + commands: () => + commandItemsFromRegistry( + typeof deps.commands === "function" ? deps.commands() : deps.commands, + ), onCommand: deps.onCommand, chrome: chromeFromSession(deps.chrome()), onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()), diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 409b32b7d..57dec385e 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -90,15 +90,14 @@ import { type PathTrustStore, } from "../trust/path-trust.js"; import { + registerCommandPluginModule, registerCommandPlugins, registerWorkflowPlugins, - isEnabledCommandPlugin, enablePluginConfig, } from "../plugins/register.js"; import { getCommand, listCommands, - registerCommandPlugin, setHiddenCommands, type CommandContext, type CommandResult, @@ -581,11 +580,11 @@ export function telemetryStartupNotice( export function setUpCommandRegistry( settings: Settings | undefined, plugins: PluginModule[], + getPluginConfig: () => Record = () => settings?.plugins ?? {}, ): void { - const pluginConfig = settings?.plugins ?? {}; registerBuiltInCommands(); - registerWorkflowPlugins(plugins, pluginConfig); - registerCommandPlugins(plugins, pluginConfig); + registerWorkflowPlugins(plugins, getPluginConfig()); + registerCommandPlugins(plugins, getPluginConfig); setHiddenCommands(settings?.hiddenCommands ?? []); } @@ -654,9 +653,8 @@ export async function runTUI(initialConfig: Config): Promise { // with a fully loaded module without restarting the process. let livePluginModules = pluginModules; const executablePlugins = () => livePluginModules.filter((m) => m.metadataOnly !== true); - // Command plugins are wired in only when explicitly enabled in settings. - const pluginConfig = config.settings?.plugins ?? {}; - setUpCommandRegistry(config.settings, executablePlugins()); + let livePluginConfig: Record = { ...(config.settings?.plugins ?? {}) }; + setUpCommandRegistry(config.settings, executablePlugins(), () => livePluginConfig); // loadConfig already bootstrapped pricing metadata; re-read cache here so a // TUI-only entry (tests) still picks up the tool-home cache path. await seedPricingMetadataFromCache({ @@ -973,7 +971,6 @@ export async function runTUI(initialConfig: Config): Promise { ...(typeof a["description"] === "string" ? { description: a["description"] } : {}), })); } - let livePluginConfig: Record = { ...(config.settings?.plugins ?? {}) }; let liveWebOverride: string | undefined = config.settings?.web; const livePluginPaths: string[] = [...(config.settings?.pluginPaths ?? [])]; const persistPluginSettings = async (): Promise => { @@ -1043,21 +1040,10 @@ export async function runTUI(initialConfig: Config): Promise { if (ci >= 0) toolPluginCandidates.splice(ci, 1, cand); else toolPluginCandidates.push(cand); } - if ( - full.commandPlugin !== undefined && - isEnabledCommandPlugin(full, livePluginConfig) - ) { - registerCommandPlugin(full.commandPlugin); - } + registerCommandPluginModule(full, () => livePluginConfig); } } } - // Live-wire a command plugin the moment it is enabled (no restart needed); - // disabling takes effect on the next launch. - const mod = livePluginModules.find((m) => m.manifest?.id === id); - if (mod !== undefined && isEnabledCommandPlugin(mod, livePluginConfig)) { - registerCommandPlugin(mod.commandPlugin!); - } await persistPluginSettings(); return trustGrantMessage === undefined ? undefined : { message: trustGrantMessage }; }, @@ -1168,13 +1154,10 @@ export async function runTUI(initialConfig: Config): Promise { if (ci >= 0) toolPluginCandidates.splice(ci, 1, cand); else toolPluginCandidates.push(cand); } - // Register slash commands immediately so they show up without a restart. - // Also persist enabled: true — path-add is consent to use the plugin; without - // this, restart loads the path but isPluginEnabled stays false and commands vanish. + // Path-add is consent to use the plugin, so persist activation and make + // its commands available from the registry without requiring a restart. livePluginConfig = enablePluginConfig(livePluginConfig, descriptor.id); - if (mod.commandPlugin !== undefined && isEnabledCommandPlugin(mod, livePluginConfig)) { - registerCommandPlugin(mod.commandPlugin); - } + registerCommandPluginModule(mod, () => livePluginConfig); // Persist the resolved absolute path so it reloads regardless of the cwd // the next session starts from. if (!livePluginPaths.includes(abs)) livePluginPaths.push(abs); @@ -1237,7 +1220,7 @@ export async function runTUI(initialConfig: Config): Promise { // Skill directories from enabled plugins, in addition to project-local // `.agents`/`.claude`/`.codex/skills` that discoverSkills/resolveSkillBody check. - const skillDirs = skillDirsFromEnabledPlugins(executablePlugins(), pluginConfig); + const skillDirs = skillDirsFromEnabledPlugins(executablePlugins(), livePluginConfig); const shellTimeout = shellTimeoutFromSettings(config.settings); // Mutable so Settings → waitForApproval takes effect on the next tool call @@ -2447,7 +2430,7 @@ export async function runTUI(initialConfig: Config): Promise { }); }); }, - commands: listCommands().map((c) => ({ name: c.name, description: c.description })), + commands: () => listCommands().map((c) => ({ name: c.name, description: c.description })), onCommand: (name) => { const route = routeSubmission(name); if (route.kind === "empty") return; diff --git a/tests/unit/plugin-register.test.ts b/tests/unit/plugin-register.test.ts index 461914dcd..a2a9b6b00 100644 --- a/tests/unit/plugin-register.test.ts +++ b/tests/unit/plugin-register.test.ts @@ -7,6 +7,7 @@ import { isPluginModuleEnabled, } from "../../src/plugins/register.js"; import type { PluginModule } from "../../src/plugins/loader.js"; +import { getCommand } from "../../src/tui/commands/registry.js"; function cmdModule(id: string, extra: Partial = {}): PluginModule { return { @@ -55,6 +56,17 @@ test("registerCommandPlugins registers only enabled command plugins", () => { expect(registered).toEqual(["reg-on"]); }); +test("registerCommandPlugins restores a disabled-at-startup command without re-registering", () => { + const mods = [cmdModule("reg-off-live")]; + const config: Record = { "reg-off-live": { enabled: false } }; + const registered = registerCommandPlugins(mods, config); + expect(registered).toEqual([]); + expect(getCommand("reg-off-live")).toBeUndefined(); + + config["reg-off-live"] = { enabled: true }; + expect(getCommand("reg-off-live")).toBeDefined(); +}); + test("enablePluginConfig marks enabled and preserves credentials/consented", () => { // Path-add must leave plugins[id].enabled === true so restart re-wires commands. expect(isPluginEnabled({}, "path-cmd")).toBe(false);