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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/plugin-marketplace-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
191 changes: 97 additions & 94 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +28,8 @@ interface ShowPluginsPickerOptions {
readonly id: string;
readonly text: string;
};
readonly initialTab?: PluginsPanelTabId;
readonly marketplaceSource?: string;
}

interface PluginMcpServerHint {
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
}
Expand All @@ -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') {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
}
Loading
Loading