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
5 changes: 5 additions & 0 deletions .changeset/installed-plugin-update-badges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ coverage/
.conductor
.kimi-stash-dir
plugins/cdn/
superpowers
.worktrees/
.kimi-code/local.toml

Expand Down
1 change: 0 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@
"node_modules/",
"apps/*/scripts/",
"docs/smoke-archive/",
"plugins/curated/superpowers/",
"*.generated.ts"
]
}
68 changes: 41 additions & 27 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,22 +167,28 @@ async function showPluginsPicker(
// 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) => {
void handlePluginsPanelSelection(host, panel, 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.
// Every tab except Custom needs the catalog: Official/Third-party list it,
// and Installed uses it to show update badges. The Installed/Custom tabs
// keep working even when the marketplace is unreachable (badges simply stay
// hidden until data arrives).
onRequestMarketplace: () => {
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
},
});
host.mountEditorReplacement(panel);
if (options?.initialTab === 'official' || options?.initialTab === 'third-party') {
// Kick off the catalog fetch for any tab that needs it: Installed uses it for
// update badges, Official/Third-party list it. Custom never reads the catalog,
// so skip the fetch there. Done here (after `panel` is initialized) rather
// than inside the component constructor, because the callback above closes
// over `panel`.
if (options?.initialTab !== 'custom') {
panel.setMarketplaceLoading();
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
}
Expand Down Expand Up @@ -287,6 +293,7 @@ async function applyPluginEnabled(

async function handlePluginsPanelSelection(
host: SlashCommandHost,
panel: PluginsPanelComponent,
selection: PluginsPanelSelection,
): Promise<void> {
switch (selection.kind) {
Expand Down Expand Up @@ -320,18 +327,35 @@ async function handlePluginsPanelSelection(
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' });
panel.setInstalling(selection.entry.displayName);
host.state.ui.requestRender();
try {
await installPluginFromSource(host, selection.entry.source);
} catch (error) {
panel.clearInstalling();
host.state.ui.requestRender();
host.showError(`Failed to install ${selection.entry.displayName}: ${formatErrorMessage(error)}`);
return;
}
// 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' });
case 'install-source': {
panel.setInstalling(truncateForStatus(selection.source));
host.state.ui.requestRender();
try {
await installPluginFromSource(host, selection.source);
} catch (error) {
panel.clearInstalling();
host.state.ui.requestRender();
host.showError(`Failed to install from ${truncateForStatus(selection.source)}: ${formatErrorMessage(error)}`);
return;
}
host.restoreEditor();
return;
}
}
}

Expand Down Expand Up @@ -362,7 +386,8 @@ async function handlePluginMcpSelection(

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.`);
host.showStatus(`Removed ${id}.`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}

async function renderPluginsList(
Expand Down Expand Up @@ -394,25 +419,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<voi
async function installPluginFromSource(
host: SlashCommandHost,
source: string,
options?: {
readonly successNotice?: 'marketplace';
},
): Promise<void> {
const session = host.requireSession();
const beforeList = await session.listPlugins();
const summary = await session.installPlugin(
resolvePluginInstallSource(source, host.state.appState.workDir),
);
showPluginInstallResult(host, beforeList, summary, options);
showPluginInstallResult(host, beforeList, summary);
}

const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';

function showPluginInstallResult(
host: SlashCommandHost,
beforeList: readonly PluginSummary[],
summary: PluginSummary,
options?: {
readonly successNotice?: 'marketplace';
},
): void {
const previous = beforeList.find((entry) => entry.id === summary.id);
const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
Expand All @@ -421,15 +442,8 @@ function showPluginInstallResult(
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
: '';
const action = describeInstallAction(previous, summary);
host.showStatus(
`${action} (${summary.id}).${mcpHint} Run /new or /reload to apply plugin changes.`,
);
if (options?.successNotice === 'marketplace') {
host.showNotice(
`Installed or updated ${summary.displayName}`,
`Marketplace install or update succeeded for ${summary.id}. Run /new or /reload to apply plugin changes.`,
);
}
host.showStatus(`${action} (${summary.id}).${mcpHint}`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}

function describeInstallAction(
Expand Down
65 changes: 63 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
private activeTabIndex: number;
private selectedIndex = 0;
private market: MarketState = { status: 'idle' };
private installing: string | undefined;

constructor(opts: PluginsPanelOptions) {
super();
Expand Down Expand Up @@ -323,6 +324,16 @@ export class PluginsPanelComponent extends Container implements Focusable {
this.market = { status: 'error', message };
}

setInstalling(label: string): void {
this.installing = label;
this.invalidate();
}

clearInstalling(): void {
this.installing = undefined;
this.invalidate();
}

private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] {
return PLUGINS_PANEL_TABS[this.activeTabIndex]!;
}
Expand Down Expand Up @@ -351,7 +362,9 @@ export class PluginsPanelComponent extends Container implements Focusable {
}

private requestMarketplaceIfNeeded(): void {
if (this.market.status === 'idle' && this.activeTab.id !== 'installed' && this.activeTab.id !== 'custom') {
// The Installed tab also needs the catalog to render update badges; only the
// Custom tab (manual URL entry) can skip the fetch entirely.
if (this.market.status === 'idle' && this.activeTab.id !== 'custom') {
this.market = { status: 'loading' };
this.opts.onRequestMarketplace?.();
}
Expand Down Expand Up @@ -423,6 +436,16 @@ export class PluginsPanelComponent extends Container implements Focusable {
return;
}
if (matchesKey(data, Key.enter)) {
if (plugin === undefined) return;
const update = this.installedUpdateStatus(plugin);
if (update !== undefined) {
this.opts.onSelect({ kind: 'install', entry: update.entry });
} else {
this.opts.onSelect({ kind: 'details', id: plugin.id });
}
return;
}
if (ch === 'i' || ch === 'I') {
if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id });
}
}
Expand Down Expand Up @@ -452,11 +475,14 @@ export class PluginsPanelComponent extends Container implements Focusable {
}

override render(width: number): string[] {
if (this.installing !== undefined) {
return this.renderInstalling(width);
}
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'
? this.installedHint()
: tab === 'custom'
? ' Tab switch · Enter install · Esc cancel'
: ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel';
Expand Down Expand Up @@ -497,17 +523,39 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines.push(mutedHintLine(` ${installed.length} installed`, colors));
}

private installedHint(): string {
const plugin = this.opts.installed[this.selectedIndex];
const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined;
const enter = hasUpdate ? 'Enter update' : 'Enter details';
return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`;
}

private installedUpdateStatus(
plugin: PluginSummary,
): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined {
if (this.market.status !== 'loaded') return undefined;
const entry = this.market.entries.find((e) => e.id === plugin.id);
if (entry === undefined) return undefined;
const status = computeUpdateStatus(entry.version, plugin.version, true);
return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined;
}

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);
const update = this.installedUpdateStatus(plugin);
let line = prefix + labelStyle(plugin.displayName);
if (status !== undefined) {
line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status);
}
if (update !== undefined) {
const badge = `update ${update.local} → ${update.latest}`;
line += ' ' + marketplaceStatusStyle(badge, colors)(badge);
}
if (this.opts.pluginHint?.id === plugin.id) {
line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text);
}
Expand Down Expand Up @@ -580,6 +628,19 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines.push('');
lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors));
}

private renderInstalling(width: number): string[] {
const colors = currentTheme.palette;
const lines = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Plugins'),
'',
chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`),
'',
chalk.hex(colors.primary)('─'.repeat(width)),
];
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
}

function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
Expand Down
Loading
Loading