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/polite-plugin-ui-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Clarify plugin manager keyboard shortcuts and show plugin state changes inline.
53 changes: 43 additions & 10 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ interface ShowPluginsPickerOptions {
};
}

interface PluginMcpServerHint {
readonly server: string;
readonly text: string;
}

interface ShowPluginMcpPickerOptions {
readonly selectedServer?: string;
readonly serverHint?: PluginMcpServerHint;
}

export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise<void> {
const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0);
const sub = args[0];
Expand Down Expand Up @@ -179,7 +189,11 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri
}
}

async function showPluginMcpPicker(host: SlashCommandHost, id: string): Promise<void> {
async function showPluginMcpPicker(
host: SlashCommandHost,
id: string,
options?: ShowPluginMcpPickerOptions,
): Promise<void> {
let info: PluginInfo;
try {
info = await host.requireSession().getPluginInfo(id);
Expand All @@ -191,6 +205,8 @@ async function showPluginMcpPicker(host: SlashCommandHost, id: string): Promise<
host.mountEditorReplacement(
new PluginMcpSelectorComponent({
info,
selectedServer: options?.selectedServer,
serverHint: options?.serverHint,
colors: host.state.theme.colors,
onSelect: (selection) => {
host.restoreEditor();
Expand Down Expand Up @@ -229,7 +245,12 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<
});
}

async function applyPluginEnabled(host: SlashCommandHost, id: string, enabled: boolean): Promise<void> {
async function applyPluginEnabled(
host: SlashCommandHost,
id: string,
enabled: boolean,
showStatus = true,
): Promise<string> {
const session = host.requireSession();
await session.setPluginEnabled(id, enabled);
let info: PluginInfo | undefined;
Expand All @@ -242,7 +263,11 @@ async function applyPluginEnabled(host: SlashCommandHost, id: string, enabled: b
enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
: '';
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
if (showStatus) {
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
}
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
}

async function handlePluginsOverviewSelection(
Expand All @@ -261,13 +286,14 @@ async function handlePluginsOverviewSelection(
case 'show-list':
await renderPluginsList(host);
return;
case 'toggle':
await applyPluginEnabled(host, selection.id, selection.enabled);
case 'toggle': {
const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false);
await showPluginsPicker(host, {
selectedId: selection.id,
pluginHint: { id: selection.id, text: 'saved · /new to apply' },
pluginHint: { id: selection.id, text: hint },
});
return;
}
case 'mcp':
await showPluginMcpPicker(host, selection.id);
return;
Expand Down Expand Up @@ -298,10 +324,13 @@ async function handlePluginMcpSelection(
selection.server,
selection.enabled,
);
host.showStatus(
`${selection.enabled ? 'Enabled' : 'Disabled'} MCP server ${selection.server} for ${selection.pluginId}. Run /new to apply.`,
);
await showPluginMcpPicker(host, selection.pluginId);
await showPluginMcpPicker(host, selection.pluginId, {
selectedServer: selection.server,
serverHint: {
server: selection.server,
text: pluginInlineChangeHint(),
},
});
Comment on lines +327 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the toggled MCP server selection

When toggling an MCP server other than the first one, this remounts the MCP picker with only a serverHint; PluginMcpSelectorComponent still initializes selectedIndex to 0, so focus jumps back to the first server while the inline success text appears on the toggled row. Because this change also removes the transcript status for interactive toggles, a user who presses Space again expecting to undo the same server can silently toggle the first server instead. Pass the toggled server through as the selected item, similar to the overview picker's selectedId, before remounting.

Useful? React with 👍 / 👎.

return;
case 'back':
await showPluginsPicker(host, { selectedId: selection.pluginId });
Expand Down Expand Up @@ -389,3 +418,7 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2));
return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed);
}

function pluginInlineChangeHint(): string {
return 'pending /new';
}
22 changes: 20 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ export interface ChoiceOption {
readonly value: string;
/** Display text shown in the list. */
readonly label: string;
/** Optional semantic tone for labels that need stronger visual treatment. */
readonly tone?: 'danger';
/** Optional explanatory text shown below the label. */
readonly description?: string | undefined;
}

export interface ChoicePickerOptions {
readonly title: string;
readonly hint?: string;
readonly formatHint?: (text: string, colors: ColorPalette) => string;
readonly notice?: string;
readonly options: readonly ChoiceOption[];
readonly currentValue?: string;
Expand Down Expand Up @@ -136,7 +139,11 @@ export class ChoicePickerComponent extends Container implements Focusable {
if (searchable && view.query.length > 0) {
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query));
}
lines.push(chalk.hex(colors.textMuted)(` ${hint}`));
lines.push(
this.opts.formatHint === undefined
? chalk.hex(colors.textMuted)(` ${hint}`)
: this.opts.formatHint(` ${hint}`, colors),
);
if (this.opts.notice !== undefined) {
lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`));
}
Expand All @@ -150,7 +157,7 @@ export class ChoicePickerComponent extends Container implements Focusable {
const isSelected = i === view.selectedIndex;
const isCurrent = opt.value === this.opts.currentValue;
const pointer = isSelected ? '❯' : ' ';
const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const labelStyle = optionLabelStyle(opt, isSelected, colors);
let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `);
line += labelStyle(opt.label);
if (isCurrent) {
Expand All @@ -177,3 +184,14 @@ export class ChoicePickerComponent extends Container implements Focusable {
return lines.map((line) => truncateToWidth(line, width));
}
}

function optionLabelStyle(
option: ChoiceOption,
selected: boolean,
colors: ColorPalette,
): (text: string) => string {
if (option.tone === 'danger') {
return selected ? chalk.hex(colors.error).bold : chalk.hex(colors.error);
}
return selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
}
66 changes: 51 additions & 15 deletions apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,13 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus
override render(width: number): string[] {
const { colors, plugins } = this.opts;
const hint =
'↑↓ navigate · Space enable/disable · M MCP · D remove · Enter/→ info · ←/Esc close';
'↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc close';
const pluginItems = this.items.filter((item) => item.kind === 'plugin');
const actionItems = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Plugins'),
chalk.hex(colors.textMuted)(` ${hint}`),
pluginShortcutHint(` ${hint}`, colors),
'',
sectionLabel(`Installed plugins (${plugins.length})`, colors),
];
Expand Down Expand Up @@ -171,7 +171,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(chalk.hex(colors.textMuted)(` ${descLine}`));
lines.push(pluginShortcutHint(` ${descLine}`, colors));
}
return lines;
}
Expand Down Expand Up @@ -236,7 +236,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Official plugins'),
chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter/Space install/update · ←/Esc back'),
pluginShortcutHint(' ↑↓ navigate · Enter/Space install/update · ←/Esc back', colors),
chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`),
'',
sectionLabel(`Marketplace (${entries.length})`, colors),
Expand Down Expand Up @@ -274,7 +274,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(chalk.hex(colors.textMuted)(` ${descLine}`));
lines.push(pluginShortcutHint(` ${descLine}`, colors));
}
return lines;
}
Expand All @@ -286,6 +286,11 @@ export type PluginMcpSelection =

export interface PluginMcpSelectorOptions {
readonly info: PluginInfo;
readonly selectedServer?: string;
readonly serverHint?: {
readonly server: string;
readonly text: string;
};
readonly colors: ColorPalette;
readonly onSelect: (selection: PluginMcpSelection) => void;
readonly onCancel: () => void;
Expand All @@ -302,6 +307,10 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
super();
this.opts = opts;
this.items = buildMcpItems(opts.info);
const selectedIndex = this.items.findIndex(
(item) => item.value === `${MCP_SERVER_PREFIX}${opts.selectedServer}`,
);
this.selectedIndex = Math.max(0, selectedIndex);
}

handleInput(data: string): void {
Expand Down Expand Up @@ -344,7 +353,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`),
chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter/Space enable/disable · ←/Esc back'),
pluginShortcutHint(' ↑↓ navigate · Enter/Space enable/disable · ←/Esc back', colors),
'',
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors),
];
Expand Down Expand Up @@ -378,10 +387,14 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
if (item.status !== undefined) {
line += ' ' + statusStyle(item, colors)(item.status);
}
const serverName = mcpItemServerName(item);
if (serverName !== undefined && this.opts.serverHint?.server === serverName) {
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(chalk.hex(colors.textMuted)(` ${descLine}`));
lines.push(pluginShortcutHint(` ${descLine}`, colors));
}
return lines;
}
Expand All @@ -403,6 +416,7 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
super({
title: `Remove ${opts.displayName} (${opts.id})?`,
hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel',
formatHint: pluginShortcutHint,
options: [
{
value: REMOVE_CONFIRM_CANCEL,
Expand All @@ -412,6 +426,7 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
{
value: REMOVE_CONFIRM_REMOVE,
label: 'Remove plugin',
tone: 'danger',
description: 'Remove only the install record; plugin files are left in place.',
},
],
Expand All @@ -438,36 +453,34 @@ function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewI
{
value: OVERVIEW_MARKETPLACE,
kind: 'action',
label: 'Browse official marketplace',
description: 'Install official plugins from marketplace.json.',
label: 'Marketplace',
description: 'Browse official plugins.',
},
{
value: OVERVIEW_RELOAD,
kind: 'action',
label: 'Reload plugins',
description: 'Re-read installed.json and plugin manifests.',
label: 'Reload',
description: 'Re-read installed plugins and manifests.',
},
{
value: OVERVIEW_SHOW_LIST,
kind: 'action',
label: 'Show plugin summary',
label: 'Summary',
description: 'Append the current plugin summary to the transcript.',
},
);
return options;
}

function overviewPluginDescription(plugin: PluginSummary): string {
const mcpShortcut = plugin.mcpServerCount > 0 ? ' · M MCP' : '';
const shortcut = `Space ${plugin.enabled ? 'disable' : 'enable'}${mcpShortcut} · D remove · Enter info`;
const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`;
const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`;
const mcp =
plugin.mcpServerCount > 0
? ` · MCP ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount}`
: '';
const diagnostics = plugin.hasErrors ? ' · diagnostics available' : '';
return `${shortcut} · id ${plugin.id} · ${skills}${mcp}${state}${diagnostics}`;
return `id ${plugin.id} · ${skills}${mcp}${state}${diagnostics}`;
}

function pluginStatus(plugin: PluginSummary): string {
Expand Down Expand Up @@ -579,6 +592,29 @@ function statusStyle(
return chalk.hex(colors.warning);
}

function pluginShortcutHint(text: string, colors: ColorPalette): string {
const shortcutPattern = /D(?= remove)|M(?= MCP)|Space|Enter|Esc|[←→↑↓]/gu;
let output = '';
let offset = 0;

for (const match of text.matchAll(shortcutPattern)) {
const index = match.index;
if (index === undefined) continue;
const token = match[0];
output += chalk.hex(colors.textMuted)(text.slice(offset, index));
output += shortcutTokenStyle(token, colors)(token);
offset = index + token.length;
}

output += chalk.hex(colors.textMuted)(text.slice(offset));
return output;
}

function shortcutTokenStyle(token: string, colors: ColorPalette): (text: string) => string {
if (token === 'D') return chalk.hex(colors.error).bold;
return chalk.hex(colors.primary).bold;
}

function wrapOverviewDescription(text: string, width: number): string[] {
const maxWidth = Math.max(1, width);
const words = text
Expand Down
Loading
Loading