feat(tui): redesign /plugins as a tabbed panel (#1025)

* feat(tui): redesign /plugins as a tabbed panel

Split the /plugins manager into Installed / Official / Third-party /
Custom tabs. The Official and Third-party marketplace catalogs load
lazily, so /plugins opens instantly and keeps working offline, with
fetch failures shown inline instead of closing the panel. The tab strip
is shared with the /model provider tabs via the new renderTabStrip
helper.

* fix(tui): show untiered marketplace entries and update badges

Address Codex review feedback on the /plugins tab redesign:

- Untiered marketplace entries (no `tier` field) now appear on the
  Third-party tab instead of being invisible in both marketplace tabs.
- Installed plugins whose marketplace version is newer than the local
  version render an `update <local> → <latest>` badge again, and
  up-to-date plugins show `installed · v<version>` — restoring the
  update visibility the pre-redesign marketplace UI had.

* fix(tui): decode Space for installed-plugin toggle

In terminals that send printable keys via Kitty/CSI-u sequences (e.g. VS
Code's integrated terminal), the Space key arrives as a printable char
rather than a Key.space match, so the Installed-tab Space toggle silently
stopped working. Check both matchesKey(Key.space) and the decoded
printable char to match the MCP selector and other dialogs.

* fix(tui): open custom marketplaces on the Third-party tab

When `/plugins marketplace <source>` points at a custom catalog whose
entries omit `tier`, those entries are classified into the Third-party
tab. Opening on Official left the visible tab empty and Enter could not
install anything, unlike the old marketplace picker which showed all
entries from the supplied source. Open on Third-party when a custom
source is supplied; the default catalog still lands on Official.

* docs(plugins): drop open-url wording and hyphenate Shift-Tab

Address Codex review feedback:

- The marketplace Enter action is install/update only (open-url rows were
  removed), so say "install or update" instead of "open or install" and
  drop the leftover changeset sentence about setup URLs.
- Use `Shift-Tab` (hyphen) instead of `Shift+Tab` to match the docs
  typography convention.

* fix(tui): keep marketplace selection valid while loading

When the Official/Third-party catalog is still loading, `entries` is empty
and pressing ↓ computed `Math.min(-1, selectedIndex + 1)` = -1. The later
Enter then read `entries[-1]` and the first install silently did nothing.
Clamp the index to 0 while there are no entries.

* fix(tui): count tab separators in tab-strip fit check

renderTabStrip declared a strip to fit whenever the sum of tab cell widths
fit, but the returned string also inserts single spaces between tabs via
`segments.join(' ')`. At widths around 43-45 columns for a four-tab strip
this declared a fit while the joined line was wider, so the trailing tab
got truncated instead of showing the `<`/`>` scroll markers. Count the
inter-tab separators in both the full-fit check and the scrolling window
fit check.

* docs(plugins): fix Kimi Datasource redirect anchor

The datasource.md redirect pointed at ./plugins.html#kimi-datasource, but
plugins.md no longer has a `## Kimi Datasource` heading — it is now
`## Official Plugins`. Update the en/zh redirect targets and fallback
links to #official-plugins / #官方插件 so the link lands on an existing
anchor.

* docs(plugins): restore concise Kimi Datasource section

The `## Official Plugins` section had replaced the original
`## Kimi Datasource` section, leaving the datasource.md redirect pointing
at a missing anchor and the Datasource capabilities/usage unreachable.
Restore a concise `## Kimi Datasource` section (intro + OAuth login +
install steps + usage) in both en and zh so the #kimi-datasource anchor
is valid again and the content is reachable.

* docs(plugins): restore Installing-from-GitHub subheading

The tab-redesign rewrite had dropped the `### Installing from GitHub` /
`### 从 GitHub 安装` subheading and its lead sentence, leaving only the
four URL forms. Restore the heading and lead sentence in both en and zh.

* docs(plugins): expand Kimi Datasource and tidy marketplace docs

- Condense the Official / Third-party / Custom tab overview and trust-badge note

- Trim the custom marketplace JSON section to the minimal id + source shape

- Move and expand the Kimi Datasource section with install, usage, and coverage

* docs(plugins): fix heading style and drop Next steps section

- Use sentence case for the Datasource headings (How to use, What you can do)

- Rename the Datasource caveat heading to Billing and limitations / 计费与限制 to avoid a duplicate Notes / 注意事项 anchor

- Remove the Next steps section, which linked back to the on-page Datasource anchor

* fix(tui): repaint plugins panel from current theme palette

The /plugins panel and MCP selector captured a palette snapshot at construction. In auto theme mode, applyResolvedAutoTheme swaps currentTheme.palette and re-renders without remounting the open panel, so it kept stale colors until closed.

Read currentTheme.palette during render instead, drop the colors opt from both components and their call sites, and add a regression test that switches palettes on a mounted panel.

* fix(tui): repaint model tab strip from current theme palette

TabbedModelSelectorComponent cached a palette snapshot in opts and used it only for the tab strip. In auto theme mode the inner model list repaints from currentTheme but the strip kept the old colors until the dialog was closed.

Read currentTheme.palette on the render path instead, drop the colors opt and its three call sites, and add a regression test that switches palettes on a mounted selector and asserts the strip repaints. This removes the last palette snapshot among editor-replacement dialogs.
This commit is contained in:
qer 2026-06-24 13:12:28 +08:00 committed by GitHub
parent 51723bee1a
commit 5ef66ddfed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1194 additions and 914 deletions

View file

@ -0,0 +1,13 @@
---
"@moonshot-ai/kimi-code": minor
---
Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed
plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained
marketplace plugins), **Third-party** (marketplace plugins from other
publishers), and **Custom** (install straight from a GitHub URL, zip URL, or
local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party
catalogs load lazily, so `/plugins` opens instantly and keeps working offline —
a marketplace fetch failure is shown inline instead of closing the panel. The
tab strip is shared with the `/model` provider tabs via the new `renderTabStrip`
helper.

View file

@ -5,13 +5,12 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
PluginsPanelComponent,
type PluginMcpSelection,
type PluginMarketplaceSelection,
type PluginRemoveConfirmResult,
type PluginsOverviewSelection,
type PluginsPanelSelection,
type PluginsPanelTabId,
} from '../components/dialogs/plugins-selector';
import {
buildPluginsInfoLines,
@ -29,6 +28,8 @@ interface ShowPluginsPickerOptions {
readonly id: string;
readonly text: string;
};
readonly initialTab?: PluginsPanelTabId;
readonly marketplaceSource?: string;
}
interface PluginMcpServerHint {
@ -73,7 +74,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
return;
}
if (sub === 'marketplace') {
await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined);
const marketplaceSource = rest.join(' ').trim() || undefined;
await showPluginsPicker(host, {
// Custom marketplaces often omit `tier`, so their entries land on the
// Third-party tab (entry.tier !== 'official'). Open there when a custom
// source is supplied; otherwise the default catalog's official entries
// make Official the right landing tab.
initialTab: marketplaceSource === undefined ? 'official' : 'third-party',
marketplaceSource,
});
return;
}
if (sub === 'info') {
@ -95,7 +104,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
}
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
host.showStatus(
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new or /reload to apply.`,
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`,
);
return;
}
@ -118,8 +127,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
host.showStatus(`Remove cancelled: ${id}.`);
return;
}
await session.removePlugin(id);
host.showStatus(`Removed ${id} (plugin files left in place).`);
await removePlugin(host, id);
return;
}
if (sub === 'reload') {
@ -149,55 +157,52 @@ async function showPluginsPicker(
return;
}
host.mountEditorReplacement(
new PluginsOverviewSelectorComponent({
plugins,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
onSelect: (selection) => {
// Each branch of the handler either mounts the next view or restores
// the editor itself, so do not pre-restore here — that would flash the
// editor for in-place actions like toggling a plugin.
void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => {
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
},
}),
);
const panel = new PluginsPanelComponent({
installed: plugins,
installedIds: new Set(plugins.map((plugin) => plugin.id)),
initialTab: options?.initialTab,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
onSelect: (selection) => {
// Each branch of the handler either mounts the next view or restores the
// editor itself, so do not pre-restore here — that would flash the editor
// for in-place actions like toggling a plugin.
void handlePluginsPanelSelection(host, selection).catch((error: unknown) => {
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
},
// The Official/Third-party tabs fetch their catalog lazily so /plugins
// opens instantly and Installed/Custom keep working even when the
// marketplace is unreachable.
onRequestMarketplace: () => {
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
},
});
host.mountEditorReplacement(panel);
if (options?.initialTab === 'official' || options?.initialTab === 'third-party') {
panel.setMarketplaceLoading();
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
}
}
async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise<void> {
async function loadMarketplaceCatalog(
host: SlashCommandHost,
panel: PluginsPanelComponent,
source?: string,
): Promise<void> {
try {
const [marketplace, installed] = await Promise.all([
loadPluginMarketplace({ workDir: host.state.appState.workDir, source }),
host.requireSession().listPlugins(),
]);
host.mountEditorReplacement(
new PluginMarketplaceSelectorComponent({
entries: marketplace.plugins,
installed: new Map(
installed.map((plugin): [string, string | undefined] => [plugin.id, plugin.version]),
),
source: marketplace.source,
onSelect: (selection) => {
// Every marketplace action re-mounts a picker, so let the handler do
// the mounting — pre-restoring the editor here would flash.
void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => {
host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
void showPluginsPicker(host);
},
}),
);
const marketplace = await loadPluginMarketplace({
workDir: host.state.appState.workDir,
source,
});
panel.setMarketplace(marketplace.plugins, marketplace.source);
} catch (error) {
host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`);
panel.setMarketplaceError(formatErrorMessage(error));
}
host.state.ui.requestRender();
}
async function showPluginMcpPicker(
@ -274,54 +279,59 @@ async function applyPluginEnabled(
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
: '';
if (showStatus) {
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new or /reload to apply.${mcpHint}`);
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`);
}
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
}
async function handlePluginsOverviewSelection(
async function handlePluginsPanelSelection(
host: SlashCommandHost,
selection: PluginsOverviewSelection,
selection: PluginsPanelSelection,
): Promise<void> {
const session = host.requireSession();
switch (selection.kind) {
case 'marketplace':
await showPluginMarketplacePicker(host);
return;
case 'reload':
await reloadPlugins(host);
await showPluginsPicker(host);
return;
case 'show-list':
host.restoreEditor();
await renderPluginsList(host);
return;
case 'toggle': {
const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false);
await showPluginsPicker(host, {
initialTab: 'installed',
selectedId: selection.id,
pluginHint: { id: selection.id, text: hint },
});
return;
}
case 'mcp':
await showPluginMcpPicker(host, selection.id);
return;
case 'remove':
if (!(await confirmRemovePlugin(host, selection.id))) {
host.showStatus(`Remove cancelled: ${selection.id}.`);
await showPluginsPicker(host, { selectedId: selection.id });
await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id });
return;
}
await session.removePlugin(selection.id);
host.showStatus(`Removed ${selection.id} (plugin files left in place).`);
await showPluginsPicker(host);
await removePlugin(host, selection.id);
await showPluginsPicker(host, { initialTab: 'installed' });
return;
case 'info':
case 'mcp':
await showPluginMcpPicker(host, selection.id);
return;
case 'details':
host.restoreEditor();
await renderPluginInfo(host, selection.id);
return;
case 'reload':
await reloadPlugins(host);
await showPluginsPicker(host, { initialTab: 'installed' });
return;
case 'install': {
host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`);
await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' });
// Close the panel after installing so the success notice and the
// "/reload or /new" / post-install tip are visible in the transcript.
host.restoreEditor();
return;
}
case 'install-source':
host.showStatus(`Installing plugin from ${truncateForStatus(selection.source)}`);
await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' });
host.restoreEditor();
return;
}
}
@ -350,22 +360,9 @@ async function handlePluginMcpSelection(
}
}
async function handlePluginMarketplaceSelection(
host: SlashCommandHost,
selection: PluginMarketplaceSelection,
): Promise<void> {
switch (selection.kind) {
case 'install':
host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`);
await installPluginFromSource(host, selection.entry.source, {
successNotice: 'marketplace',
});
await showPluginsPicker(host, { selectedId: selection.entry.id });
return;
case 'back':
await showPluginsPicker(host);
return;
}
async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
await host.requireSession().removePlugin(id);
host.showStatus(`Removed ${id}. Run /reload or /new to apply plugin changes.`);
}
async function renderPluginsList(
@ -445,13 +442,19 @@ function describeInstallAction(
return ` ${prev}${cur ?? '-'}`;
};
if (previous === undefined) {
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`;
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`;
}
if (sourceIdentity(previous) !== sourceIdentity(next)) {
const prevSourceLabel = formatPluginSourceLabel(previous);
return `Migrated ${next.displayName}: ${prevSourceLabel}${sourceLabel}${versionFromTo(previous.version, next.version)}`;
}
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`;
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`;
}
// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding
// "from" would read as "from via <host>". Only prepend "from" otherwise.
function sourcePhrase(sourceLabel: string): string {
return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`;
}
function sourceIdentity(plugin: PluginSummary): string {
@ -482,5 +485,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
}
function pluginInlineChangeHint(): string {
return 'require run /new or /reload to apply';
return 'run /reload or /new to apply';
}

View file

@ -1,5 +1,6 @@
import {
Container,
Input,
Key,
matchesKey,
truncateToWidth,
@ -7,19 +8,18 @@ import {
type Focusable,
} from '@earendil-works/pi-tui';
import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { ColorPalette } from '#/tui/theme/colors';
import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
import { printableChar } from '#/tui/utils/printable-key';
import { renderTabStrip } from '#/tui/utils/tab-strip';
import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
import { ChoicePickerComponent } from './choice-picker';
const OVERVIEW_MARKETPLACE = 'marketplace';
const OVERVIEW_RELOAD = 'reload';
const OVERVIEW_SHOW_LIST = 'show-list';
const OVERVIEW_PLUGIN_PREFIX = 'plugin:';
const MCP_SERVER_PREFIX = 'mcp:';
const REMOVE_CONFIRM_CANCEL = 'cancel';
@ -34,252 +34,6 @@ interface PluginsOverviewItem {
readonly description: string;
}
export type PluginsOverviewSelection =
| { readonly kind: 'marketplace' }
| { readonly kind: 'reload' }
| { readonly kind: 'show-list' }
| { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean }
| { readonly kind: 'mcp'; readonly id: string }
| { readonly kind: 'remove'; readonly id: string }
| { readonly kind: 'info'; readonly id: string };
export interface PluginsOverviewSelectorOptions {
readonly plugins: readonly PluginSummary[];
readonly selectedId?: string;
readonly pluginHint?: {
readonly id: string;
readonly text: string;
};
readonly onSelect: (selection: PluginsOverviewSelection) => void;
readonly onCancel: () => void;
}
export class PluginsOverviewSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginsOverviewSelectorOptions;
private readonly items: readonly PluginsOverviewItem[];
private selectedIndex = 0;
constructor(opts: PluginsOverviewSelectorOptions) {
super();
this.opts = opts;
this.items = buildOverviewItems(opts.plugins);
const selectedIndex = this.items.findIndex(
(item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`,
);
this.selectedIndex = Math.max(0, selectedIndex);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
return;
}
const chosen = this.items[this.selectedIndex];
if (chosen === undefined) return;
const pluginId = overviewItemPluginId(chosen);
const decoded = printableChar(data);
if (matchesKey(data, Key.space) || decoded === ' ') {
if (pluginId === undefined) return;
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
if (plugin !== undefined) {
this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled });
}
return;
}
if (decoded === 'd' || decoded === 'D') {
if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId });
return;
}
if (decoded === 'm' || decoded === 'M') {
if (pluginId === undefined) return;
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
if (plugin !== undefined && plugin.mcpServerCount > 0) {
this.opts.onSelect({ kind: 'mcp', id: pluginId });
}
return;
}
if (matchesKey(data, Key.enter)) {
if (pluginId !== undefined) {
this.opts.onSelect({ kind: 'info', id: pluginId });
return;
}
const selection = parseOverviewSelection(chosen.value);
if (selection !== undefined) this.opts.onSelect(selection);
}
}
override render(width: number): string[] {
const { plugins } = this.opts;
const hint =
'↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel';
const pluginItems = this.items.filter((item) => item.kind === 'plugin');
const actionItems = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ' Plugins'),
mutedHintLine(` ${hint}`),
'',
sectionLabel(`Installed plugins (${plugins.length})`),
];
if (pluginItems.length === 0) {
lines.push(currentTheme.fg('textMuted', ' No plugins installed.'));
} else {
let absoluteIndex = 0;
for (const item of pluginItems) {
lines.push(...this.renderItem(item, absoluteIndex, width));
absoluteIndex++;
}
}
lines.push('');
lines.push(sectionLabel('Actions'));
for (let i = 0; i < actionItems.length; i++) {
lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width));
}
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const selected = index === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected
? (text: string) => currentTheme.boldFg('primary', text)
: (text: string) => currentTheme.fg('text', text);
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item)(item.status);
}
const pluginId = overviewItemPluginId(item);
if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) {
line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(mutedHintLine(` ${descLine}`));
}
return lines;
}
}
export type PluginMarketplaceSelection =
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
| { readonly kind: 'back' };
export interface PluginMarketplaceSelectorOptions {
readonly entries: readonly PluginMarketplaceEntry[];
readonly installed: ReadonlyMap<string, string | undefined>;
readonly source: string;
readonly onSelect: (selection: PluginMarketplaceSelection) => void;
readonly onCancel: () => void;
}
export class PluginMarketplaceSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginMarketplaceSelectorOptions;
private readonly items: readonly PluginsOverviewItem[];
private selectedIndex = 0;
constructor(opts: PluginMarketplaceSelectorOptions) {
super();
this.opts = opts;
this.items = buildMarketplaceItems(opts.entries, opts.installed);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
return;
}
if (matchesKey(data, Key.enter)) {
const chosen = this.items[this.selectedIndex];
if (chosen === undefined) return;
if (chosen.value === 'back') {
this.opts.onSelect({ kind: 'back' });
return;
}
const entry = this.opts.entries.find((item) => item.id === chosen.value);
if (entry === undefined) return;
this.opts.onSelect({ kind: 'install', entry });
}
}
override render(width: number): string[] {
const entries = this.items.filter((item) => item.kind === 'plugin');
const actions = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ' Official plugins'),
mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel'),
currentTheme.fg('textMuted', ` Source: ${this.opts.source}`),
'',
sectionLabel(`Marketplace (${entries.length})`),
];
if (entries.length === 0) {
lines.push(currentTheme.fg('textMuted', ' No marketplace plugins found.'));
} else {
for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderItem(entries[i]!, i, width));
}
}
lines.push('');
lines.push(sectionLabel('Actions'));
for (let i = 0; i < actions.length; i++) {
lines.push(...this.renderItem(actions[i]!, entries.length + i, width));
}
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const selected = index === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected
? (text: string) => currentTheme.boldFg('primary', text)
: (text: string) => currentTheme.fg('text', text);
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item)(item.status);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(mutedHintLine(` ${descLine}`));
}
return lines;
}
}
export type PluginMcpSelection =
| { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean }
| { readonly kind: 'back'; readonly pluginId: string };
@ -347,18 +101,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
override render(width: number): string[] {
const { info } = this.opts;
const colors = currentTheme.palette;
const serverItems = this.items.filter((item) => item.kind === 'plugin');
const actionItems = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`),
mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'),
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`),
mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors),
'',
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`),
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors),
];
if (serverItems.length === 0) {
lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.'));
lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.'));
} else {
for (let i = 0; i < serverItems.length; i++) {
lines.push(...this.renderItem(serverItems[i]!, i, width));
@ -366,35 +121,34 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
}
lines.push('');
lines.push(sectionLabel('Actions'));
lines.push(sectionLabel('Actions', colors));
for (let i = 0; i < actionItems.length; i++) {
lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width));
}
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const colors = currentTheme.palette;
const selected = index === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected
? (text: string) => currentTheme.boldFg('primary', text)
: (text: string) => currentTheme.fg('text', text);
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item)(item.status);
line += ' ' + statusStyle(item, colors)(item.status);
}
const serverName = mcpItemServerName(item);
if (serverName !== undefined && this.opts.serverHint?.server === serverName) {
line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text);
line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(mutedHintLine(` ${descLine}`));
lines.push(mutedHintLine(` ${descLine}`, colors));
}
return lines;
}
@ -439,37 +193,6 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
}
}
function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] {
const options: PluginsOverviewItem[] = plugins.map((plugin) => ({
value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`,
kind: 'plugin',
label: plugin.displayName,
status: pluginStatus(plugin),
description: overviewPluginDescription(plugin),
}));
options.push(
{
value: OVERVIEW_MARKETPLACE,
kind: 'action',
label: 'Marketplace',
description: 'Browse official plugins.',
},
{
value: OVERVIEW_RELOAD,
kind: 'action',
label: 'Reload',
description: 'Re-read installed plugins and manifests.',
},
{
value: OVERVIEW_SHOW_LIST,
kind: 'action',
label: 'Summary',
description: 'Append the current plugin summary to the transcript.',
},
);
return options;
}
function overviewPluginDescription(plugin: PluginSummary): string {
const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`;
const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`;
@ -483,41 +206,380 @@ function overviewPluginDescription(plugin: PluginSummary): string {
return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`;
}
function pluginStatus(plugin: PluginSummary): string {
function pluginStatus(plugin: PluginSummary): string | undefined {
if (plugin.state !== 'ok') return plugin.state;
return plugin.enabled ? 'enabled' : 'disabled';
}
function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined {
if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' };
if (value === OVERVIEW_RELOAD) return { kind: 'reload' };
if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' };
return undefined;
function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string {
// "update …" is a warning (actionable); "installed …" is success;
// "install …" is the available action.
if (status.startsWith('update')) return chalk.hex(colors.warning);
if (status.startsWith('installed')) return chalk.hex(colors.success);
return chalk.hex(colors.primary);
}
function overviewItemPluginId(item: PluginsOverviewItem): string | undefined {
if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined;
return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length);
/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace
* Custom tab and the unified plugins panel. */
function renderUrlInputBox(
input: Input,
focused: boolean,
width: number,
colors: ColorPalette,
): string[] {
input.focused = focused;
const border = (s: string): string => chalk.hex(colors.primary)(s);
const boxWidth = Math.max(24, width - 2);
const innerWidth = Math.max(10, boxWidth - 4);
const inputLine = input.render(innerWidth)[0] ?? '';
const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine));
return [
' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'),
' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'),
' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'),
];
}
function buildMarketplaceItems(
entries: readonly PluginMarketplaceEntry[],
installed: ReadonlyMap<string, string | undefined>,
): PluginsOverviewItem[] {
const items: PluginsOverviewItem[] = entries.map((entry) => ({
value: entry.id,
kind: 'plugin',
label: entry.displayName,
status: marketplaceItemStatus(entry, installed),
description: marketplaceEntryDescription(entry),
}));
items.push({
value: 'back',
kind: 'action',
label: 'Back to installed plugins',
description: 'Return to the local plugin manager.',
});
return items;
// ===========================================================================
// Unified /plugins panel: Installed / Official / Third-party / Custom tabs.
// ===========================================================================
export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom';
export type PluginsPanelSelection =
| { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean }
| { readonly kind: 'remove'; readonly id: string }
| { readonly kind: 'mcp'; readonly id: string }
| { readonly kind: 'details'; readonly id: string }
| { readonly kind: 'reload' }
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
| { readonly kind: 'install-source'; readonly source: string };
export interface PluginsPanelOptions {
readonly installed: readonly PluginSummary[];
readonly installedIds: ReadonlySet<string>;
readonly initialTab?: PluginsPanelTabId;
readonly selectedId?: string;
readonly pluginHint?: { readonly id: string; readonly text: string };
readonly onSelect: (selection: PluginsPanelSelection) => void;
readonly onCancel: () => void;
/** Called the first time the Official or Third-party tab needs its catalog.
* The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */
readonly onRequestMarketplace?: () => void;
}
type MarketState =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'error'; readonly message: string }
| { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string };
const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [
{ id: 'installed', label: 'Installed' },
{ id: 'official', label: 'Official' },
{ id: 'third-party', label: 'Third-party' },
{ id: 'custom', label: 'Custom' },
];
export class PluginsPanelComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginsPanelOptions;
private readonly customInput = new Input();
private activeTabIndex: number;
private selectedIndex = 0;
private market: MarketState = { status: 'idle' };
constructor(opts: PluginsPanelOptions) {
super();
this.opts = opts;
this.activeTabIndex = Math.max(
0,
PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')),
);
if (opts.selectedId !== undefined && this.activeTab.id === 'installed') {
const idx = opts.installed.findIndex((p) => p.id === opts.selectedId);
if (idx >= 0) this.selectedIndex = idx;
}
this.customInput.onSubmit = (value) => {
const source = value.trim();
if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source });
};
}
marketplaceStatus(): MarketState['status'] {
return this.market.status;
}
setMarketplaceLoading(): void {
this.market = { status: 'loading' };
}
setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void {
this.market = { status: 'loaded', entries, source };
}
setMarketplaceError(message: string): void {
this.market = { status: 'error', message };
}
private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] {
return PLUGINS_PANEL_TABS[this.activeTabIndex]!;
}
private get marketplaceEntries(): readonly PluginMarketplaceEntry[] {
if (this.market.status !== 'loaded') return [];
const { installedIds } = this.opts;
return this.market.entries.toSorted(
(a, b) => Number(installedIds.has(b.id)) - Number(installedIds.has(a.id)),
);
}
private get installedVersions(): ReadonlyMap<string, string | undefined> {
return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version]));
}
private get officialEntries(): readonly PluginMarketplaceEntry[] {
return this.marketplaceEntries.filter((entry) => entry.tier === 'official');
}
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
// Anything not explicitly marked official lands here: `curated` entries plus
// entries that omit `tier` (custom marketplaces often do). Without this,
// untiered entries would be invisible in both marketplace tabs.
return this.marketplaceEntries.filter((entry) => entry.tier !== 'official');
}
private requestMarketplaceIfNeeded(): void {
if (this.market.status === 'idle' && this.activeTab.id !== 'installed' && this.activeTab.id !== 'custom') {
this.market = { status: 'loading' };
this.opts.onRequestMarketplace?.();
}
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.tab)) {
this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length;
this.selectedIndex = 0;
this.requestMarketplaceIfNeeded();
return;
}
if (matchesKey(data, Key.shift('tab'))) {
this.activeTabIndex =
(this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length;
this.selectedIndex = 0;
this.requestMarketplaceIfNeeded();
return;
}
switch (this.activeTab.id) {
case 'installed':
this.handleInstalledInput(data);
return;
case 'official':
case 'third-party':
this.handleMarketplaceInput(data);
return;
case 'custom':
this.customInput.handleInput(data);
return;
}
}
private handleInstalledInput(data: string): void {
const plugins = this.opts.installed;
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1);
return;
}
const plugin = plugins[this.selectedIndex];
const ch = printableChar(data);
// Decode Space for terminals that send printable keys via Kitty/CSI-u
// sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)`
// alone misses those and the toggle silently stops working.
if (matchesKey(data, Key.space) || ch === ' ') {
if (plugin !== undefined) {
this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled });
}
return;
}
if (ch === 'd' || ch === 'D') {
if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id });
return;
}
if (ch === 'm' || ch === 'M') {
if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id });
return;
}
if (ch === 'r' || ch === 'R') {
this.opts.onSelect({ kind: 'reload' });
return;
}
if (matchesKey(data, Key.enter)) {
if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id });
}
}
private handleMarketplaceInput(data: string): void {
const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries;
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
// Clamp to 0 while the catalog is still loading (entries empty); otherwise
// `entries.length - 1` is -1 and a later Enter reads `entries[-1]`.
this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1);
return;
}
if (matchesKey(data, Key.enter)) {
const entry = entries[this.selectedIndex];
if (entry === undefined) return;
this.opts.onSelect({ kind: 'install', entry });
}
}
override invalidate(): void {
super.invalidate();
this.customInput.invalidate();
}
override render(width: number): string[] {
const colors = currentTheme.palette;
const tab = this.activeTab.id;
const hint =
tab === 'installed'
? ' Tab switch · Space toggle · D remove · M MCP · Enter details · R reload · Esc cancel'
: tab === 'custom'
? ' Tab switch · Enter install · Esc cancel'
: ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel';
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Plugins'),
mutedHintLine(hint, colors),
'',
renderTabStrip({
labels: PLUGINS_PANEL_TABS.map((t) => t.label),
activeIndex: this.activeTabIndex,
width,
colors,
}),
'',
];
if (tab === 'installed') this.renderInstalled(lines, width);
else if (tab === 'official') this.renderOfficial(lines, width);
else if (tab === 'third-party') this.renderThirdParty(lines, width);
else this.renderCustom(lines, width);
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderInstalled(lines: string[], width: number): void {
const { installed } = this.opts;
const colors = currentTheme.palette;
if (installed.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No plugins installed.'));
} else {
for (let i = 0; i < installed.length; i++) {
lines.push(...this.renderInstalledRow(installed[i]!, i, width));
}
}
lines.push('');
lines.push(mutedHintLine(` ${installed.length} installed`, colors));
}
private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] {
const colors = currentTheme.palette;
const selected = index === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
const status = pluginStatus(plugin);
let line = prefix + labelStyle(plugin.displayName);
if (status !== undefined) {
line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status);
}
if (this.opts.pluginHint?.id === plugin.id) {
line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text);
}
const descWidth = Math.max(1, width - 4);
const out = [line];
for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) {
out.push(mutedHintLine(` ${descLine}`, colors));
}
return out;
}
private renderMarketplaceTab(
lines: string[],
width: number,
entries: readonly PluginMarketplaceEntry[],
): void {
const colors = currentTheme.palette;
if (this.market.status === 'loading' || this.market.status === 'idle') {
lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…'));
return;
}
if (this.market.status === 'error') {
lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`));
lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors));
return;
}
if (entries.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
} else {
for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
}
}
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
lines.push('');
lines.push(
mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors),
);
lines.push(mutedHintLine(` Source: ${this.market.source}`, colors));
}
private renderOfficial(lines: string[], width: number): void {
this.renderMarketplaceTab(lines, width, this.officialEntries);
}
private renderThirdParty(lines: string[], width: number): void {
this.renderMarketplaceTab(lines, width, this.thirdPartyEntries);
}
private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] {
const colors = currentTheme.palette;
const selected = index === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
const status = marketplaceEntryStatus(entry, this.installedVersions);
const line =
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
const descWidth = Math.max(1, width - 4);
const out = [line];
for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) {
out.push(mutedHintLine(` ${descLine}`, colors));
}
return out;
}
private renderCustom(lines: string[], width: number): void {
const colors = currentTheme.palette;
lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors));
lines.push('');
lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors));
}
}
function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
@ -556,13 +618,13 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined {
function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string {
const tier = marketplaceTierLabel(entry.tier);
const description = entry.description ?? tier;
const version = entry.version !== undefined ? ` · v${entry.version}` : '';
const keywords =
entry.keywords !== undefined && entry.keywords.length > 0
? ` · ${entry.keywords.join(', ')}`
: '';
const tierSuffix = entry.description !== undefined ? ` · ${tier}` : '';
// The version now lives in the status badge, so it is omitted here to avoid duplication.
return `${description} · id ${entry.id}${tierSuffix}${keywords}`;
return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`;
}
function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
@ -571,7 +633,11 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
return 'Plugin';
}
function marketplaceItemStatus(
function installStatus(entry: PluginMarketplaceEntry): string {
return entry.version === undefined ? 'install' : `install v${entry.version}`;
}
function marketplaceEntryStatus(
entry: PluginMarketplaceEntry,
installed: ReadonlyMap<string, string | undefined>,
): string {
@ -582,27 +648,30 @@ function marketplaceItemStatus(
case 'up-to-date':
return status.version === undefined ? 'installed' : `installed · v${status.version}`;
case 'not-installed':
return entry.version === undefined ? 'install' : `install v${entry.version}`;
return installStatus(entry);
}
}
function sectionLabel(label: string): string {
return currentTheme.boldFg('textDim', ` ${label}`);
function sectionLabel(label: string, colors: ColorPalette): string {
return chalk.hex(colors.textDim).bold(` ${label}`);
}
function statusStyle(
item: PluginsOverviewItem,
colors: ColorPalette,
): (text: string) => string {
if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text);
if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text);
if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text);
if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text);
if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text);
if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text);
return (text) => currentTheme.fg('warning', text);
if (item.kind === 'action') return chalk.hex(colors.textDim);
if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success);
if (item.status?.startsWith('install')) return chalk.hex(colors.primary);
if (item.status === 'disabled') return chalk.hex(colors.textDim);
if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim);
return chalk.hex(colors.warning);
}
function mutedHintLine(text: string): string {
function mutedHintLine(text: string, colors?: ColorPalette): string {
if (colors !== undefined) {
return chalk.hex(colors.textMuted)(text);
}
return currentTheme.fg('textMuted', text);
}

View file

@ -19,11 +19,11 @@ import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import { currentTheme } from '#/tui/theme';
import { renderTabStrip } from '#/tui/utils/tab-strip';
import {
ModelSelectorComponent,
@ -103,7 +103,12 @@ export class TabbedModelSelectorComponent extends Container implements Focusable
// Layout: divider, title, hint, blank, tab strip, blank, then the model
// list. The inner selector's blank line (inner[3]) separates the hint from
// the tab strip; an extra blank separates the tabs from their list.
const stripLine = this.renderTabStrip(width);
const stripLine = renderTabStrip({
labels: this.tabs.map((tab) => tab.label),
activeIndex: this.activeIndex,
width,
colors: currentTheme.palette,
});
const out: string[] = [
inner[0] ?? '',
inner[1] ?? '',
@ -129,81 +134,6 @@ export class TabbedModelSelectorComponent extends Container implements Focusable
tab.selector.focused = this.focused && i === this.activeIndex;
}
}
/** Style a tab segment. The active tab is filled with the brand background
* (matching the AskUserQuestion dialog); inactive tabs are muted. Both have
* the same visible width so switching never shifts the layout. */
private styleTab(label: string, isActive: boolean): string {
const cell = ` ${label} `;
return isActive
? currentTheme.bg('primary', currentTheme.boldFg('text', cell))
: currentTheme.fg('textMuted', cell);
}
private renderTabStrip(width: number): string {
const segments: string[] = [];
for (let i = 0; i < this.tabs.length; i++) {
const tab = this.tabs[i]!;
segments.push(this.styleTab(tab.label, i === this.activeIndex));
}
// If everything fits with a leading space, show the whole strip. The
// provider-switch hint lives in the inner selector's hint line, not here.
const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0);
if (1 + totalSegmentWidth <= width) {
return ' ' + segments.join(' ');
}
// Scrolling needed. Find the widest window that contains activeIndex.
const segmentWidths = segments.map((s) => visibleWidth(s));
let start = this.activeIndex;
let end = this.activeIndex + 1;
let contentWidth = segmentWidths[this.activeIndex]!;
const fits = (s: number, e: number, cw: number): boolean => {
const needLeft = s > 0;
const needRight = e < segments.length;
const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0);
return cw + frameWidth <= width;
};
while (true) {
const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity;
const rightW = end < segments.length ? segmentWidths[end]! : Infinity;
if (leftW === Infinity && rightW === Infinity) break;
if (leftW <= rightW) {
if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else {
break;
}
} else {
if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else {
break;
}
}
}
const hasLeft = start > 0;
const hasRight = end < segments.length;
let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' ';
strip += segments.slice(start, end).join(' ');
if (hasRight) {
strip += currentTheme.fg('textMuted', ' >');
}
return strip;
}
}
function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] {

View file

@ -0,0 +1,94 @@
/**
* Shared tab strip renderer for tabbed dialogs (model selector, plugin
* marketplace, ). The active tab is filled with the brand background, inactive
* tabs are muted matching the AskUserQuestion dialog. See
* .agents/skills/write-tui/DESIGN.md §5.
*
* When the strip is wider than the terminal, it scrolls to keep the active tab
* visible, framed by `<`/`>` markers.
*/
import { visibleWidth } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
export interface RenderTabStripOptions {
readonly labels: readonly string[];
readonly activeIndex: number;
readonly width: number;
readonly colors: ColorPalette;
}
/** Style one tab cell. Active and inactive cells have the same visible width so
* switching never shifts the layout. */
function styleTab(label: string, isActive: boolean, colors: ColorPalette): string {
const cell = ` ${label} `;
return isActive
? chalk.bgHex(colors.primary).hex(colors.text).bold(cell)
: chalk.hex(colors.textMuted)(cell);
}
export function renderTabStrip(opts: RenderTabStripOptions): string {
const { labels, activeIndex, width, colors } = opts;
const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors));
// If everything fits with a leading space, show the whole strip. Account for
// the single spaces `segments.join(' ')` inserts between tabs — otherwise the
// strip is declared to fit at widths where the joined line is actually wider
// and gets truncated instead of showing the `<`/`>` scroll markers.
const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0);
const fullSeparatorWidth = Math.max(0, segments.length - 1);
if (1 + totalSegmentWidth + fullSeparatorWidth <= width) {
return ' ' + segments.join(' ');
}
// Scrolling needed. Find the widest window that contains activeIndex.
const segmentWidths = segments.map((s) => visibleWidth(s));
let start = activeIndex;
let end = activeIndex + 1;
let contentWidth = segmentWidths[activeIndex] ?? 0;
const fits = (s: number, e: number, cw: number): boolean => {
const needLeft = s > 0;
const needRight = e < segments.length;
const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0);
const separators = Math.max(0, e - s - 1);
return cw + separators + frameWidth <= width;
};
while (true) {
const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity;
const rightW = end < segments.length ? segmentWidths[end]! : Infinity;
if (leftW === Infinity && rightW === Infinity) break;
if (leftW <= rightW) {
if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else {
break;
}
} else if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else {
break;
}
}
const hasLeft = start > 0;
const hasRight = end < segments.length;
let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' ';
strip += segments.slice(start, end).join(' ');
if (hasRight) {
strip += chalk.hex(colors.textMuted)(' >');
}
return strip;
}

View file

@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -76,11 +76,21 @@ export interface LoadPluginMarketplaceOptions {
export async function loadPluginMarketplace(
options: LoadPluginMarketplaceOptions,
): Promise<PluginMarketplace> {
const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV];
const location = resolveMarketplaceLocation(
options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL,
configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL,
options.workDir,
);
const raw = await readMarketplaceText(location, options.fetchImpl ?? fetch);
let raw: string;
try {
raw = await readMarketplaceText(location, options.fetchImpl ?? fetch);
} catch (error) {
const fallback =
configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined;
if (fallback === undefined) throw error;
raw = await readMarketplaceText(fallback, options.fetchImpl ?? fetch);
return parsePluginMarketplace(raw, fallback);
}
return parsePluginMarketplace(raw, location);
}
@ -124,6 +134,14 @@ function resolveMarketplaceLocation(source: string, workDir: string): Marketplac
return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) };
}
async function getSourceCheckoutMarketplaceLocation(): Promise<MarketplaceLocation | undefined> {
const sourceDir = dirname(fileURLToPath(import.meta.url));
const marketplacePath = resolve(sourceDir, '../../../../plugins/marketplace.json');
const info = await stat(marketplacePath).catch(() => undefined);
if (info?.isFile() !== true) return undefined;
return { raw: marketplacePath, kind: 'local', resolved: marketplacePath };
}
async function readMarketplaceText(
location: MarketplaceLocation,
fetchImpl: typeof fetch,
@ -147,6 +165,7 @@ function parseMarketplaceEntry(
throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`);
}
const id = requiredString(value, 'id', index);
validateMarketplaceEntryType(value, id);
const source = stringField(value, 'source') ??
stringField(value, 'url') ??
stringField(value, 'downloadUrl');
@ -165,6 +184,19 @@ function parseMarketplaceEntry(
};
}
function validateMarketplaceEntryType(value: Record<string, unknown>, id: string): void {
const raw = value['type'];
if (raw === undefined) return;
if (typeof raw !== 'string') {
throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`);
}
const type = raw.trim();
if (type === 'plugin' || type === 'managed' || type === 'guide') return;
throw new Error(
`Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`,
);
}
function parseMarketplaceTier(
value: Record<string, unknown>,
id: string,

View file

@ -3,20 +3,17 @@ import chalk from 'chalk';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
PluginsPanelComponent,
type PluginMcpSelection,
type PluginRemoveConfirmResult,
type PluginsPanelSelection,
} from '#/tui/components/dialogs/plugins-selector';
import { darkColors } from '#/tui/theme/colors';
import { currentTheme } from '#/tui/theme';
import { darkColors, lightColors } from '#/tui/theme/colors';
import { pluginTrustLabel } from '#/tui/utils/plugin-source-label';
const ANSI_SGR = /\[[0-9;]*m/g;
const MID = '\u00B7';
const ESC = String.fromCodePoint(27);
const RIGHT = `${ESC}[C`;
const LEFT = `${ESC}[D`;
const ANSI_SGR = /\u001b\[[0-9;]*m/g;
function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?');
@ -40,6 +37,49 @@ function dangerShortcut(text: string): string {
return withAnsiColors(() => chalk.hex(darkColors.error).bold(text));
}
const superpowers = {
id: 'superpowers',
displayName: 'Superpowers',
version: '5.1.0',
enabled: true,
state: 'ok' as const,
skillCount: 14,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path' as const,
};
const officialEntries = [
{ id: 'kimi-datasource', tier: 'official' as const, displayName: 'Kimi Datasource', version: '3.1.1', source: 'https://x/d.zip' },
];
const thirdPartyEntries = [
{ id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' },
];
const marketplaceEntries = [...officialEntries, ...thirdPartyEntries];
function makePanel(opts: {
installed?: readonly (typeof superpowers)[];
initialTab?: 'installed' | 'official' | 'third-party' | 'custom';
selectedId?: string;
pluginHint?: { id: string; text: string };
}) {
const installed = opts.installed ?? [];
const onSelect = vi.fn<(s: PluginsPanelSelection) => void>();
const onRequestMarketplace = vi.fn();
const panel = new PluginsPanelComponent({
installed,
installedIds: new Set(installed.map((p) => p.id)),
initialTab: opts.initialTab,
selectedId: opts.selectedId,
pluginHint: opts.pluginHint,
onSelect,
onCancel: vi.fn(),
onRequestMarketplace,
});
return { panel, onSelect, onRequestMarketplace };
}
describe('plugins selector dialogs', () => {
it('trusts only built-in Kimi CDN plugin paths', () => {
expect(pluginTrustLabel({
@ -92,313 +132,169 @@ describe('plugins selector dialogs', () => {
})).toBe('third-party');
});
it('renders installed plugins as selectable overview entries', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 2,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
source: 'local-path',
},
],
onSelect,
onCancel: vi.fn(),
});
const raw = renderRaw(picker);
const out = strip(raw);
expect(out).toContain('Installed plugins (1)');
expect(out).toContain('Actions');
expect(out).toContain('? Kimi Datasource enabled');
expect(out).toContain(`id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`);
expect(out).not.toContain('Space disable');
expect(out).not.toContain('Enter info');
expect(out).toContain('Space toggle · M MCP servers · D remove · Enter details');
expect(out).toContain('Marketplace');
expect(out).toContain('Summary');
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' });
it('opens on the Installed tab with the four panel tabs', () => {
const { panel } = makePanel({ installed: [superpowers] });
const out = strip(renderRaw(panel));
expect(out).toContain('Plugins');
expect(out).toContain('Installed');
expect(out).toContain('Official');
expect(out).toContain('Third-party');
expect(out).toContain('Custom');
expect(out).toContain('? Superpowers enabled');
expect(out).toContain('Space toggle');
expect(out).toContain('1 installed');
});
it('ignores Left/Right arrows in the overview (no enter/exit by arrow)', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 2,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
source: 'local-path',
},
],
onSelect,
onCancel,
});
picker.handleInput(RIGHT); // must NOT open details
expect(onSelect).not.toHaveBeenCalled();
picker.handleInput(LEFT); // must NOT cancel/exit
expect(onCancel).not.toHaveBeenCalled();
it('repaints from the current theme palette without remounting', () => {
const { panel } = makePanel({ installed: [superpowers] });
const previous = currentTheme.palette;
try {
currentTheme.setPalette(darkColors);
const darkOut = renderRaw(panel);
currentTheme.setPalette(lightColors);
const lightOut = renderRaw(panel);
// A palette snapshot cached at construction would render identically
// after the switch; reading currentTheme.palette at render time must
// produce different ANSI output for the same panel instance.
expect(darkOut).not.toBe(lightOut);
} finally {
currentTheme.setPalette(previous);
}
});
it('renders marketplace plugins separately from marketplace actions', () => {
const onSelect = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
description: 'Workflow skills',
source: 'https://example.com/superpowers.zip',
keywords: ['workflow'],
},
],
installed: new Map(),
source: '/tmp/marketplace.json',
onSelect,
onCancel: vi.fn(),
it('toggles an installed plugin with Space', () => {
const { panel, onSelect } = makePanel({ installed: [superpowers] });
panel.handleInput(' ');
expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false });
});
it('routes D / M / R / Enter to remove / mcp / reload / details on the Installed tab', () => {
const { panel, onSelect } = makePanel({ installed: [superpowers] });
panel.handleInput('d');
panel.handleInput('m');
panel.handleInput('r');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'superpowers' });
expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'superpowers' });
expect(onSelect).toHaveBeenCalledWith({ kind: 'reload' });
expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' });
});
it('renders the inline plugin hint on the installed row', () => {
const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 };
const { panel } = makePanel({
installed: [datasource],
selectedId: 'kimi-datasource',
pluginHint: { id: 'kimi-datasource', text: 'pending /new' },
});
const out = strip(renderRaw(panel));
expect(out).toContain('? Kimi Datasource enabled pending /new');
});
const raw = renderRaw(picker);
const out = strip(raw);
expect(out).toContain('Marketplace (1)');
expect(out).toContain('? Superpowers install v5.1.0');
expect(out).toContain(
`Workflow skills ${MID} id superpowers ${MID} Curated plugin ${MID} workflow`,
);
expect(out).toContain('Enter install/update');
expect(out).toContain('Actions');
expect(out).toContain('Back to installed plugins');
it('lazily loads the Official catalog, then lists installed entries first', () => {
const { panel, onRequestMarketplace } = makePanel({ installed: [superpowers] });
panel.handleInput('\t'); // → Official
expect(onRequestMarketplace).toHaveBeenCalledTimes(1);
expect(strip(renderRaw(panel))).toContain('Loading marketplace');
picker.handleInput('\r');
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi Datasource install');
expect(out).toContain('0 installed · 1 available');
});
it('installs the selected Third-party entry on Enter', () => {
const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'superpowers' }),
});
});
it('installs only on Enter, not Space, in the marketplace', () => {
const onSelect = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
description: 'Workflow skills',
source: 'https://example.com/superpowers.zip',
keywords: ['workflow'],
},
],
installed: new Map(),
source: '/tmp/marketplace.json',
onSelect,
onCancel: vi.fn(),
});
picker.handleInput(' '); // Space must NOT install
expect(onSelect).not.toHaveBeenCalled();
picker.handleInput('\r'); // Enter installs
it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => {
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
// Catalog still loading (entries empty); pressing ↓ must not drive the
// selection negative, or the later Enter would read entries[-1].
panel.handleInput('\u001b[B'); // ↓
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'superpowers' }),
});
});
it('ignores the Left arrow in the marketplace view (Esc returns instead)', () => {
const onCancel = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
description: 'Workflow skills',
source: 'https://example.com/superpowers.zip',
keywords: ['workflow'],
},
],
installed: new Map(),
source: '/tmp/marketplace.json',
onSelect: vi.fn(),
onCancel,
});
picker.handleInput(LEFT); // must NOT return to the overview
expect(onCancel).not.toHaveBeenCalled();
it('shows untiered marketplace entries on the Third-party tab', () => {
const untiered = [
{ id: 'custom-plugin', displayName: 'Custom Plugin', source: 'https://x/c.zip' },
];
const { panel } = makePanel({ initialTab: 'third-party' });
panel.setMarketplace(untiered, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Custom Plugin install');
});
it('issues install for installed marketplace entries (update path)', () => {
const onSelect = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
displayName: 'Superpowers',
source: 'https://example.com/superpowers.zip',
},
],
installed: new Map([['superpowers', undefined]]),
source: '/tmp/marketplace.json',
onSelect,
onCancel: vi.fn(),
});
it('shows an update badge when the marketplace version is newer than installed', () => {
const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }];
const entries = [
{
id: 'superpowers',
tier: 'curated' as const,
displayName: 'Superpowers',
version: '5.0.0',
source: 'https://x/s.zip',
},
];
const { panel } = makePanel({ installed, initialTab: 'third-party' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Superpowers update 4.0.0 → 5.0.0');
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('? Superpowers installed');
expect(out).toContain(`Plugin ${MID} id superpowers`);
it('shows installed · v<version> when the installed plugin is up to date', () => {
const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }];
const entries = [
{
id: 'superpowers',
tier: 'curated' as const,
displayName: 'Superpowers',
version: '5.0.0',
source: 'https://x/s.zip',
},
];
const { panel } = makePanel({ installed, initialTab: 'third-party' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Superpowers installed · v5.0.0');
});
picker.handleInput('\r');
it('shows an inline error when the Official catalog fails', () => {
const { panel } = makePanel({ installed: [superpowers] });
panel.handleInput('\t'); // → Official
panel.setMarketplaceError('fetch failed');
const out = strip(renderRaw(panel));
expect(out).toContain('Marketplace unavailable: fetch failed');
expect(out).toContain('Use the Custom tab');
});
it('installs from a URL typed on the Custom tab', () => {
const { panel, onSelect } = makePanel({ initialTab: 'custom' });
const out = strip(renderRaw(panel));
expect(out).toContain('Install from a GitHub URL');
expect(out).toContain('╭');
for (const ch of 'https://github.com/owner/repo') {
panel.handleInput(ch);
}
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'superpowers' }),
kind: 'install-source',
source: 'https://github.com/owner/repo',
});
});
it('shows an update badge when the installed version is older than the marketplace', () => {
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
source: 'https://example.com/superpowers.zip',
},
],
installed: new Map([['superpowers', '5.0.0']]),
source: '/tmp/marketplace.json',
onSelect: vi.fn(),
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('? Superpowers update 5.0.0 → 5.1.0');
});
it('shows installed with the version when already up to date', () => {
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
source: 'https://example.com/superpowers.zip',
},
],
installed: new Map([['superpowers', '5.1.0']]),
source: '/tmp/marketplace.json',
onSelect: vi.fn(),
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain(`? Superpowers installed ${MID} v5.1.0`);
});
it('toggles an installed plugin from the overview with space', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
},
],
onSelect,
onCancel: vi.fn(),
});
picker.handleInput(' ');
expect(onSelect).toHaveBeenCalledWith({
kind: 'toggle',
id: 'kimi-datasource',
enabled: false,
});
});
it('issues a remove request from the overview on D', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
},
],
onSelect,
onCancel: vi.fn(),
});
picker.handleInput('d');
expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'kimi-datasource' });
});
it('opens MCP server management from the overview on M', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
source: 'local-path',
},
],
onSelect,
onCancel: vi.fn(),
});
picker.handleInput('m');
expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'kimi-datasource' });
});
it('toggles MCP servers from the MCP selector', () => {
const selections: PluginMcpSelection[] = [];
const picker = new PluginMcpSelectorComponent({
@ -448,33 +344,6 @@ describe('plugins selector dialogs', () => {
]);
});
it('renders plugin action hints inline on the overview row', () => {
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
},
],
selectedId: 'kimi-datasource',
pluginHint: { id: 'kimi-datasource', text: 'pending /new' },
onSelect: vi.fn(),
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('? Kimi Datasource enabled pending /new');
});
it('defaults plugin removal confirmation to cancel', () => {
const results: PluginRemoveConfirmResult[] = [];
const picker = new PluginRemoveConfirmComponent({
@ -505,7 +374,7 @@ describe('plugins selector dialogs', () => {
},
});
picker.handleInput('');
picker.handleInput('\u001b[B');
const raw = renderRaw(picker);
expect(strip(raw)).toContain('Enter/Space select');
// The destructive option label keeps its danger styling (error + bold).

View file

@ -3,7 +3,8 @@ import chalk from 'chalk';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';
import { darkColors } from '#/tui/theme/colors';
import { currentTheme } from '#/tui/theme';
import { darkColors, lightColors } from '#/tui/theme/colors';
const ESC = String.fromCodePoint(27);
const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g');
@ -44,12 +45,15 @@ function make(): {
describe('TabbedModelSelectorComponent', () => {
let previousLevel: typeof chalk.level;
const previousPalette = currentTheme.palette;
beforeAll(() => {
previousLevel = chalk.level;
chalk.level = 3;
currentTheme.setPalette(darkColors);
});
afterAll(() => {
chalk.level = previousLevel;
currentTheme.setPalette(previousPalette);
});
it('renders an "All" + per-provider tab strip', () => {
@ -66,6 +70,25 @@ describe('TabbedModelSelectorComponent', () => {
expect(raw).toContain(PRIMARY_BG);
});
it('repaints the tab strip from the current theme palette without remounting', () => {
const { component } = make();
const stripLine = (lines: string[]): string =>
lines.find((l) => l.includes('All') && l.includes('openai')) ?? '';
const previous = currentTheme.palette;
try {
currentTheme.setPalette(darkColors);
const darkStrip = stripLine(component.render(120));
currentTheme.setPalette(lightColors);
const lightStrip = stripLine(component.render(120));
// The strip is drawn from currentTheme.palette at render time; a
// construction-time palette snapshot would render the same strip after
// the switch.
expect(darkStrip).not.toBe(lightStrip);
} finally {
currentTheme.setPalette(previous);
}
});
it('opens on the All tab by default (showing every provider\'s models)', () => {
const out = strip(make().component.render(120).join('\n'));
expect(out).toContain('Kimi K2');

View file

@ -24,9 +24,8 @@ import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-mo
import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
PluginsPanelComponent,
} from '#/tui/components/dialogs/plugins-selector';
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import type { StreamingUIController } from '#/tui/controllers/streaming-ui';
@ -43,8 +42,6 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => {
return { ...actual, promptFeedbackInput: vi.fn() };
});
vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() }));
const ESC = String.fromCodePoint(0x1b);
const BEL = String.fromCodePoint(0x07);
@ -173,12 +170,14 @@ function makeSession(overrides: Record<string, unknown> = {}) {
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
})),
setPluginEnabled: vi.fn(async () => {}),
setPluginMcpServerEnabled: vi.fn(async () => {}),
removePlugin: vi.fn(async () => {}),
reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })),
reloadSession: vi.fn(async () => ({})),
activateSkill: vi.fn(async () => {}),
getPluginInfo: vi.fn(async (id: string) => ({
id,
displayName: id,
@ -3089,6 +3088,7 @@ command = "vim"
plugins: [
{
id: 'kimi-datasource',
tier: 'official',
displayName: 'Kimi Datasource',
description: 'Datasource plugin',
source: './kimi-datasource',
@ -3104,12 +3104,14 @@ command = "vim"
driver.handleUserInput('/plugins marketplace');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginMarketplaceSelectorComponent,
);
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent;
picker.handleInput('\r');
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
// Official loads its catalog lazily; wait for the entry to render before install.
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
});
panel.handleInput('\r');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource'));
@ -3119,6 +3121,31 @@ command = "vim"
expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...');
expect(transcript).toContain('Installed or updated Demo');
});
// Installing closes the panel so the success notice / reload tip is visible.
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor);
});
});
it('removes a plugin record without auto-running any cleanup skill', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins remove kimi-webbridge');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginRemoveConfirmComponent,
);
});
const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent;
confirm.handleInput('\u001B[B');
confirm.handleInput('\r');
await vi.waitFor(() => {
expect(session.removePlugin).toHaveBeenCalledWith('kimi-webbridge');
});
expect(session.activateSkill).not.toHaveBeenCalled();
});
it('installs default marketplace entries through plain install', async () => {
@ -3141,12 +3168,13 @@ command = "vim"
driver.handleUserInput('/plugins marketplace');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginMarketplaceSelectorComponent,
);
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent;
picker.handleInput('\r');
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
});
panel.handleInput('\r');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith(
@ -3159,7 +3187,41 @@ command = "vim"
}
});
it('toggles plugins from the overview with space', async () => {
it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => {
const originalFetch = globalThis.fetch;
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json';
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('fetch failed');
}),
);
const session = makeSession();
const { driver } = await makeDriver(session);
try {
driver.handleUserInput('/plugins');
// The panel opens immediately on the Installed tab — no marketplace fetch.
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
panel.handleInput('\t'); // → Official, which lazily (and unsuccessfully) loads
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain(
'Marketplace unavailable: fetch failed',
);
});
// The panel stays mounted; the failure does not close /plugins.
expect(driver.state.editorContainer.children[0]).toBe(panel);
} finally {
vi.stubGlobal('fetch', originalFetch);
}
});
it('toggles plugins from the Installed tab with space', async () => {
let enabled = true;
const session = makeSession({
listPlugins: vi.fn(async () => [
@ -3173,6 +3235,7 @@ command = "vim"
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
},
]),
setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => {
@ -3184,31 +3247,25 @@ command = "vim"
driver.handleUserInput('/plugins');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent;
overview.handleInput(' ');
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
panel.handleInput(' ');
// Toggling refreshes the picker in place: it must not flash back to the
// editor between the keypress and the refreshed picker mounting.
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
// Toggling refreshes the panel in place: it must not flash back to the
// editor between the keypress and the refreshed panel mounting.
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
await vi.waitFor(() => {
expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false);
});
// The picker stays mounted the whole time (no editor flash), so wait for the
// refreshed render rather than for an instance swap.
await vi.waitFor(() => {
const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(refreshed).toContain(' Demo disabled require run /new or /reload to apply');
expect(refreshed).toContain(' Demo disabled run /reload or /new to apply');
});
const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(out).not.toContain('Space enable');
expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new or /reload to apply.');
expect(stripSgr(renderTranscript(driver))).not.toContain(
'Disabled demo. Run /reload or /new to apply.',
);
});
it('toggles plugin MCP servers from the overview MCP picker', async () => {
@ -3272,12 +3329,10 @@ command = "vim"
driver.handleUserInput('/plugins');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent;
overview.handleInput('m');
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
panel.handleInput('m');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
@ -3299,9 +3354,9 @@ command = "vim"
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent);
});
const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(out).toContain(' data disabled require run /new or /reload to apply');
expect(out).toContain(' data disabled run /reload or /new to apply');
expect(stripSgr(renderTranscript(driver))).not.toContain(
'Disabled MCP server data for kimi-datasource. Run /new or /reload to apply.',
'Disabled MCP server data for kimi-datasource. Run /reload or /new to apply.',
);
});

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import chalk from 'chalk';
import { darkColors } from '#/tui/theme/colors';
import { renderTabStrip } from '#/tui/utils/tab-strip';
const ANSI_SGR = /\u001b\[[0-9;]*m/g;
function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '');
}
function render(labels: readonly string[], width: number, activeIndex = 0): string {
const previousChalkLevel = chalk.level;
chalk.level = 3;
try {
return strip(renderTabStrip({ labels, activeIndex, width, colors: darkColors }));
} finally {
chalk.level = previousChalkLevel;
}
}
describe('renderTabStrip', () => {
const labels = ['Installed', 'Official', 'Third-party', 'Custom'];
// Cell widths: ` ${label} ` → 11 / 10 / 13 / 8 = 42, plus 3 separators and a
// leading space → 46 columns total.
const FULL_WIDTH = 46;
it('shows the full strip when it exactly fits', () => {
const out = render(labels, FULL_WIDTH);
expect(out).toContain('Installed');
expect(out).toContain('Custom');
expect(out).not.toContain('<');
expect(out).not.toContain('>');
});
it('scrolls (shows markers) when one column narrower than full fit', () => {
const out = render(labels, FULL_WIDTH - 1, 0);
expect(out).toContain('>');
expect(out).not.toContain('Custom');
});
it('does not truncate the last tab when separators just barely fit', () => {
// Regression: the old fit check summed only cell widths and ignored the
// three inter-tab spaces, so at 4345 columns it declared a fit while the
// joined line was wider and the trailing tab got truncated.
const out = render(labels, FULL_WIDTH);
expect(out.endsWith(' Custom ')).toBe(true);
});
});

View file

@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
import {
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
} from '#/constant/app';
import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace';
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
@ -179,6 +182,100 @@ describe('loadPluginMarketplace', () => {
);
});
it('falls back to the source checkout marketplace when the default CDN cannot be fetched', async () => {
const previous = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV];
delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV];
const fetchImpl = vi.fn(async () => {
throw new Error('fetch failed');
}) as unknown as typeof fetch;
try {
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl });
expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL);
expect(marketplace.source).toBe(join(REPO_ROOT, 'plugins/marketplace.json'));
expect(marketplace.plugins).toContainEqual(
expect.objectContaining({
id: 'superpowers',
source: join(REPO_ROOT, 'plugins/curated/superpowers'),
}),
);
} finally {
if (previous === undefined) {
delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV];
} else {
process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] = previous;
}
}
});
it('does not use the source checkout fallback for explicit marketplace sources', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('fetch failed');
}) as unknown as typeof fetch;
await expect(loadPluginMarketplace({
workDir: '/tmp/work',
source: KIMI_CODE_PLUGIN_MARKETPLACE_URL,
fetchImpl,
})).rejects.toThrow(/fetch failed/);
});
it('accepts legacy marketplace type aliases as normal plugins', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({
plugins: [
{
id: 'kimi-webbridge',
type: 'guide',
displayName: 'Kimi WebBridge',
source: './kimi-webbridge',
installSkill: 'install',
removeSkill: 'remove',
},
{
id: 'demo-managed',
type: 'managed',
source: './demo-managed',
},
],
}),
'utf8',
);
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file });
expect(marketplace.plugins).toContainEqual(
expect.objectContaining({
id: 'kimi-webbridge',
source: join(dir, 'kimi-webbridge'),
}),
);
expect(marketplace.plugins).toContainEqual(
expect.objectContaining({
id: 'demo-managed',
source: join(dir, 'demo-managed'),
}),
);
});
it('rejects an entry without a source', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({ plugins: [{ id: 'broken', displayName: 'Broken' }] }),
'utf8',
);
await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow(
/must define "source"/,
);
});
it('loads an explicit remote marketplace with injectable fetch', async () => {
const source = 'https://example.com/plugins/marketplace.json';
const fetchImpl = vi.fn(async () => ({
@ -227,4 +324,21 @@ describe('loadPluginMarketplace', () => {
/"tier" must be one of/,
);
});
it('rejects unknown marketplace entry types', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({
plugins: [{ id: 'demo', type: 'integration', source: './demo' }],
}),
'utf8',
);
await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow(
/Legacy aliases "managed" and "guide" are also accepted/,
);
});
});

View file

@ -124,7 +124,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` |
| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy |

View file

@ -6,16 +6,17 @@ Kimi Code CLI applies a conservative loading strategy for plugins: installing a
## Installation and Management
Run `/plugins` in the TUI to open the plugin manager, where you can perform all routine operations. Common keys:
Run `/plugins` in the TUI to open the plugin manager. It is a single panel with four tabs — **Installed** (manage what you have), **Official** (Kimi-maintained marketplace plugins), **Third-party** (marketplace plugins from other publishers), and **Custom** (install from a URL) — switched with `Tab` / `Shift-Tab`. Common keys:
| Key | Action |
| --- | --- |
| `Enter` or `→` | Open the selected item, or install a marketplace plugin |
| `Space` | Enable or disable an installed plugin; install or update a marketplace plugin |
| `M` | Manage MCP servers for the selected plugin |
| `←` or `Esc` | Go back to the previous level |
In the marketplace list, an installed plugin with a newer version available shows `update <local> → <latest>`, an up-to-date one shows `installed · v<version>`, and an uninstalled one shows `install v<version>`. Select an updatable entry and press `Enter` to update.
| `Tab` / `Shift-Tab` | Switch between the Installed / Official / Third-party / Custom tabs |
| `Space` | Enable or disable the selected installed plugin (Installed tab) |
| `D` | Remove the selected installed plugin (Installed tab) |
| `M` | Manage MCP servers for the selected plugin (Installed tab) |
| `R` | Reload `installed.json` and all manifests (Installed tab) |
| `Enter` | Installed tab: view plugin details · Official/Third-party tab: install or update · Custom tab: install |
| `Esc` | Go back or cancel |
You can also use slash commands directly:
@ -24,7 +25,7 @@ You can also use slash commands directly:
| `/plugins` | Open the interactive plugin manager |
| `/plugins list` | List installed plugins |
| `/plugins install <path-or-url>` | Install from a local directory, zip URL, or GitHub repository URL |
| `/plugins marketplace [source]` | Browse the official marketplace; optionally pass a path or URL to a marketplace JSON |
| `/plugins marketplace [source]` | Browse the official marketplace, or pass a custom marketplace JSON path or URL |
| `/plugins info <id>` | View plugin details and diagnostics |
| `/plugins enable <id>` | Enable a plugin |
| `/plugins disable <id>` | Disable a plugin |
@ -33,7 +34,7 @@ You can also use slash commands directly:
| `/plugins mcp enable <id> <server>` | Enable an MCP server declared by a plugin |
| `/plugins mcp disable <id> <server>` | Disable an MCP server declared by a plugin |
The plugin manager shows the installation source and a trust badge for each install: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else).
The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load when you switch to those tabs. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else).
### Installing from GitHub
@ -48,11 +49,28 @@ Network requests only go through `github.com` redirects and `codeload.github.com
### Notes
- Plugin changes only take effect for new sessions. After installing, enabling/disabling, or removing a plugin, run `/reload` to reload plugins or `/new` to start a new session; the current session will not update.
- Plugin changes apply after `/reload` or in new sessions. After installing, enabling/disabling, or removing a plugin, run `/reload` or `/new`; the current session will not update.
- Local installations are copied to `$KIMI_CODE_HOME/plugins/managed/<id>/`, and the CLI always runs from this managed copy. Editing the original source directory after installation has no effect; you must reinstall.
- Removing a plugin only deletes the installation record; the managed copy and original source files remain on disk.
- Plugins are currently installed per-user and apply to all projects; project-level installation scope is not yet supported.
### Custom marketplace JSON
Pass a custom marketplace JSON path or URL to `/plugins marketplace <source>`, or set [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) to override the default catalog. Each entry in the `plugins` array needs an `id` and a `source` (local path, zip URL, or GitHub URL):
```json
{
"version": "2",
"plugins": [
{
"id": "my-plugin",
"displayName": "My Plugin",
"source": "./my-plugin"
}
]
}
```
## Kimi Datasource
Kimi Datasource is the official Kimi Code data plugin. It lets you query financial market data, macroeconomic indicators, corporate registration records, academic literature, and Chinese laws and regulations in natural language — no manual API calls or data account registration required.
@ -61,17 +79,17 @@ Kimi Datasource is the official Kimi Code data plugin. It lets you query financi
You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services.
1. Run `/plugins` and select **Marketplace**
2. Find **Kimi Datasource** and press `Space` to install
3. After installation completes, run `/reload` to activate the plugin
1. Run `/plugins` and select **Official**
2. Find **Kimi Datasource** and press `Enter` to install
3. After installation completes, run `/reload` or `/new` to activate the plugin
The current latest version is v3.2.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above.
### How to Use
### How to use
Once installed, describe your need in natural language and Kimi Code will automatically invoke the data capabilities. You can also explicitly trigger the data query skill with `/skill:kimi-datasource`.
### What You Can Do
### What you can do
**Live market research**: Want to run a quantitative analysis on a stock? Pull three years of daily closing prices, MACD, and KDJ signals in a single query — no third-party data platforms needed.
@ -93,7 +111,7 @@ Once installed, describe your need in natural language and Kimi Code will automa
| Academic literature | Millions of papers across physics, mathematics, CS, quantitative finance, economics — including preprints |
| Legal | Chinese laws, regulations, and judicial cases — semantic/keyword search and detail lookup for statutes across all authority levels (constitution, laws, judicial interpretations, departmental rules), plus ordinary and authoritative case search |
### Notes
### Billing and limitations
- Data queries are billed per call and consume Kimi Code account credits
- The plugin provides read-only queries; no write or trading functionality is available
@ -192,14 +210,14 @@ HTTP server (remote service):
For stdio servers, `command` can be a command on `PATH` or a path starting with `./` within the plugin root directory. `cwd` likewise must start with `./` and be within the plugin root directory; otherwise the server is ignored.
Plugin MCP servers only start in new sessions. To enable or disable a server:
Plugin MCP servers start after `/reload` or in new sessions. To enable or disable a server:
```sh
/plugins mcp disable kimi-finance finance
/new
/reload
/plugins mcp enable kimi-finance finance
/new
/reload
```
## Security Model
@ -208,10 +226,6 @@ Plugins have a limited loading scope. The following operations do not occur duri
- Command-type plugin tools, hooks, and legacy tool runtimes are not executed
- All paths must remain within the plugin root directory after symbolic link resolution
- MCP servers of enabled plugins only start in new sessions and can be disabled at any time from `/plugins`
- MCP servers of enabled plugins start after `/reload` or in new sessions and can be disabled at any time from `/plugins`
- Broken manifests or unsafe paths appear in `/plugins info <id>` diagnostics and do not affect other sessions
## Next steps
- [Agent Skills](./skills.md) — File format and frontmatter field reference for Skills
- [MCP](./mcp.md) — Full schema and permission configuration for plugin MCP servers

View file

@ -124,7 +124,7 @@ kimi
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 替换 `/plugins` 加载的 marketplace JSON | URL 或本地路径 |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能;`micro_compaction` 已默认开启 | `1``true``yes``on` |
| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 [`[experimental].micro_compaction`](./config-files.md#experimental) | 真值或假值 |

View file

@ -6,16 +6,17 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
## 安装与管理
在 TUI 中运行 `/plugins` 打开 plugin 管理器,可以在这里完成所有日常操作。常用按键:
在 TUI 中运行 `/plugins` 打开 plugin 管理器。它是一个面板,有四个 tab**Installed**(管理已装的)、**Official**Kimi 官方 marketplace plugin、**Third-party**(第三方 marketplace plugin、**Custom**(从 URL 安装),用 `Tab` / `Shift-Tab` 切换。常用按键:
| 按键 | 操作 |
| --- | --- |
| `Enter``→` | 打开选中项,或安装 marketplace 中的 plugin |
| `Space` | 启用或禁用已安装 plugin在 marketplace 中安装或更新 plugin |
| `M` | 管理选中 plugin 的 MCP servers |
| `←``Esc` | 返回上一层 |
在 marketplace 列表里,已安装且有新版本的 plugin 会显示 `update <本地版本> → <最新版本>`,已是最新显示 `installed · v<版本>`,未安装显示 `install v<版本>`。选中可更新的项按 `Enter` 即可更新。
| `Tab` / `Shift-Tab` | 在 Installed / Official / Third-party / Custom 四个 tab 间切换 |
| `Space` | 启用或禁用选中的已安装 pluginInstalled tab |
| `D` | 移除选中的已安装 pluginInstalled tab |
| `M` | 管理选中 plugin 的 MCP serversInstalled tab |
| `R` | 重新加载 `installed.json` 和所有 manifestInstalled tab |
| `Enter` | Installed tab查看 plugin 详情 · Official/Third-party tab安装或更新 · Custom tab安装 |
| `Esc` | 返回或取消 |
也可以直接使用斜杠命令:
@ -24,7 +25,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
| `/plugins` | 打开交互式 plugin 管理器 |
| `/plugins list` | 列出已安装 plugins |
| `/plugins install <path-or-url>` | 从本地目录、zip URL 或 GitHub 仓库 URL 安装 |
| `/plugins marketplace [source]` | 浏览官方 marketplace;可选传入 marketplace JSON 的路径或 URL |
| `/plugins marketplace [source]` | 浏览官方 marketplace,或传入自定义 marketplace JSON 的路径或 URL |
| `/plugins info <id>` | 查看 plugin 详情和 diagnostics |
| `/plugins enable <id>` | 启用 plugin |
| `/plugins disable <id>` | 禁用 plugin |
@ -33,7 +34,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server |
| `/plugins mcp disable <id> <server>` | 禁用 plugin 声明的 MCP server |
Plugin 管理器会展示每个安装的来源和信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。
**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin**Custom** tab 从 URL 安装。切到对应 tab 时才会加载 marketplace 目录。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。
### 从 GitHub 安装
@ -48,11 +49,28 @@ Plugin 管理器会展示每个安装的来源和信任徽章:`kimi-official`
### 注意事项
- Plugin 变更只对新会话生效。安装、启用/禁用、移除后,需通过 `/reload` 重载插件或通过 `/new` 开启新会话;当前会话不会更新。
- Plugin 变更需要通过 `/reload` 或新会话生效。安装、启用/禁用、移除后,运行 `/reload``/new`;当前会话不会更新。
- 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed/<id>/`CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。
- 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。
- Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。
### 自定义 marketplace JSON
浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace <source>`;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id``source`本地路径、zip URL 或 GitHub URL
```json
{
"version": "2",
"plugins": [
{
"id": "my-plugin",
"displayName": "My Plugin",
"source": "./my-plugin"
}
]
}
```
## Kimi Datasource
Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直接查询金融行情、宏观经济、企业工商、学术文献和中国法律法规,无需手动调用接口或申请任何数据账号。
@ -61,9 +79,9 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直
需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。
1. 运行 `/plugins`,选择 **Marketplace**
2. 找到 **Kimi Datasource**,按 `Space` 安装
3. 安装完成后运行 `/reload` 重载插件,即可使用
1. 运行 `/plugins`,选择 **Official**
2. 找到 **Kimi Datasource**,按 `Enter` 安装
3. 安装完成后运行 `/reload` `/new` 激活 plugin
当前最新版本为 v3.2.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。
@ -93,7 +111,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直
| 学术文献 | 物理、数学、计算机、金融、经济等领域百万量级论文,支持预印本查询 |
| 法律法规 | 中国法律法规与司法案例:宪法、法律、司法解释、部门规章等各效力层次的法规语义/关键词检索与详情,普通及权威判例检索 |
### 注意事项
### 计费与限制
- 数据查询按次计费,消耗 Kimi Code 账号额度
- 插件为只读查询,不提供任何写入或交易功能
@ -192,14 +210,14 @@ HTTP server远程服务
对于 stdio servers`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。`cwd` 同理,必须以 `./` 开头并位于 plugin 根目录内,否则该 server 会被忽略。
Plugin MCP servers 会在新会话中启动。启用或禁用某个 server
Plugin MCP servers 会在 `/reload` 后或新会话中启动。启用或禁用某个 server
```sh
/plugins mcp disable kimi-finance finance
/new
/reload
/plugins mcp enable kimi-finance finance
/new
/reload
```
## 安全模型
@ -208,10 +226,6 @@ Plugin 的加载范围有限,以下操作不会在安装或会话启动时发
- 不会执行命令型 plugin tools、hooks 或旧式工具运行时
- 所有路径在解析符号链接后仍必须位于 plugin 根目录内
- 已启用 plugin 的 MCP servers 只在新会话中启动,且可随时从 `/plugins` 禁用
- 已启用 plugin 的 MCP servers 会在 `/reload` 后或新会话中启动,且可随时从 `/plugins` 禁用
- 损坏的 manifest 或不安全路径会显示在 `/plugins info <id>` 的 diagnostics 中,不影响其他会话
## 下一步
- [Agent Skills](./skills.md) — Skills 的文件格式与 frontmatter 字段参考
- [MCP](./mcp.md) — Plugin MCP servers 的完整 schema 与权限配置