diff --git a/src/plugins/origin-marker.test.ts b/src/plugins/origin-marker.test.ts new file mode 100644 index 000000000..631c29a65 --- /dev/null +++ b/src/plugins/origin-marker.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { + BUNDLED_PLUGIN_MARKER, + pluginOriginMarker, + withOriginMarker, +} from "./origin-marker"; + +describe("pluginOriginMarker", () => { + test("bundled repo plugins get the bundled marker", () => { + expect(pluginOriginMarker("repo")).toBe(BUNDLED_PLUGIN_MARKER); + }); + + test("other origins render as their origin label", () => { + expect(pluginOriginMarker("user")).toBe("[user]"); + expect(pluginOriginMarker("project")).toBe("[project]"); + expect(pluginOriginMarker("path")).toBe("[path]"); + }); + + test("rows without an origin stay unmarked", () => { + expect(pluginOriginMarker(undefined)).toBe(""); + }); +}); + +describe("withOriginMarker", () => { + test("appends the marker after the label", () => { + expect(withOriginMarker("/implement", "repo")).toBe( + `/implement ${BUNDLED_PLUGIN_MARKER}`, + ); + expect(withOriginMarker("exa — enabled", "user")).toBe( + "exa — enabled [user]", + ); + }); + + test("leaves unmarked labels alone", () => { + expect(withOriginMarker("/help", undefined)).toBe("/help"); + }); +}); diff --git a/src/plugins/origin-marker.ts b/src/plugins/origin-marker.ts new file mode 100644 index 000000000..1edbd2bb6 --- /dev/null +++ b/src/plugins/origin-marker.ts @@ -0,0 +1,26 @@ +import type { PluginOrigin } from "../trust/project-trust.js"; + +/** + * Inline marker for a bundled (origin "repo") Corbits plugin row. ASCII only: + * AGENTS.md bans emoji in code, and wide-glyph width tables disagree across + * terminals, so rows use the same `[origin]` label shape as every other + * origin. (The brand mark itself is a multi-cell canvas silhouette + * (`tui/mark-shape.ts`), not a single text glyph, and `●` already means live + * work in chrome state.) + */ +export const BUNDLED_PLUGIN_MARKER = "[bundled]"; + +/** Short marker naming a plugin row's discovery origin for list display. */ +export function pluginOriginMarker(origin: PluginOrigin | undefined): string { + if (origin === undefined) return ""; + return origin === "repo" ? BUNDLED_PLUGIN_MARKER : `[${origin}]`; +} + +/** Append the origin marker to a row label, leaving unmarked labels alone. */ +export function withOriginMarker( + label: string, + origin: PluginOrigin | undefined, +): string { + const marker = pluginOriginMarker(origin); + return marker === "" ? label : `${label} ${marker}`; +} diff --git a/src/plugins/register.ts b/src/plugins/register.ts index f3aeca1fb..eeb18af6e 100644 --- a/src/plugins/register.ts +++ b/src/plugins/register.ts @@ -76,8 +76,10 @@ export function registerCommandPluginModule( if (!isCommandPluginModule(mod)) return false; const commandPlugin = mod.commandPlugin; if (commandPlugin === undefined) return false; - registerCommandPlugin(commandPlugin, () => - isPluginModuleEnabled(mod, getConfig()), + registerCommandPlugin( + commandPlugin, + () => isPluginModuleEnabled(mod, getConfig()), + mod.origin, ); return true; } diff --git a/src/tui/command-catalog.test.ts b/src/tui/command-catalog.test.ts index c4b0c7006..8a961b5b7 100644 --- a/src/tui/command-catalog.test.ts +++ b/src/tui/command-catalog.test.ts @@ -2,8 +2,11 @@ import { describe, expect, test } from "bun:test"; import { commandItemsFromRegistry, filterPaletteCommands, + formatPaletteRows, paletteLabels, } from "./command-catalog"; +import { BUNDLED_PLUGIN_MARKER } from "../plugins/origin-marker.js"; +import { stringWidth } from "./view/height.js"; describe("commandItemsFromRegistry", () => { test("maps listCommands-shaped entries to name-only `/` labels", () => { @@ -78,3 +81,42 @@ describe("paletteLabels", () => { expect(paletteLabels(catalog)).toEqual(["/tasks"]); }); }); + +describe("command origin markers", () => { + test("bundled repo rows carry the bundled marker, other origins their label", () => { + const items = commandItemsFromRegistry([ + { name: "implement", description: "Bundled command", origin: "repo" }, + { name: "mine", description: "Marketplace command", origin: "user" }, + { name: "proj", description: "Project command", origin: "project" }, + { name: "local", description: "Path command", origin: "path" }, + { name: "help", description: "Built-in" }, + ]); + expect(paletteLabels(items)).toEqual([ + `/implement ${BUNDLED_PLUGIN_MARKER}`, + "/mine [user]", + "/proj [project]", + "/local [path]", + "/help", + ]); + }); + + test("marked rows still filter by command name", () => { + const catalog = commandItemsFromRegistry([ + { name: "implement", description: "Bundled command", origin: "repo" }, + ]); + expect(filterPaletteCommands("implem", catalog).map((c) => c.id)).toEqual([ + "implement", + ]); + }); + + test("a marked row still formats to exactly the target width", () => { + const catalog = commandItemsFromRegistry([ + { name: "implement", description: "Bundled command", origin: "repo" }, + ]); + for (const width of [16, 24, 40]) { + const rows = formatPaletteRows(paletteLabels(catalog), width); + expect(rows).toHaveLength(1); + expect(stringWidth(rows[0] ?? "")).toBe(width); + } + }); +}); diff --git a/src/tui/command-catalog.ts b/src/tui/command-catalog.ts index 1295a1fe7..43ab4774b 100644 --- a/src/tui/command-catalog.ts +++ b/src/tui/command-catalog.ts @@ -7,12 +7,16 @@ * setPaletteCatalog(shell, () => commandItemsFromRegistry(listCommands())) */ +import { withOriginMarker } from "../plugins/origin-marker.js"; +import type { PluginOrigin } from "../trust/project-trust.js"; import { sliceToWidth, stringWidth } from "./view/height.js"; /** Minimal registry shape — matches `listCommands()` entries without importing them. */ export interface RegistryCommandSource { readonly name: string; readonly description: string; + /** Discovery origin of the contributing plugin, when the command has one. */ + readonly origin?: PluginOrigin; } /** One entry in the `/` command list: registry command name + display label. */ @@ -33,8 +37,9 @@ export function commandItemsFromRegistry( id: c.name, // Name-only rows keep the slash popup scannable; description is a // dedicated field for the overlay zone and stays in keywords so typed - // filter still finds prose matches. - label: `/${c.name}`, + // filter still finds prose matches. Plugin rows carry their origin + // marker ([bundled] for bundled, origin label otherwise). + label: withOriginMarker(`/${c.name}`, c.origin), description: c.description, keywords: [c.name, c.description, "slash", "command"], })); diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index cf55f18d3..e02f4e6b9 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -23,6 +23,7 @@ import { import type { KeyEvent } from "@opentui/core"; import { focusOwner } from "./focus/index.js"; +import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js"; import { withTestRenderer, type Harness } from "./harness"; import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js"; import { createAppShell } from "./shell/index"; @@ -109,7 +110,7 @@ describe("surface labels", () => { credentialValues: {}, origin: "project", }; - expect(pluginRowLabel(entry)).toBe("linear — untrusted"); + expect(pluginRowLabel(entry)).toBe("linear — untrusted [project]"); expect( pluginRowLabel({ id: "b", @@ -119,7 +120,7 @@ describe("surface labels", () => { credentialValues: {}, origin: "user", }), - ).toBe("exa — enabled"); + ).toBe("exa — enabled [user]"); }); test("mcp label reports disabled without a tool count", () => { @@ -141,7 +142,24 @@ describe("surface labels", () => { 'agent a: skill "style" referenced but not found in skill search path', ], }), - ).toBe("agents — enabled — has warnings"); + ).toBe("agents — enabled — has warnings [user]"); + }); + + test("plugin label marks bundled and non-bundled origins differently", () => { + const entry = (origin: PluginEntry["origin"], name = "repo-plugin") => + pluginRowLabel({ + id: name, + name, + enabled: true, + credentials: [], + credentialValues: {}, + origin, + }); + expect(entry("repo")).toBe(`repo-plugin — enabled ${BUNDLED_MARKER}`); + expect(entry("user", "market-plugin")).toBe( + "market-plugin — enabled [user]", + ); + expect(entry("path", "local-plugin")).toBe("local-plugin — enabled [path]"); }); }); @@ -413,6 +431,39 @@ describe("plugins surface", () => { expect(shell.overlayItems[0]).toBe("linear — enabled"); }); }); + + test("marks bundled rows with the bundled marker and other origins by label", async () => { + await withShell(async (shell) => { + const deps: CommandSurfaceDeps = { + notify: () => undefined, + plugins: { + list: () => [ + { + id: "bundled", + name: "bundled", + enabled: true, + credentials: [], + credentialValues: {}, + origin: "repo", + }, + { + id: "market", + name: "market", + enabled: true, + credentials: [], + credentialValues: {}, + origin: "user", + }, + ], + } as unknown as PluginsSurfaceDeps, + }; + openCommandSurface(shell, "plugins", deps); + expect(shell.overlayItems.slice(0, 2)).toEqual([ + `bundled — enabled ${BUNDLED_MARKER}`, + "market — enabled [user]", + ]); + }); + }); }); function key(name: string): KeyEvent { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index a481eedac..f91070efc 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -10,6 +10,7 @@ import { isAbsoluteHTTPURL, validateMCPServerName } from "../mcp/add-server.js"; import { formatPluginWarningsSummary } from "../plugins/diagnostics.js"; +import { withOriginMarker } from "../plugins/origin-marker.js"; import type { PluginOrigin } from "../plugins/admin.js"; import { classifyPluginRemove, @@ -283,9 +284,12 @@ export function pluginRowLabel(entry: PluginEntry): string { : pluginHasWarnings(entry) ? "has warnings" : entry.kind; - return blocker - ? `${entry.name} — ${state} — ${blocker}` - : `${entry.name} — ${state}`; + return withOriginMarker( + blocker + ? `${entry.name} — ${state} — ${blocker}` + : `${entry.name} — ${state}`, + entry.origin, + ); } function pluginNeedsDiskConfirm( diff --git a/src/tui/commands/registry.test.ts b/src/tui/commands/registry.test.ts index 7426a3d64..449ba3227 100644 --- a/src/tui/commands/registry.test.ts +++ b/src/tui/commands/registry.test.ts @@ -274,6 +274,40 @@ describe("registerCommandPlugin", () => { "built-in", ); }); + + it("surfaces the winning candidate's plugin origin via listCommands", () => { + registerCommandPlugin( + { + commands: [ + { + name: "origin-marked-cmd", + description: "bundled", + handler: () => ({ type: "noop" }), + }, + ], + }, + () => true, + "repo", + ); + registerCommandPlugin({ + commands: [ + { + name: "unmarked-plugin-cmd", + description: "no origin", + handler: () => ({ type: "noop" }), + }, + ], + }); + + expect( + listCommands().find((command) => command.name === "origin-marked-cmd") + ?.pluginOrigin, + ).toBe("repo"); + expect( + listCommands().find((command) => command.name === "unmarked-plugin-cmd") + ?.pluginOrigin, + ).toBeUndefined(); + }); }); describe("setHiddenCommands", () => { diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 22a1bdf8d..ce7909ffa 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -1,4 +1,5 @@ import type { CostSummary } from "../../cost/cost-summary.js"; +import type { PluginOrigin } from "../../trust/project-trust.js"; export interface CommandContext { signalClear: () => void; @@ -57,6 +58,11 @@ export interface SubcommandDefinition { export interface CommandDefinition { name: string; description: string; + /** + * Discovery origin of the plugin that contributed this command, when the + * command came from a plugin. Built-ins leave it unset and render unmarked. + */ + pluginOrigin?: PluginOrigin; /** * Claude Code–compatible free-form arg guidance (frontmatter `argument-hint`). * Shown greyed next to the command and after `/cmd ` until the operator types. @@ -78,6 +84,7 @@ export interface CommandPlugin { interface PluginCommandCandidate { command: CommandDefinition; isActive: () => boolean; + origin?: PluginOrigin; } const registry = new Map(); @@ -94,10 +101,15 @@ export function registerCommand(def: CommandDefinition): void { export function registerCommandPlugin( plugin: CommandPlugin, isActive: () => boolean = () => true, + origin?: PluginOrigin, ): void { for (const cmd of plugin.commands) { const candidates = pluginCandidates.get(cmd.name) ?? []; - candidates.push({ command: cmd, isActive }); + candidates.push({ + command: cmd, + isActive, + ...(origin !== undefined ? { origin } : {}), + }); pluginCandidates.set(cmd.name, candidates); } } @@ -118,8 +130,15 @@ export function listCommands(): CommandDefinition[] { 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); + const winner = pluginCandidates + .get(name) + ?.find((candidate) => candidate.isActive()); + if (winner === undefined) continue; + commands.push( + winner.origin === undefined + ? winner.command + : { ...winner.command, pluginOrigin: winner.origin }, + ); } return commands .filter( diff --git a/src/tui/palette-paint.test.ts b/src/tui/palette-paint.test.ts index 981a93712..745653116 100644 --- a/src/tui/palette-paint.test.ts +++ b/src/tui/palette-paint.test.ts @@ -8,7 +8,11 @@ import type { KeyEvent } from "@opentui/core"; import { defined } from "../../tests/helpers/defined.js"; import { withTestRenderer } from "./harness"; -import type { PaletteCommand } from "./command-catalog"; +import { + commandItemsFromRegistry, + type PaletteCommand, +} from "./command-catalog"; +import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; import { acceptOverlaySelection, openListOverlay } from "./shell/overlay-host"; @@ -222,6 +226,36 @@ describe("palette filters as you type", () => { { width: 100, height: 32 }, ); }); + + test("bundled plugin rows paint the bundled marker; other origins their label", async () => { + const catalog = commandItemsFromRegistry([ + { name: "bundled-cmd", description: "Bundled command", origin: "repo" }, + { + name: "market-cmd", + description: "Marketplace command", + origin: "user", + }, + ]); + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }); + try { + openPalette(shell, { catalog, typeToFilter: true }); + expect(shell.overlayItems).toEqual([ + `/bundled-cmd ${BUNDLED_MARKER}`, + "/market-cmd [user]", + ]); + } finally { + shell.dispose(); + } + }, + { width: 100, height: 32 }, + ); + }); }); const DESCRIBED_CATALOG: readonly PaletteCommand[] = [ diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 380e1dba1..28fbcf871 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -160,6 +160,7 @@ export async function runTUI(initialConfig: Config): Promise { listCommands().map((c) => ({ name: c.name, description: c.description, + ...(c.pluginOrigin !== undefined ? { origin: c.pluginOrigin } : {}), })), onCommand: (name) => { const route = routeSubmission(name);