From e14eaf31f35cd9b47f7b4602fbca2322de68f788 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 28 May 2026 20:37:48 +0800 Subject: [PATCH 1/2] fix: polish plugin manager hints --- .changeset/polite-plugin-ui-hints.md | 5 ++ apps/kimi-code/src/tui/commands/plugins.ts | 44 +++++++++++++---- .../tui/components/dialogs/choice-picker.ts | 22 ++++++++- .../components/dialogs/plugins-selector.ts | 45 ++++++++++++++--- .../dialogs/plugins-selector.test.ts | 48 +++++++++++++++++-- .../test/tui/kimi-tui-message-flow.test.ts | 18 +++++-- 6 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 .changeset/polite-plugin-ui-hints.md diff --git a/.changeset/polite-plugin-ui-hints.md b/.changeset/polite-plugin-ui-hints.md new file mode 100644 index 0000000000..b97dde7825 --- /dev/null +++ b/.changeset/polite-plugin-ui-hints.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Clarify plugin manager keyboard shortcuts and show plugin state changes inline. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index d465b268db..efdd85607a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -30,6 +30,11 @@ interface ShowPluginsPickerOptions { }; } +interface PluginMcpServerHint { + readonly server: string; + readonly text: string; +} + export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise { const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; @@ -179,7 +184,11 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri } } -async function showPluginMcpPicker(host: SlashCommandHost, id: string): Promise { +async function showPluginMcpPicker( + host: SlashCommandHost, + id: string, + serverHint?: PluginMcpServerHint, +): Promise { let info: PluginInfo; try { info = await host.requireSession().getPluginInfo(id); @@ -191,6 +200,7 @@ async function showPluginMcpPicker(host: SlashCommandHost, id: string): Promise< host.mountEditorReplacement( new PluginMcpSelectorComponent({ info, + serverHint, colors: host.state.theme.colors, onSelect: (selection) => { host.restoreEditor(); @@ -229,7 +239,12 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } -async function applyPluginEnabled(host: SlashCommandHost, id: string, enabled: boolean): Promise { +async function applyPluginEnabled( + host: SlashCommandHost, + id: string, + enabled: boolean, + showStatus = true, +): Promise { const session = host.requireSession(); await session.setPluginEnabled(id, enabled); let info: PluginInfo | undefined; @@ -242,7 +257,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} .` : ''; - 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(enabled)}${inlineMcpHint}`; } async function handlePluginsOverviewSelection( @@ -261,13 +280,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; @@ -298,10 +318,10 @@ 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, { + server: selection.server, + text: pluginInlineChangeHint(selection.enabled), + }); return; case 'back': await showPluginsPicker(host, { selectedId: selection.pluginId }); @@ -389,3 +409,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(enabled: boolean): string { + return `${enabled ? 'Enabled' : 'Disabled'} · /new to apply`; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index 29d18074a2..a07b4a3b53 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -27,6 +27,8 @@ 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; } @@ -34,6 +36,7 @@ export interface ChoiceOption { 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; @@ -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}`)); } @@ -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) { @@ -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); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index ef543f6e98..88465e3d4f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -127,7 +127,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus 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), ]; @@ -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; } @@ -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), @@ -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; } @@ -286,6 +286,10 @@ export type PluginMcpSelection = export interface PluginMcpSelectorOptions { readonly info: PluginInfo; + readonly serverHint?: { + readonly server: string; + readonly text: string; + }; readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; @@ -344,7 +348,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), ]; @@ -378,10 +382,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; } @@ -403,6 +411,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, @@ -412,6 +421,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.', }, ], @@ -579,6 +589,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 diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 2035ac62cd..6b8158ea20 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import chalk from 'chalk'; import { PluginMcpSelectorComponent, @@ -11,12 +12,36 @@ import { import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; +const SGR_SEQUENCE = String.raw`\[[0-9;]*m`; +const HIGHLIGHTED_D_REMOVE = new RegExp(`${SGR_SEQUENCE}(?:${SGR_SEQUENCE})*D(?:${SGR_SEQUENCE})+ remove`, 'g'); const MID = '\u00B7'; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); } +function withAnsiColors(fn: () => T): T { + const previousChalkLevel = chalk.level; + chalk.level = 3; + try { + return fn(); + } finally { + chalk.level = previousChalkLevel; + } +} + +function renderRaw(component: { render(width: number): string[] }, width = 120): string { + return withAnsiColors(() => component.render(width).join('\n')); +} + +function primaryShortcut(text: string): string { + return withAnsiColors(() => chalk.hex(darkColors.primary).bold(text)); +} + +function dangerShortcut(text: string): string { + return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); +} + describe('plugins selector dialogs', () => { it('renders installed plugins as selectable overview entries', () => { const onSelect = vi.fn(); @@ -39,13 +64,19 @@ describe('plugins selector dialogs', () => { onCancel: vi.fn(), }); - const out = picker.render(120).map(strip).join('\n'); + 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( `Space disable ${MID} M MCP ${MID} D remove ${MID} Enter info ${MID} id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`, ); + expect(raw.match(HIGHLIGHTED_D_REMOVE)).toHaveLength(2); + expect(raw).toContain(primaryShortcut('Space')); + expect(raw).toContain(primaryShortcut('M')); + expect(raw).toContain(dangerShortcut('D')); + expect(raw).toContain(primaryShortcut('Enter')); expect(out).toContain('Browse official marketplace'); expect(out).toContain('Show plugin summary'); @@ -74,12 +105,15 @@ describe('plugins selector dialogs', () => { onCancel: vi.fn(), }); - const out = picker.render(120).map(strip).join('\n'); + const raw = renderRaw(picker); + const out = strip(raw); expect(out).toContain('Marketplace (1)'); expect(out).toContain('? Superpowers install v5.1.0'); expect(out).toContain( `Enter/Space install ${MID} Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, ); + expect(raw).toContain(primaryShortcut('Enter')); + expect(raw).toContain(primaryShortcut('Space')); expect(out).toContain('Actions'); expect(out).toContain('Back to installed plugins'); @@ -236,9 +270,12 @@ describe('plugins selector dialogs', () => { onCancel: vi.fn(), }); - const out = picker.render(120).map(strip).join('\n'); + const raw = renderRaw(picker); + const out = strip(raw); expect(out).toContain('MCP servers (1/1 enabled)'); expect(out).toContain('? data enabled'); + expect(raw).toContain(primaryShortcut('Enter')); + expect(raw).toContain(primaryShortcut('Space')); picker.handleInput(' '); @@ -307,6 +344,11 @@ describe('plugins selector dialogs', () => { }); picker.handleInput(''); + const raw = renderRaw(picker); + expect(raw).toContain(primaryShortcut('Enter')); + expect(raw).toContain(primaryShortcut('Space')); + expect(raw).toContain(dangerShortcut('Remove plugin')); + picker.handleInput('\r'); expect(results).toEqual([{ kind: 'confirm' }]); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 28b6ea1290..4919af7cba 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1486,11 +1486,12 @@ describe('KimiTUI message flow', () => { ); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ Demo disabled saved · /new to apply'); - expect(stripSgr(renderTranscript(driver))).toContain('Disabled demo. Run /new to apply.'); + expect(out).toContain('❯ Demo disabled Disabled · /new to apply'); + expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { + let serverEnabled = true; const session = makeSession({ listPlugins: vi.fn(async () => [ { @@ -1522,7 +1523,7 @@ describe('KimiTUI message flow', () => { { name: 'data', runtimeName: 'plugin-kimi-datasource-data', - enabled: true, + enabled: serverEnabled, transport: 'stdio', command: 'node', args: ['./bin/kimi-datasource.mjs'], @@ -1530,6 +1531,9 @@ describe('KimiTUI message flow', () => { ], diagnostics: [], })), + setPluginMcpServerEnabled: vi.fn(async (_id: string, _server: string, nextEnabled: boolean) => { + serverEnabled = nextEnabled; + }), }); const { driver } = await makeDriver(session); @@ -1558,6 +1562,14 @@ describe('KimiTUI message flow', () => { false, ); }); + await vi.waitFor(() => { + 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 Disabled · /new to apply'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled MCP server data for kimi-datasource. Run /new to apply.', + ); }); it('requires confirmation before /plugins remove removes a plugin', async () => { From be5397d63ec56a90e679e192661d1894c2e1560a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 28 May 2026 21:12:52 +0800 Subject: [PATCH 2/2] fix: refine plugin manager rows --- apps/kimi-code/src/tui/commands/plugins.ts | 23 +++++++++---- .../components/dialogs/plugins-selector.ts | 21 +++++++----- .../dialogs/plugins-selector.test.ts | 16 ++++----- .../test/tui/kimi-tui-message-flow.test.ts | 33 +++++++++++++------ 4 files changed, 59 insertions(+), 34 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index efdd85607a..929afce93b 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -35,6 +35,11 @@ interface PluginMcpServerHint { readonly text: string; } +interface ShowPluginMcpPickerOptions { + readonly selectedServer?: string; + readonly serverHint?: PluginMcpServerHint; +} + export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise { const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; @@ -187,7 +192,7 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri async function showPluginMcpPicker( host: SlashCommandHost, id: string, - serverHint?: PluginMcpServerHint, + options?: ShowPluginMcpPickerOptions, ): Promise { let info: PluginInfo; try { @@ -200,7 +205,8 @@ async function showPluginMcpPicker( host.mountEditorReplacement( new PluginMcpSelectorComponent({ info, - serverHint, + selectedServer: options?.selectedServer, + serverHint: options?.serverHint, colors: host.state.theme.colors, onSelect: (selection) => { host.restoreEditor(); @@ -261,7 +267,7 @@ async function applyPluginEnabled( host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; - return `${pluginInlineChangeHint(enabled)}${inlineMcpHint}`; + return `${pluginInlineChangeHint()}${inlineMcpHint}`; } async function handlePluginsOverviewSelection( @@ -319,8 +325,11 @@ async function handlePluginMcpSelection( selection.enabled, ); await showPluginMcpPicker(host, selection.pluginId, { - server: selection.server, - text: pluginInlineChangeHint(selection.enabled), + selectedServer: selection.server, + serverHint: { + server: selection.server, + text: pluginInlineChangeHint(), + }, }); return; case 'back': @@ -410,6 +419,6 @@ function resolvePluginInstallSource(source: string, workDir: string): string { return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed); } -function pluginInlineChangeHint(enabled: boolean): string { - return `${enabled ? 'Enabled' : 'Disabled'} · /new to apply`; +function pluginInlineChangeHint(): string { + return 'pending /new'; } diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 88465e3d4f..4ee027b95c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -121,7 +121,7 @@ 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[] = [ @@ -286,6 +286,7 @@ export type PluginMcpSelection = export interface PluginMcpSelectorOptions { readonly info: PluginInfo; + readonly selectedServer?: string; readonly serverHint?: { readonly server: string; readonly text: string; @@ -306,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 { @@ -448,19 +453,19 @@ 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.', }, ); @@ -468,8 +473,6 @@ function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewI } 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 = @@ -477,7 +480,7 @@ function overviewPluginDescription(plugin: PluginSummary): string { ? ` · 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 { diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 6b8158ea20..7e3a810600 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -69,16 +69,16 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Installed plugins (1)'); expect(out).toContain('Actions'); expect(out).toContain('? Kimi Datasource enabled'); - expect(out).toContain( - `Space disable ${MID} M MCP ${MID} D remove ${MID} Enter info ${MID} id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`, - ); - expect(raw.match(HIGHLIGHTED_D_REMOVE)).toHaveLength(2); + 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(raw.match(HIGHLIGHTED_D_REMOVE)).toHaveLength(1); expect(raw).toContain(primaryShortcut('Space')); expect(raw).toContain(primaryShortcut('M')); expect(raw).toContain(dangerShortcut('D')); expect(raw).toContain(primaryShortcut('Enter')); - expect(out).toContain('Browse official marketplace'); - expect(out).toContain('Show plugin summary'); + expect(out).toContain('Marketplace'); + expect(out).toContain('Summary'); picker.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' }); @@ -300,7 +300,7 @@ describe('plugins selector dialogs', () => { }, ], selectedId: 'kimi-datasource', - pluginHint: { id: 'kimi-datasource', text: `saved ${MID} /new to apply` }, + pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), @@ -308,7 +308,7 @@ describe('plugins selector dialogs', () => { const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain(`? Kimi Datasource enabled saved ${MID} /new to apply`); + expect(out).toContain('? Kimi Datasource enabled pending /new'); }); it('defaults plugin removal confirmation to cancel', () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 4919af7cba..40ab4e6510 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1486,12 +1486,16 @@ describe('KimiTUI message flow', () => { ); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ Demo disabled Disabled · /new to apply'); + expect(out).toContain('❯ Demo disabled pending /new'); + expect(out).not.toContain('Space enable'); expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { - let serverEnabled = true; + const serverEnabled = new Map([ + ['metadata', true], + ['data', true], + ]); const session = makeSession({ listPlugins: vi.fn(async () => [ { @@ -1501,8 +1505,8 @@ describe('KimiTUI message flow', () => { enabled: true, state: 'ok', skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, + mcpServerCount: 2, + enabledMcpServerCount: 2, hasErrors: false, }, ]), @@ -1513,26 +1517,34 @@ describe('KimiTUI message flow', () => { enabled: true, state: 'ok', skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, + mcpServerCount: 2, + enabledMcpServerCount: [...serverEnabled.values()].filter(Boolean).length, hasErrors: false, source: 'local-path', root: '/plugins/kimi-datasource', manifest: undefined, mcpServers: [ + { + name: 'metadata', + runtimeName: 'plugin-kimi-datasource-metadata', + enabled: serverEnabled.get('metadata') === true, + transport: 'stdio', + command: 'node', + args: ['./bin/kimi-datasource.mjs', 'metadata'], + }, { name: 'data', runtimeName: 'plugin-kimi-datasource-data', - enabled: serverEnabled, + enabled: serverEnabled.get('data') === true, transport: 'stdio', command: 'node', - args: ['./bin/kimi-datasource.mjs'], + args: ['./bin/kimi-datasource.mjs', 'data'], }, ], diagnostics: [], })), setPluginMcpServerEnabled: vi.fn(async (_id: string, _server: string, nextEnabled: boolean) => { - serverEnabled = nextEnabled; + serverEnabled.set(_server, nextEnabled); }), }); const { driver } = await makeDriver(session); @@ -1553,6 +1565,7 @@ describe('KimiTUI message flow', () => { ); }); const mcpPicker = driver.state.editorContainer.children[0] as PluginMcpSelectorComponent; + mcpPicker.handleInput('\u001B[B'); mcpPicker.handleInput(' '); await vi.waitFor(() => { @@ -1566,7 +1579,7 @@ describe('KimiTUI message flow', () => { 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 Disabled · /new to apply'); + expect(out).toContain('❯ data disabled pending /new'); expect(stripSgr(renderTranscript(driver))).not.toContain( 'Disabled MCP server data for kimi-datasource. Run /new to apply.', );