Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/plugins/origin-marker.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
26 changes: 26 additions & 0 deletions src/plugins/origin-marker.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
6 changes: 4 additions & 2 deletions src/plugins/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions src/tui/command-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
}
});
});
9 changes: 7 additions & 2 deletions src/tui/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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"],
}));
Expand Down
57 changes: 54 additions & 3 deletions src/tui/command-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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", () => {
Expand All @@ -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]");
});
});

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/tui/command-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions src/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
25 changes: 22 additions & 3 deletions src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -78,6 +84,7 @@ export interface CommandPlugin {
interface PluginCommandCandidate {
command: CommandDefinition;
isActive: () => boolean;
origin?: PluginOrigin;
}

const registry = new Map<string, CommandDefinition>();
Expand All @@ -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);
}
}
Expand All @@ -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(
Expand Down
Loading
Loading