From 5e218d24dafa23cff3fb0344a59b57d8f3190f32 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:06:59 +0800 Subject: [PATCH 01/14] 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. --- .changeset/plugin-marketplace-tabs.md | 14 + apps/kimi-code/src/tui/commands/config.ts | 1 + apps/kimi-code/src/tui/commands/plugins.ts | 188 ++--- apps/kimi-code/src/tui/commands/provider.ts | 2 + .../components/dialogs/plugins-selector.ts | 739 ++++++++++-------- .../dialogs/tabbed-model-selector.ts | 87 +-- apps/kimi-code/src/tui/utils/tab-strip.ts | 89 +++ .../kimi-code/src/utils/plugin-marketplace.ts | 38 +- .../dialogs/plugins-selector.test.ts | 430 +++------- .../dialogs/tabbed-model-selector.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 131 +++- .../test/utils/plugin-marketplace.test.ts | 116 ++- docs/en/configuration/env-vars.md | 2 +- docs/en/customization/plugins.md | 119 +-- docs/zh/configuration/env-vars.md | 2 +- docs/zh/customization/plugins.md | 119 +-- 16 files changed, 1080 insertions(+), 998 deletions(-) create mode 100644 .changeset/plugin-marketplace-tabs.md create mode 100644 apps/kimi-code/src/tui/utils/tab-strip.ts diff --git a/.changeset/plugin-marketplace-tabs.md b/.changeset/plugin-marketplace-tabs.md new file mode 100644 index 0000000000..efec620818 --- /dev/null +++ b/.changeset/plugin-marketplace-tabs.md @@ -0,0 +1,14 @@ +--- +"@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. +Marketplace rows can also open setup URLs instead of installing a plugin. The +tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` +helper. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9b91d4ba01..20333973a1 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -309,6 +309,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentValue: host.state.appState.model, selectedValue, currentThinking: host.state.appState.thinking, + colors: host.state.theme.palette, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking); diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index cb0bd6329c..318bfe18c3 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -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,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { - await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined); + await showPluginsPicker(host, { + initialTab: 'official', + marketplaceSource: rest.join(' ').trim() || undefined, + }); return; } if (sub === 'info') { @@ -95,7 +99,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 +122,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 +152,53 @@ 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, + colors: host.state.theme.palette, + 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 { +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source?: string, +): Promise { 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( @@ -218,6 +219,7 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, + colors: host.state.theme.palette, onSelect: (selection) => { // Every MCP action re-mounts a picker, so let the handler do the // mounting — pre-restoring the editor here would flash on toggle. @@ -274,54 +276,59 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` : ''; 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 { - 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 +357,9 @@ async function handlePluginMcpSelection( } } -async function handlePluginMarketplaceSelection( - host: SlashCommandHost, - selection: PluginMarketplaceSelection, -): Promise { - 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 { + await host.requireSession().removePlugin(id); + host.showStatus(`Removed ${id}. Run /reload or /new to apply plugin changes.`); } async function renderPluginsList( @@ -445,13 +439,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 ". Only prepend "from" otherwise. +function sourcePhrase(sourceLabel: string): string { + return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`; } function sourceIdentity(plugin: PluginSummary): string { @@ -482,5 +482,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'; } diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 242252bfbc..98c33c5453 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -234,6 +234,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), currentThinking: host.state.appState.thinking, + colors: host.state.theme.palette, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -324,6 +325,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise currentValue: host.state.appState.model, selectedValue: firstNewAlias, currentThinking: host.state.appState.thinking, + colors: host.state.theme.palette, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); 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 d2bcc86201..f610c137e1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -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 { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; +import 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; - 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 }; @@ -291,6 +45,7 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; + readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -346,19 +101,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { info } = this.opts; + const { colors, info } = this.opts; 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 } = this.opts; 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,364 @@ 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 { + // "installed" reads as success; "install" / "install vX" as an available action. + return status === 'installed' ? chalk.hex(colors.success) : 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, -): 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; + readonly colors: ColorPalette; + 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 officialEntries(): readonly PluginMarketplaceEntry[] { + return this.marketplaceEntries.filter((entry) => entry.tier === 'official'); + } + + private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { + return this.marketplaceEntries.filter((entry) => entry.tier === 'curated'); + } + + 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); + if (matchesKey(data, Key.space)) { + 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)) { + this.selectedIndex = 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 } = this.opts; + 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 { colors, installed } = this.opts; + 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 } = this.opts; + 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 } = this.opts; + 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, installedIds } = this.opts; + 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, installedIds); + 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 { + lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', this.opts.colors)); + lines.push(''); + lines.push(...renderUrlInputBox(this.customInput, this.focused, width, this.opts.colors)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { @@ -556,13 +602,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,38 +617,37 @@ 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, + installedIds: ReadonlySet, ): string { - const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); - switch (status.kind) { - case 'update': - return `update ${status.local} → ${status.latest}`; - 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 installedIds.has(entry.id) ? 'installed' : 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); } diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 747072a5cc..4471a95bdd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -19,11 +19,11 @@ import { Key, matchesKey, truncateToWidth, - visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, @@ -40,6 +40,7 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; + readonly colors: ColorPalette; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -100,7 +101,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: this.opts.colors, + }); const out: string[] = [ inner[0] ?? '', inner[1] ?? '', @@ -126,81 +132,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[] { diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts new file mode 100644 index 0000000000..56aef23008 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -0,0 +1,89 @@ +/** + * 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. + 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 = 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); + 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 ? chalk.hex(colors.textMuted)('< ') : ' '; + strip += segments.slice(start, end).join(' '); + if (hasRight) { + strip += chalk.hex(colors.textMuted)(' >'); + } + return strip; +} diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 5553e94c14..e1c8628998 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -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 { + 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 { + 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, 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, id: 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 79d872cd3f..7e90ffd54d 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 @@ -3,20 +3,16 @@ 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 { 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 +36,50 @@ 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)), + colors: darkColors, + 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,311 +132,93 @@ 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('toggles an installed plugin with Space', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false }); }); - 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(), - }); - - 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'); - - picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ - kind: 'install', - entry: expect.objectContaining({ id: 'superpowers' }), - }); + 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('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 - expect(onSelect).toHaveBeenCalledWith({ - kind: 'install', - entry: expect.objectContaining({ 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'); }); - 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, - }); + 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(LEFT); // must NOT return to the overview - expect(onCancel).not.toHaveBeenCalled(); + 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('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(), - }); - - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Superpowers installed'); - expect(out).toContain(`Plugin ${MID} id superpowers`); - - picker.handleInput('\r'); + 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('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('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('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(' '); + 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: '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(), + kind: 'install-source', + source: 'https://github.com/owner/repo', }); - - 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', () => { @@ -429,6 +251,7 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, + colors: darkColors, onSelect: (selection) => { selections.push(selection); }, @@ -448,33 +271,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 +301,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). diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index b1a2baf0c4..d545103afb 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -35,6 +35,7 @@ function make(): { }, currentValue: 'k2', currentThinking: false, + colors: darkColors, onSelect, onCancel: vi.fn(), }); 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 cab1bfeaed..f86fdb51c0 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 @@ -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 = {}) { 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 panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput('\r'); + 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.', ); }); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d7430b5add..d3577bc129 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -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/, + ); + }); + }); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 9332325118..c0f84251c1 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -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 | diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 78f90f8e65..ba76e434c5 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -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 `, an up-to-date one shows `installed · v`, and an uninstalled one shows `install v`. 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: open or install · Custom tab: install | +| `Esc` | Go back or cancel | You can also use slash commands directly: @@ -33,11 +34,7 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | 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). - -### Installing from GitHub - -Use `/plugins install ` to install directly from a GitHub repository. Four URL forms are supported: +**GitHub URL supports four forms:** - `https://github.com//`: Install the latest release; falls back to the default branch if no release exists - `https://github.com///tree/`: Install a specific branch, tag, or short commit SHA @@ -46,59 +43,48 @@ Use `/plugins install ` to install directly from a GitHub repository. Four Network requests only go through `github.com` redirects and `codeload.github.com` downloads; `api.github.com` is not called. -### 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. -- Local installations are copied to `$KIMI_CODE_HOME/plugins/managed//`, 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. - -## 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. - -### Installation - -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 - -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 - -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`. +The plugin manager shows each install's source and a trust badge. `kimi-official` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/official/`; `curated` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/curated/`. `third-party` marks anything else, including GitHub installs, local directories, custom marketplace sources, and other URLs. Marketplace `tier` is listing metadata; the installed trust badge still comes from the actual downloaded source. -### What You Can Do +The **Official** and **Third-party** tabs list the marketplace catalog by tier — **Official** holds Kimi-maintained plugins and **Third-party** holds plugins from other publishers. Installed entries are listed first. Both tabs load lazily — opening `/plugins` is instant and works offline; only switching to either tab fetches the catalog, and a fetch failure is shown inline on the tab instead of closing the panel. The **Custom** tab installs a plugin straight from a GitHub URL (or zip URL / local path), without it being a marketplace listing. `/plugins marketplace` opens directly on the Official tab. -**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. +By default, marketplace items are plugins: Kimi Code installs their `source` and tracks the install in `installed.json`. -**Cross-country macro comparison**: Studying supply-chain shifts across China, India, and Vietnam? Get complete GDP growth, trade volume, and demographic time-series from World Bank data spanning 50+ years, all in one go. +For custom marketplace JSON, omit `type` or set `"type": "plugin"`, and provide a `source`. `source` may be a local path, a zip URL, or a GitHub repository URL. New CLIs accept `"type": "managed"` and the legacy `"type": "guide"` as aliases for `"plugin"`. -**Pre-contract risk check**: Need to vet a counterparty fast? Type the company name and instantly get business registration, equity structure, litigation disputes, and credit blacklist status — right when you need it. +The marketplace JSON has a single `plugins` array. Do not split the same marketplace into separate old and new arrays. Old CLIs read the same list and ignore fields they do not understand. -**Literature review acceleration**: Tracing the research arc of RLHF? Get the most-cited papers, key authors, and core findings in seconds, so your literature review outline takes shape in half the time. +```json +{ + "version": "2", + "plugins": [ + { + "id": "my-plugin", + "type": "plugin", + "displayName": "My Plugin", + "source": "./my-plugin" + } + ] +} +``` -**On-the-spot legal lookup**: Stuck on which statute governs a residence-right contract dispute? Pinpoint the relevant Civil Code articles — full text, authority level, and validity — then pull a few comparable precedents to back them up, without digging through statute databases. +The marketplace JSON is a versioned contract. Keep existing field meanings stable, add optional fields when possible, and decide how each change behaves across CLI versions: -### Coverage +| Case | Rule | +| --- | --- | +| New CLI with old marketplace JSON | Works: missing `type` defaults to `plugin`, `"managed"` is accepted as a legacy alias, and legacy `url` / `downloadUrl` fields are still accepted as source aliases. | +| Old CLI with new plugin items | Works from the same `plugins` array when the item provides `source` and the source uses a manifest path the old CLI already supports; old CLIs ignore fields they do not understand. | +| Legacy `"type": "guide"` items | Treated as a normal plugin install; any `installSkill` / `removeSkill` fields are ignored. | +| Existing installed records | Keep working; the `installed.json` and managed plugin directory contract is unchanged. | +| New entry types or install behavior | Keep a single `plugins` array where possible. Use `version`, parser defaults, field aliases, and clear rejection rules; only add a separate artifact or publishing gate when one array cannot stay compatible. | -| Category | Scope | -|---|---| -| Stock market data | A-shares, HK, US, and major global markets — real-time/historical prices, technical indicators, financial statements, stock screening | -| Macroeconomic data | World Bank data for 189 countries, 50+ years of time series (GDP, trade, population, climate, and more) | -| Corporate data | Business registration, equity chain, legal risk, and related-entity graph for mainland Chinese companies | -| 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 | +If you operate a custom marketplace, apply the same rule to your own marketplace URL. Before changing fields or entry types, decide whether old CLIs should keep installing the same `plugins` list, ignore the new fields, or reject it with a clear error. -### Notes +**A few notes:** -- 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 -- Technical indicators and real-time prices are only available during active trading hours -- AI-generated output is for reference only and does not constitute investment or business advice +- Plugin changes apply after `/reload` or in new sessions. This includes newly installed or enabled Skills, same-name Skill updates, disabled or removed Skills, MCP servers, and `sessionStart.skill` changes. +- Local installations are copied to `$KIMI_CODE_HOME/plugins/managed//`, 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. ## Plugin Manifest @@ -192,26 +178,41 @@ 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 ``` +## Official Plugins + +The Kimi Code CLI official marketplace hosts reviewed official plugins. Currently available: + +**[Kimi Datasource](./datasource.md)** — Query financial market data, macroeconomic indicators, corporate registration records, and academic literature in natural language. + +Installation: + +1. Run `/plugins` and select **Official** +2. Find **Kimi Datasource** and press `Enter` to install +3. Run `/reload` or `/new` after installation + +For data capabilities and usage examples, see the [Official Plugins documentation](./datasource.md). + ## Security Model Plugins have a limited loading scope. The following operations do not occur during installation or session startup: - 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 ` diagnostics and do not affect other sessions ## Next steps +- [Kimi Datasource](./datasource.md) — Official data plugin: installation and usage for financial market data, corporate records, and academic literature - [Agent Skills](./skills.md) — File format and frontmatter field reference for Skills - [MCP](./mcp.md) — Full schema and permission configuration for plugin MCP servers diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 227b1ced1a..639f40d962 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -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) | 真值或假值 | diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 03d7a29a1c..d913ca2e55 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -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` | 启用或禁用选中的已安装 plugin(Installed tab) | +| `D` | 移除选中的已安装 plugin(Installed tab) | +| `M` | 管理选中 plugin 的 MCP servers(Installed tab) | +| `R` | 重新加载 `installed.json` 和所有 manifest(Installed tab) | +| `Enter` | Installed tab:查看 plugin 详情 · Official/Third-party tab:打开或安装 · Custom tab:安装 | +| `Esc` | 返回或取消 | 也可以直接使用斜杠命令: @@ -33,11 +34,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -Plugin 管理器会展示每个安装的来源和信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。 - -### 从 GitHub 安装 - -通过 `/plugins install ` 可以直接从 GitHub 仓库安装,支持四种 URL 形式: +**GitHub URL 支持四种形式:** - `https://github.com//`:安装最新 release;无 release 时回落到默认分支 - `https://github.com///tree/`:安装指定分支、tag 或短 commit SHA @@ -46,59 +43,48 @@ Plugin 管理器会展示每个安装的来源和信任徽章:`kimi-official` 网络请求只走 `github.com` 重定向和 `codeload.github.com` 下载,不调用 `api.github.com`。 -### 注意事项 - -- Plugin 变更只对新会话生效。安装、启用/禁用、移除后,需通过 `/reload` 重载插件或通过 `/new` 开启新会话;当前会话不会更新。 -- 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。 -- 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。 -- Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。 - -## Kimi Datasource - -Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直接查询金融行情、宏观经济、企业工商、学术文献和中国法律法规,无需手动调用接口或申请任何数据账号。 - -### 安装 - -需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。 - -1. 运行 `/plugins`,选择 **Marketplace** -2. 找到 **Kimi Datasource**,按 `Space` 安装 -3. 安装完成后运行 `/reload` 重载插件,即可使用 - -当前最新版本为 v3.2.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 - -### 使用方式 - -安装完成后,直接用自然语言描述你的需求,Kimi Code 会自动调用数据能力;也可以通过 `/skill:kimi-datasource` 明确触发数据查询 Skill。 +Plugin 管理器会展示每个安装的来源以及一个信任徽章。`kimi-official` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/official/`;`curated` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/curated/`。`third-party` 表示其它所有情况,包括 GitHub 安装、本地目录、自定义 marketplace source 和其它 URL。Marketplace `tier` 只是列表展示元数据;安装后的信任徽章仍按真实下载来源判断。 -### 能做什么 +**Official** 和 **Third-party** 两个 tab 按 tier 列出 marketplace 目录——**Official** 是 Kimi 官方维护的 plugin,**Third-party** 是第三方 publisher 的 plugin。已安装的排在前面。这两个 tab 都是**懒加载**的——打开 `/plugins` 很快、离线也能用,只有切到 Official 或 Third-party 才会去拉目录,拉取失败会就地在 tab 里提示,而不是把整个面板关掉。**Custom** tab 可以直接输入 GitHub URL(或 zip URL / 本地路径)安装一个不在 marketplace 列表里的插件。`/plugins marketplace` 会直接打开到 Official tab。 -**实时量化研究**:盯着茅台想做个量化分析?一句话拉取近三年的每日收盘价、MACD 和 KDJ 信号,直接出结论,不用找第三方数据平台。 +默认情况下,marketplace item 是 plugin:Kimi Code 会安装它的 `source`,并把安装记录写入 `installed.json`。 -**跨国宏观对比**:研究中印越产业转移?基于世界银行 50 年历史数据,一次查询拿到三国 GDP 增速、贸易额、人口结构的完整时间序列对比。 +自定义 marketplace JSON 中,可以省略 `type` 或写 `"type": "plugin"`,并提供 `source`。`source` 可以是本地路径、zip URL 或 GitHub 仓库 URL。新版 CLI 把 `"type": "managed"` 和旧的 `"type": "guide"` 都作为 `"plugin"` 的别名处理。 -**合同前风险排查**:签合同前五分钟才想起来要查对方背景?输入公司名,立刻拿到工商注册信息、股权穿透、司法纠纷和失信记录,当场决策。 +Marketplace JSON 只有一个 `plugins` 数组。不要把同一个 marketplace 拆成新旧两个数组。旧版 CLI 读取同一份列表,忽略它不认识的字段。 -**文献综述加速**:写论文要梳理 RLHF 领域的研究脉络?直接列出高引论文、主要作者和核心结论,综述提纲半小时内成型。 +```json +{ + "version": "2", + "plugins": [ + { + "id": "my-plugin", + "type": "plugin", + "displayName": "My Plugin", + "source": "./my-plugin" + } + ] +} +``` -**法律条文速查**:碰上居住权的合同纠纷,拿不准法条?一句话定位《民法典》相关条文原文、效力级别和时效性,再顺手拉几个相近判例佐证,不用翻法规库。 +Marketplace JSON 是一个带版本的契约。已有字段的含义要保持稳定;能新增可选字段时,不要改变旧字段含义。每次改字段或安装行为,都要先决定新旧 CLI 的处理方式: -### 数据覆盖 +| 场景 | 规则 | +| --- | --- | +| 新版 CLI 读取旧 marketplace JSON | 兼容:缺省 `type` 按 `plugin` 处理,`"managed"` 会作为旧别名接受,旧字段 `url` / `downloadUrl` 仍作为 `source` 的别名读取。 | +| 旧版 CLI 读取新的 plugin item | 读取同一个 `plugins` 数组;只要 item 提供 `source`,且该 source 使用旧版 CLI 已支持的 manifest 路径,旧版 CLI 会忽略它不认识的字段。 | +| 旧的 `"type": "guide"` 条目 | 当作普通 plugin 安装;其中的 `installSkill` / `removeSkill` 字段会被忽略。 | +| 已有 installed records | 继续可用,`installed.json` 与托管 plugin 目录的契约没有变化。 | +| 新增 entry type 或改变安装行为 | 尽量保持单一 `plugins` 数组。优先用 `version`、parser 默认值、字段别名和清晰拒绝规则;只有一个数组无法保持兼容时,才增加单独 artifact 或发布门禁。 | -| 类别 | 覆盖范围 | -|---|---| -| 股票行情 | A 股、港股、美股及全球主要市场实时/历史行情、技术指标、财务报表、股票筛选 | -| 宏观经济 | 世界银行 189 个成员国、50 年以上历史时间序列(GDP、贸易、人口、气候等) | -| 企业数据 | 中国大陆境内企业工商信息、股权穿透、司法风险、关联图谱 | -| 学术文献 | 物理、数学、计算机、金融、经济等领域百万量级论文,支持预印本查询 | -| 法律法规 | 中国法律法规与司法案例:宪法、法律、司法解释、部门规章等各效力层次的法规语义/关键词检索与详情,普通及权威判例检索 | +如果你维护自定义 marketplace,也要把自己的 marketplace URL 当成版本化契约。改字段或 entry type 前,先决定旧版 CLI 应继续安装同一份 `plugins` 列表、忽略新增字段,还是给出清晰错误。 -### 注意事项 +**几点注意事项:** -- 数据查询按次计费,消耗 Kimi Code 账号额度 -- 插件为只读查询,不提供任何写入或交易功能 -- 技术指标(MACD、KDJ 等)及实时行情仅在交易时段内可用 -- AI 输出内容仅供参考,不构成任何投资或商业决策建议 +- Plugin 变更需要通过 `/reload` 或新会话生效,包括新安装或新启用的 Skills、已有同名 skill 的更新、禁用或移除的 skill、MCP servers 以及 `sessionStart.skill` 变更。 +- 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。 +- 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。 +- Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。 ## Plugin manifest @@ -192,26 +178,41 @@ 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 ``` +## 官方插件 + +Kimi Code CLI 官方 marketplace 收录了经过审核的官方插件。目前可用: + +**[Kimi Datasource](./datasource.md)** — 通过自然语言查询金融行情、宏观经济、企业工商和学术文献。 + +安装方式: + +1. 运行 `/plugins`,选择 **Official** +2. 找到 **Kimi Datasource**,按 `Enter` 安装 +3. 安装完成后运行 `/reload` 或 `/new` + +数据能力、使用示例见[官方插件文档](./datasource.md)。 + ## 安全模型 Plugin 的加载范围有限,以下操作不会在安装或会话启动时发生: - 不会执行命令型 plugin tools、hooks 或旧式工具运行时 - 所有路径在解析符号链接后仍必须位于 plugin 根目录内 -- 已启用 plugin 的 MCP servers 只在新会话中启动,且可随时从 `/plugins` 禁用 +- 已启用 plugin 的 MCP servers 会在 `/reload` 后或新会话中启动,且可随时从 `/plugins` 禁用 - 损坏的 manifest 或不安全路径会显示在 `/plugins info ` 的 diagnostics 中,不影响其他会话 ## 下一步 +- [Kimi Datasource](./datasource.md) — 官方数据插件:金融行情、企业工商、学术文献的安装与使用 - [Agent Skills](./skills.md) — Skills 的文件格式与 frontmatter 字段参考 - [MCP](./mcp.md) — Plugin MCP servers 的完整 schema 与权限配置 From 7523767ae6e87433ef7af2015b5631683df352e7 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:33:52 +0800 Subject: [PATCH 02/14] fix(tui): show untiered marketplace entries and update badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` badge again, and up-to-date plugins show `installed · v` — restoring the update visibility the pre-redesign marketplace UI had. --- .../components/dialogs/plugins-selector.ts | 34 ++++++++++---- .../dialogs/plugins-selector.test.ts | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) 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 f610c137e1..8787a270d0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -16,7 +16,7 @@ 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 type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { ChoicePickerComponent } from './choice-picker'; @@ -212,8 +212,11 @@ function pluginStatus(plugin: PluginSummary): string | undefined { } function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { - // "installed" reads as success; "install" / "install vX" as an available action. - return status === 'installed' ? chalk.hex(colors.success) : chalk.hex(colors.primary); + // "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); } /** Rounded single-line URL input box (DESIGN §9), shared by the marketplace @@ -333,12 +336,19 @@ export class PluginsPanelComponent extends Container implements Focusable { ); } + private get installedVersions(): ReadonlyMap { + 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[] { - return this.marketplaceEntries.filter((entry) => entry.tier === 'curated'); + // 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 { @@ -543,12 +553,12 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { - const { colors, installedIds } = this.opts; + const { colors } = this.opts; 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, installedIds); + const status = marketplaceEntryStatus(entry, this.installedVersions); const line = prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); const descWidth = Math.max(1, width - 4); @@ -623,9 +633,17 @@ function installStatus(entry: PluginMarketplaceEntry): string { function marketplaceEntryStatus( entry: PluginMarketplaceEntry, - installedIds: ReadonlySet, + installed: ReadonlyMap, ): string { - return installedIds.has(entry.id) ? 'installed' : installStatus(entry); + const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return installStatus(entry); + } } function sectionLabel(label: string, colors: ColorPalette): 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 7e90ffd54d..641094a541 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 @@ -196,6 +196,50 @@ describe('plugins selector dialogs', () => { }); }); + 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('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'); + }); + + it('shows installed · v 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'); + }); + it('shows an inline error when the Official catalog fails', () => { const { panel } = makePanel({ installed: [superpowers] }); panel.handleInput('\t'); // → Official From 652505cf8175f13c5b5df82cfab31259b3c1759d Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:59:20 +0800 Subject: [PATCH 03/14] 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. --- .../kimi-code/src/tui/components/dialogs/plugins-selector.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 8787a270d0..adbefab49f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -402,7 +402,10 @@ export class PluginsPanelComponent extends Container implements Focusable { } const plugin = plugins[this.selectedIndex]; const ch = printableChar(data); - if (matchesKey(data, Key.space)) { + // 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 }); } From f85eb10a608ec1135cd26665c518fce756286ea8 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 19:21:50 +0800 Subject: [PATCH 04/14] fix(tui): open custom marketplaces on the Third-party tab When `/plugins marketplace ` 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. --- apps/kimi-code/src/tui/commands/plugins.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 318bfe18c3..af05f1ced6 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -74,9 +74,14 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { + const marketplaceSource = rest.join(' ').trim() || undefined; await showPluginsPicker(host, { - initialTab: 'official', - marketplaceSource: rest.join(' ').trim() || undefined, + // 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; } From 9f4f72d3715ecd1ea7cd4b85a0d910beadd2d67b Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 19:33:14 +0800 Subject: [PATCH 05/14] 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. --- .changeset/plugin-marketplace-tabs.md | 5 ++--- docs/en/customization/plugins.md | 6 +++--- docs/zh/customization/plugins.md | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.changeset/plugin-marketplace-tabs.md b/.changeset/plugin-marketplace-tabs.md index efec620818..a4e2699556 100644 --- a/.changeset/plugin-marketplace-tabs.md +++ b/.changeset/plugin-marketplace-tabs.md @@ -6,9 +6,8 @@ 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 +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. -Marketplace rows can also open setup URLs instead of installing a plugin. The +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. diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index ba76e434c5..d7f2bc9191 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -6,16 +6,16 @@ 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. 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: +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 | | --- | --- | -| `Tab` / `Shift+Tab` | Switch between the Installed / Official / Third-party / Custom tabs | +| `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: open or install · Custom tab: install | +| `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: diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index d913ca2e55..c98a6f57c2 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -6,16 +6,16 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 ## 安装与管理 -在 TUI 中运行 `/plugins` 打开 plugin 管理器。它是一个面板,有四个 tab:**Installed**(管理已装的)、**Official**(Kimi 官方 marketplace plugin)、**Third-party**(第三方 marketplace plugin)、**Custom**(从 URL 安装),用 `Tab` / `Shift+Tab` 切换。常用按键: +在 TUI 中运行 `/plugins` 打开 plugin 管理器。它是一个面板,有四个 tab:**Installed**(管理已装的)、**Official**(Kimi 官方 marketplace plugin)、**Third-party**(第三方 marketplace plugin)、**Custom**(从 URL 安装),用 `Tab` / `Shift-Tab` 切换。常用按键: | 按键 | 操作 | | --- | --- | -| `Tab` / `Shift+Tab` | 在 Installed / Official / Third-party / Custom 四个 tab 间切换 | +| `Tab` / `Shift-Tab` | 在 Installed / Official / Third-party / Custom 四个 tab 间切换 | | `Space` | 启用或禁用选中的已安装 plugin(Installed tab) | | `D` | 移除选中的已安装 plugin(Installed tab) | | `M` | 管理选中 plugin 的 MCP servers(Installed tab) | | `R` | 重新加载 `installed.json` 和所有 manifest(Installed tab) | -| `Enter` | Installed tab:查看 plugin 详情 · Official/Third-party tab:打开或安装 · Custom tab:安装 | +| `Enter` | Installed tab:查看 plugin 详情 · Official/Third-party tab:安装或更新 · Custom tab:安装 | | `Esc` | 返回或取消 | 也可以直接使用斜杠命令: From 1ecc26e3f73f01914a0b56f6846c75dc9ad4d6ff Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 19:51:15 +0800 Subject: [PATCH 06/14] fix(tui): keep marketplace selection valid while loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/tui/components/dialogs/plugins-selector.ts | 4 +++- .../tui/components/dialogs/plugins-selector.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) 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 adbefab49f..652a3fb69b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -435,7 +435,9 @@ export class PluginsPanelComponent extends Container implements Focusable { return; } if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1); + // 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)) { 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 641094a541..87da8d28c7 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 @@ -196,6 +196,19 @@ describe('plugins selector dialogs', () => { }); }); + 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('shows untiered marketplace entries on the Third-party tab', () => { const untiered = [ { id: 'custom-plugin', displayName: 'Custom Plugin', source: 'https://x/c.zip' }, From 3b7867adc047eef23728580a1cebf58ed26df8f6 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 20:22:43 +0800 Subject: [PATCH 07/14] 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. --- apps/kimi-code/src/tui/utils/tab-strip.ts | 11 ++-- .../test/tui/utils/tab-strip.test.ts | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 apps/kimi-code/test/tui/utils/tab-strip.test.ts diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts index 56aef23008..3cac6826bb 100644 --- a/apps/kimi-code/src/tui/utils/tab-strip.ts +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -33,9 +33,13 @@ 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. + // 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); - if (1 + totalSegmentWidth <= width) { + const fullSeparatorWidth = Math.max(0, segments.length - 1); + if (1 + totalSegmentWidth + fullSeparatorWidth <= width) { return ' ' + segments.join(' '); } @@ -49,7 +53,8 @@ export function renderTabStrip(opts: RenderTabStripOptions): string { const needLeft = s > 0; const needRight = e < segments.length; const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); - return cw + frameWidth <= width; + const separators = Math.max(0, e - s - 1); + return cw + separators + frameWidth <= width; }; while (true) { diff --git a/apps/kimi-code/test/tui/utils/tab-strip.test.ts b/apps/kimi-code/test/tui/utils/tab-strip.test.ts new file mode 100644 index 0000000000..e4ae2d2aaf --- /dev/null +++ b/apps/kimi-code/test/tui/utils/tab-strip.test.ts @@ -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 43–45 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); + }); +}); From 1b0bdffa5e7cb820abc9546cbff9dc65a10a1995 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 20:33:25 +0800 Subject: [PATCH 08/14] docs(plugins): fix Kimi Datasource redirect anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/en/customization/datasource.md | 4 ++-- docs/zh/customization/datasource.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/en/customization/datasource.md b/docs/en/customization/datasource.md index 3a85a7ea62..4a24b5ab86 100644 --- a/docs/en/customization/datasource.md +++ b/docs/en/customization/datasource.md @@ -2,9 +2,9 @@ head: - - meta - http-equiv: refresh - content: 0; url=./plugins.html#kimi-datasource + content: 0; url=./plugins.html#official-plugins --- # Kimi Datasource -This page has moved to [Plugins: Kimi Datasource](./plugins.md#kimi-datasource). +This page has moved to [Plugins: Official Plugins](./plugins.md#official-plugins). diff --git a/docs/zh/customization/datasource.md b/docs/zh/customization/datasource.md index 3bd77509b2..4f967ee3ae 100644 --- a/docs/zh/customization/datasource.md +++ b/docs/zh/customization/datasource.md @@ -2,9 +2,9 @@ head: - - meta - http-equiv: refresh - content: 0; url=./plugins.html#kimi-datasource + content: 0; url=./plugins.html#官方插件 --- # Kimi Datasource -本页已迁移到 [Plugins:Kimi Datasource](./plugins.md#kimi-datasource)。 +本页已迁移到 [Plugins:官方插件](./plugins.md#官方插件)。 From 7efee3e2890c5346d09dbb90c84164662dc3aebd Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 20:53:10 +0800 Subject: [PATCH 09/14] 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/en/customization/datasource.md | 4 ++-- docs/en/customization/plugins.md | 12 ++++++------ docs/zh/customization/datasource.md | 4 ++-- docs/zh/customization/plugins.md | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/en/customization/datasource.md b/docs/en/customization/datasource.md index 4a24b5ab86..3a85a7ea62 100644 --- a/docs/en/customization/datasource.md +++ b/docs/en/customization/datasource.md @@ -2,9 +2,9 @@ head: - - meta - http-equiv: refresh - content: 0; url=./plugins.html#official-plugins + content: 0; url=./plugins.html#kimi-datasource --- # Kimi Datasource -This page has moved to [Plugins: Official Plugins](./plugins.md#official-plugins). +This page has moved to [Plugins: Kimi Datasource](./plugins.md#kimi-datasource). diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index d7f2bc9191..ed96e6f327 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -188,19 +188,19 @@ Plugin MCP servers start after `/reload` or in new sessions. To enable or disabl /reload ``` -## Official Plugins +## Kimi Datasource -The Kimi Code CLI official marketplace hosts reviewed official plugins. Currently available: +Kimi Datasource is the official Kimi Code data plugin. It lets you query financial market data, macroeconomic indicators, corporate registration records, and academic literature in natural language — no manual API calls or data account registration required. -**[Kimi Datasource](./datasource.md)** — Query financial market data, macroeconomic indicators, corporate registration records, and academic literature in natural language. +### Installation -Installation: +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 **Official** 2. Find **Kimi Datasource** and press `Enter` to install -3. Run `/reload` or `/new` after installation +3. After installation completes, run `/reload` or `/new` to activate the plugin -For data capabilities and usage examples, see the [Official Plugins documentation](./datasource.md). +Once installed, describe your need in natural language and Kimi Code will invoke the data capabilities, or trigger the query skill explicitly with `/skill:kimi-datasource`. ## Security Model diff --git a/docs/zh/customization/datasource.md b/docs/zh/customization/datasource.md index 4f967ee3ae..3bd77509b2 100644 --- a/docs/zh/customization/datasource.md +++ b/docs/zh/customization/datasource.md @@ -2,9 +2,9 @@ head: - - meta - http-equiv: refresh - content: 0; url=./plugins.html#官方插件 + content: 0; url=./plugins.html#kimi-datasource --- # Kimi Datasource -本页已迁移到 [Plugins:官方插件](./plugins.md#官方插件)。 +本页已迁移到 [Plugins:Kimi Datasource](./plugins.md#kimi-datasource)。 diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index c98a6f57c2..5d2f28aa05 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -188,19 +188,19 @@ Plugin MCP servers 会在 `/reload` 后或新会话中启动。启用或禁用 /reload ``` -## 官方插件 +## Kimi Datasource -Kimi Code CLI 官方 marketplace 收录了经过审核的官方插件。目前可用: +Kimi Datasource 是 Kimi Code 的官方数据 plugin,可以用自然语言查询金融行情、宏观经济、企业工商和学术文献,无需手动调 API 或注册数据账号。 -**[Kimi Datasource](./datasource.md)** — 通过自然语言查询金融行情、宏观经济、企业工商和学术文献。 +### 安装方式 -安装方式: +需要先通过 `/login` 完成 Kimi Code 账号的 OAuth 登录,plugin 依赖本地凭证访问数据服务。 1. 运行 `/plugins`,选择 **Official** 2. 找到 **Kimi Datasource**,按 `Enter` 安装 -3. 安装完成后运行 `/reload` 或 `/new` +3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin -数据能力、使用示例见[官方插件文档](./datasource.md)。 +安装后用自然语言描述需求,Kimi Code 会自动调用数据能力;也可以用 `/skill:kimi-datasource` 显式触发查询技能。 ## 安全模型 From 028108816b0cd62db127d16855a012865f23b6ba Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 21:03:32 +0800 Subject: [PATCH 10/14] docs(plugins): restore Installing-from-GitHub subheading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/en/customization/plugins.md | 4 +++- docs/zh/customization/plugins.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index ed96e6f327..3c26e093dd 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -34,7 +34,9 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | -**GitHub URL supports four forms:** +### Installing from GitHub + +Use `/plugins install ` to install directly from a GitHub repository. Four URL forms are supported: - `https://github.com//`: Install the latest release; falls back to the default branch if no release exists - `https://github.com///tree/`: Install a specific branch, tag, or short commit SHA diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 5d2f28aa05..de1cd450b6 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -34,7 +34,9 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**GitHub URL 支持四种形式:** +### 从 GitHub 安装 + +通过 `/plugins install ` 可以直接从 GitHub 仓库安装,支持四种 URL 形式: - `https://github.com//`:安装最新 release;无 release 时回落到默认分支 - `https://github.com///tree/`:安装指定分支、tag 或短 commit SHA From 582ad4f34478dd85f8d53a8f5cc42025d2b05dc9 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 21:22:30 +0800 Subject: [PATCH 11/14] 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/en/customization/plugins.md | 88 +++++++++++++++++++------------- docs/zh/customization/plugins.md | 88 +++++++++++++++++++------------- 2 files changed, 104 insertions(+), 72 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 3c26e093dd..9f0b5e94b4 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -25,7 +25,7 @@ You can also use slash commands directly: | `/plugins` | Open the interactive plugin manager | | `/plugins list` | List installed plugins | | `/plugins install ` | 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 ` | View plugin details and diagnostics | | `/plugins enable ` | Enable a plugin | | `/plugins disable ` | Disable a plugin | @@ -34,6 +34,8 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | +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 Use `/plugins install ` to install directly from a GitHub repository. Four URL forms are supported: @@ -45,15 +47,16 @@ Use `/plugins install ` to install directly from a GitHub repository. Four Network requests only go through `github.com` redirects and `codeload.github.com` downloads; `api.github.com` is not called. -The plugin manager shows each install's source and a trust badge. `kimi-official` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/official/`; `curated` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/curated/`. `third-party` marks anything else, including GitHub installs, local directories, custom marketplace sources, and other URLs. Marketplace `tier` is listing metadata; the installed trust badge still comes from the actual downloaded source. - -The **Official** and **Third-party** tabs list the marketplace catalog by tier — **Official** holds Kimi-maintained plugins and **Third-party** holds plugins from other publishers. Installed entries are listed first. Both tabs load lazily — opening `/plugins` is instant and works offline; only switching to either tab fetches the catalog, and a fetch failure is shown inline on the tab instead of closing the panel. The **Custom** tab installs a plugin straight from a GitHub URL (or zip URL / local path), without it being a marketplace listing. `/plugins marketplace` opens directly on the Official tab. +### Notes -By default, marketplace items are plugins: Kimi Code installs their `source` and tracks the install in `installed.json`. +- 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//`, 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. -For custom marketplace JSON, omit `type` or set `"type": "plugin"`, and provide a `source`. `source` may be a local path, a zip URL, or a GitHub repository URL. New CLIs accept `"type": "managed"` and the legacy `"type": "guide"` as aliases for `"plugin"`. +### Custom marketplace JSON -The marketplace JSON has a single `plugins` array. Do not split the same marketplace into separate old and new arrays. Old CLIs read the same list and ignore fields they do not understand. +Pass a custom marketplace JSON path or URL to `/plugins marketplace `, 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 { @@ -61,7 +64,6 @@ The marketplace JSON has a single `plugins` array. Do not split the same marketp "plugins": [ { "id": "my-plugin", - "type": "plugin", "displayName": "My Plugin", "source": "./my-plugin" } @@ -69,24 +71,52 @@ The marketplace JSON has a single `plugins` array. Do not split the same marketp } ``` -The marketplace JSON is a versioned contract. Keep existing field meanings stable, add optional fields when possible, and decide how each change behaves across CLI versions: +## Kimi Datasource -| Case | Rule | -| --- | --- | -| New CLI with old marketplace JSON | Works: missing `type` defaults to `plugin`, `"managed"` is accepted as a legacy alias, and legacy `url` / `downloadUrl` fields are still accepted as source aliases. | -| Old CLI with new plugin items | Works from the same `plugins` array when the item provides `source` and the source uses a manifest path the old CLI already supports; old CLIs ignore fields they do not understand. | -| Legacy `"type": "guide"` items | Treated as a normal plugin install; any `installSkill` / `removeSkill` fields are ignored. | -| Existing installed records | Keep working; the `installed.json` and managed plugin directory contract is unchanged. | -| New entry types or install behavior | Keep a single `plugins` array where possible. Use `version`, parser defaults, field aliases, and clear rejection rules; only add a separate artifact or publishing gate when one array cannot stay compatible. | +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. -If you operate a custom marketplace, apply the same rule to your own marketplace URL. Before changing fields or entry types, decide whether old CLIs should keep installing the same `plugins` list, ignore the new fields, or reject it with a clear error. +### Installation -**A few notes:** +You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services. -- Plugin changes apply after `/reload` or in new sessions. This includes newly installed or enabled Skills, same-name Skill updates, disabled or removed Skills, MCP servers, and `sessionStart.skill` changes. -- Local installations are copied to `$KIMI_CODE_HOME/plugins/managed//`, 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. +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 + +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 + +**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. + +**Cross-country macro comparison**: Studying supply-chain shifts across China, India, and Vietnam? Get complete GDP growth, trade volume, and demographic time-series from World Bank data spanning 50+ years, all in one go. + +**Pre-contract risk check**: Need to vet a counterparty fast? Type the company name and instantly get business registration, equity structure, litigation disputes, and credit blacklist status — right when you need it. + +**Literature review acceleration**: Tracing the research arc of RLHF? Get the most-cited papers, key authors, and core findings in seconds, so your literature review outline takes shape in half the time. + +**On-the-spot legal lookup**: Stuck on which statute governs a residence-right contract dispute? Pinpoint the relevant Civil Code articles — full text, authority level, and validity — then pull a few comparable precedents to back them up, without digging through statute databases. + +### Coverage + +| Category | Scope | +|---|---| +| Stock market data | A-shares, HK, US, and major global markets — real-time/historical prices, technical indicators, financial statements, stock screening | +| Macroeconomic data | World Bank data for 189 countries, 50+ years of time series (GDP, trade, population, climate, and more) | +| Corporate data | Business registration, equity chain, legal risk, and related-entity graph for mainland Chinese companies | +| 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 + +- 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 +- Technical indicators and real-time prices are only available during active trading hours +- AI-generated output is for reference only and does not constitute investment or business advice ## Plugin Manifest @@ -190,20 +220,6 @@ Plugin MCP servers start after `/reload` or in new sessions. To enable or disabl /reload ``` -## Kimi Datasource - -Kimi Datasource is the official Kimi Code data plugin. It lets you query financial market data, macroeconomic indicators, corporate registration records, and academic literature in natural language — no manual API calls or data account registration required. - -### Installation - -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 **Official** -2. Find **Kimi Datasource** and press `Enter` to install -3. After installation completes, run `/reload` or `/new` to activate the plugin - -Once installed, describe your need in natural language and Kimi Code will invoke the data capabilities, or trigger the query skill explicitly with `/skill:kimi-datasource`. - ## Security Model Plugins have a limited loading scope. The following operations do not occur during installation or session startup: diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index de1cd450b6..2ea3ae5e75 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -25,7 +25,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins` | 打开交互式 plugin 管理器 | | `/plugins list` | 列出已安装 plugins | | `/plugins install ` | 从本地目录、zip URL 或 GitHub 仓库 URL 安装 | -| `/plugins marketplace [source]` | 浏览官方 marketplace;可选传入 marketplace JSON 的路径或 URL | +| `/plugins marketplace [source]` | 浏览官方 marketplace,或传入自定义 marketplace JSON 的路径或 URL | | `/plugins info ` | 查看 plugin 详情和 diagnostics | | `/plugins enable ` | 启用 plugin | | `/plugins disable ` | 禁用 plugin | @@ -34,6 +34,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | +**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。切到对应 tab 时才会加载 marketplace 目录。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。 + ### 从 GitHub 安装 通过 `/plugins install ` 可以直接从 GitHub 仓库安装,支持四种 URL 形式: @@ -45,15 +47,16 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 网络请求只走 `github.com` 重定向和 `codeload.github.com` 下载,不调用 `api.github.com`。 -Plugin 管理器会展示每个安装的来源以及一个信任徽章。`kimi-official` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/official/`;`curated` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/curated/`。`third-party` 表示其它所有情况,包括 GitHub 安装、本地目录、自定义 marketplace source 和其它 URL。Marketplace `tier` 只是列表展示元数据;安装后的信任徽章仍按真实下载来源判断。 - -**Official** 和 **Third-party** 两个 tab 按 tier 列出 marketplace 目录——**Official** 是 Kimi 官方维护的 plugin,**Third-party** 是第三方 publisher 的 plugin。已安装的排在前面。这两个 tab 都是**懒加载**的——打开 `/plugins` 很快、离线也能用,只有切到 Official 或 Third-party 才会去拉目录,拉取失败会就地在 tab 里提示,而不是把整个面板关掉。**Custom** tab 可以直接输入 GitHub URL(或 zip URL / 本地路径)安装一个不在 marketplace 列表里的插件。`/plugins marketplace` 会直接打开到 Official tab。 +### 注意事项 -默认情况下,marketplace item 是 plugin:Kimi Code 会安装它的 `source`,并把安装记录写入 `installed.json`。 +- Plugin 变更需要通过 `/reload` 或新会话生效。安装、启用/禁用、移除后,运行 `/reload` 或 `/new`;当前会话不会更新。 +- 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。 +- 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。 +- Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。 -自定义 marketplace JSON 中,可以省略 `type` 或写 `"type": "plugin"`,并提供 `source`。`source` 可以是本地路径、zip URL 或 GitHub 仓库 URL。新版 CLI 把 `"type": "managed"` 和旧的 `"type": "guide"` 都作为 `"plugin"` 的别名处理。 +### 自定义 marketplace JSON -Marketplace JSON 只有一个 `plugins` 数组。不要把同一个 marketplace 拆成新旧两个数组。旧版 CLI 读取同一份列表,忽略它不认识的字段。 +浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace `;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id` 和 `source`(本地路径、zip URL 或 GitHub URL): ```json { @@ -61,7 +64,6 @@ Marketplace JSON 只有一个 `plugins` 数组。不要把同一个 marketplace "plugins": [ { "id": "my-plugin", - "type": "plugin", "displayName": "My Plugin", "source": "./my-plugin" } @@ -69,24 +71,52 @@ Marketplace JSON 只有一个 `plugins` 数组。不要把同一个 marketplace } ``` -Marketplace JSON 是一个带版本的契约。已有字段的含义要保持稳定;能新增可选字段时,不要改变旧字段含义。每次改字段或安装行为,都要先决定新旧 CLI 的处理方式: +## Kimi Datasource -| 场景 | 规则 | -| --- | --- | -| 新版 CLI 读取旧 marketplace JSON | 兼容:缺省 `type` 按 `plugin` 处理,`"managed"` 会作为旧别名接受,旧字段 `url` / `downloadUrl` 仍作为 `source` 的别名读取。 | -| 旧版 CLI 读取新的 plugin item | 读取同一个 `plugins` 数组;只要 item 提供 `source`,且该 source 使用旧版 CLI 已支持的 manifest 路径,旧版 CLI 会忽略它不认识的字段。 | -| 旧的 `"type": "guide"` 条目 | 当作普通 plugin 安装;其中的 `installSkill` / `removeSkill` 字段会被忽略。 | -| 已有 installed records | 继续可用,`installed.json` 与托管 plugin 目录的契约没有变化。 | -| 新增 entry type 或改变安装行为 | 尽量保持单一 `plugins` 数组。优先用 `version`、parser 默认值、字段别名和清晰拒绝规则;只有一个数组无法保持兼容时,才增加单独 artifact 或发布门禁。 | +Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直接查询金融行情、宏观经济、企业工商、学术文献和中国法律法规,无需手动调用接口或申请任何数据账号。 -如果你维护自定义 marketplace,也要把自己的 marketplace URL 当成版本化契约。改字段或 entry type 前,先决定旧版 CLI 应继续安装同一份 `plugins` 列表、忽略新增字段,还是给出清晰错误。 +### 安装 -**几点注意事项:** +需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。 -- Plugin 变更需要通过 `/reload` 或新会话生效,包括新安装或新启用的 Skills、已有同名 skill 的更新、禁用或移除的 skill、MCP servers 以及 `sessionStart.skill` 变更。 -- 本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed//`,CLI 始终从这份托管副本运行。安装后编辑原始源目录不会生效,需重新安装。 -- 移除 plugin 只会删除安装记录,托管副本和原始源文件仍保留在磁盘上。 -- Plugin 目前按用户安装,对所有项目生效,暂不支持项目级安装范围。 +1. 运行 `/plugins`,选择 **Official** +2. 找到 **Kimi Datasource**,按 `Enter` 安装 +3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin + +当前最新版本为 v3.2.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 + +### 使用方式 + +安装完成后,直接用自然语言描述你的需求,Kimi Code 会自动调用数据能力;也可以通过 `/skill:kimi-datasource` 明确触发数据查询 Skill。 + +### 能做什么 + +**实时量化研究**:盯着茅台想做个量化分析?一句话拉取近三年的每日收盘价、MACD 和 KDJ 信号,直接出结论,不用找第三方数据平台。 + +**跨国宏观对比**:研究中印越产业转移?基于世界银行 50 年历史数据,一次查询拿到三国 GDP 增速、贸易额、人口结构的完整时间序列对比。 + +**合同前风险排查**:签合同前五分钟才想起来要查对方背景?输入公司名,立刻拿到工商注册信息、股权穿透、司法纠纷和失信记录,当场决策。 + +**文献综述加速**:写论文要梳理 RLHF 领域的研究脉络?直接列出高引论文、主要作者和核心结论,综述提纲半小时内成型。 + +**法律条文速查**:碰上居住权的合同纠纷,拿不准法条?一句话定位《民法典》相关条文原文、效力级别和时效性,再顺手拉几个相近判例佐证,不用翻法规库。 + +### 数据覆盖 + +| 类别 | 覆盖范围 | +|---|---| +| 股票行情 | A 股、港股、美股及全球主要市场实时/历史行情、技术指标、财务报表、股票筛选 | +| 宏观经济 | 世界银行 189 个成员国、50 年以上历史时间序列(GDP、贸易、人口、气候等) | +| 企业数据 | 中国大陆境内企业工商信息、股权穿透、司法风险、关联图谱 | +| 学术文献 | 物理、数学、计算机、金融、经济等领域百万量级论文,支持预印本查询 | +| 法律法规 | 中国法律法规与司法案例:宪法、法律、司法解释、部门规章等各效力层次的法规语义/关键词检索与详情,普通及权威判例检索 | + +### 注意事项 + +- 数据查询按次计费,消耗 Kimi Code 账号额度 +- 插件为只读查询,不提供任何写入或交易功能 +- 技术指标(MACD、KDJ 等)及实时行情仅在交易时段内可用 +- AI 输出内容仅供参考,不构成任何投资或商业决策建议 ## Plugin manifest @@ -190,20 +220,6 @@ Plugin MCP servers 会在 `/reload` 后或新会话中启动。启用或禁用 /reload ``` -## Kimi Datasource - -Kimi Datasource 是 Kimi Code 的官方数据 plugin,可以用自然语言查询金融行情、宏观经济、企业工商和学术文献,无需手动调 API 或注册数据账号。 - -### 安装方式 - -需要先通过 `/login` 完成 Kimi Code 账号的 OAuth 登录,plugin 依赖本地凭证访问数据服务。 - -1. 运行 `/plugins`,选择 **Official** -2. 找到 **Kimi Datasource**,按 `Enter` 安装 -3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin - -安装后用自然语言描述需求,Kimi Code 会自动调用数据能力;也可以用 `/skill:kimi-datasource` 显式触发查询技能。 - ## 安全模型 Plugin 的加载范围有限,以下操作不会在安装或会话启动时发生: From 6715774ddfcbe173cd2e26fae0cffcd8038d7762 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 21:33:00 +0800 Subject: [PATCH 12/14] docs(plugins): fix heading style and drop Next steps section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/en/customization/plugins.md | 11 +++-------- docs/zh/customization/plugins.md | 7 +------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 9f0b5e94b4..3be6cb4411 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -85,11 +85,11 @@ You must first complete OAuth login with a Kimi Code account via `/login`. The p 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. @@ -111,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 @@ -229,8 +229,3 @@ Plugins have a limited loading scope. The following operations do not occur duri - 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 ` diagnostics and do not affect other sessions -## Next steps - -- [Kimi Datasource](./datasource.md) — Official data plugin: installation and usage for financial market data, corporate records, and academic literature -- [Agent Skills](./skills.md) — File format and frontmatter field reference for Skills -- [MCP](./mcp.md) — Full schema and permission configuration for plugin MCP servers diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 2ea3ae5e75..092d17f35b 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -111,7 +111,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 | 学术文献 | 物理、数学、计算机、金融、经济等领域百万量级论文,支持预印本查询 | | 法律法规 | 中国法律法规与司法案例:宪法、法律、司法解释、部门规章等各效力层次的法规语义/关键词检索与详情,普通及权威判例检索 | -### 注意事项 +### 计费与限制 - 数据查询按次计费,消耗 Kimi Code 账号额度 - 插件为只读查询,不提供任何写入或交易功能 @@ -229,8 +229,3 @@ Plugin 的加载范围有限,以下操作不会在安装或会话启动时发 - 已启用 plugin 的 MCP servers 会在 `/reload` 后或新会话中启动,且可随时从 `/plugins` 禁用 - 损坏的 manifest 或不安全路径会显示在 `/plugins info ` 的 diagnostics 中,不影响其他会话 -## 下一步 - -- [Kimi Datasource](./datasource.md) — 官方数据插件:金融行情、企业工商、学术文献的安装与使用 -- [Agent Skills](./skills.md) — Skills 的文件格式与 frontmatter 字段参考 -- [MCP](./mcp.md) — Plugin MCP servers 的完整 schema 与权限配置 From f177414b185cdf51eeadb4f0326500c6bab1f249 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 22:18:46 +0800 Subject: [PATCH 13/14] 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. --- apps/kimi-code/src/tui/commands/plugins.ts | 2 -- .../components/dialogs/plugins-selector.ts | 23 ++++++++++--------- .../dialogs/plugins-selector.test.ts | 22 +++++++++++++++--- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index af05f1ced6..f7fcaa0b89 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -163,7 +163,6 @@ async function showPluginsPicker( initialTab: options?.initialTab, selectedId: options?.selectedId, pluginHint: options?.pluginHint, - colors: host.state.theme.palette, 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 @@ -224,7 +223,6 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, - colors: host.state.theme.palette, onSelect: (selection) => { // Every MCP action re-mounts a picker, so let the handler do the // mounting — pre-restoring the editor here would flash on toggle. 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 652a3fb69b..4a51e4979c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -45,7 +45,6 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -101,7 +100,8 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors, info } = this.opts; + 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[] = [ @@ -132,7 +132,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; + 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); @@ -258,7 +258,6 @@ export type PluginsPanelSelection = export interface PluginsPanelOptions { readonly installed: readonly PluginSummary[]; readonly installedIds: ReadonlySet; - readonly colors: ColorPalette; readonly initialTab?: PluginsPanelTabId; readonly selectedId?: string; readonly pluginHint?: { readonly id: string; readonly text: string }; @@ -453,7 +452,7 @@ export class PluginsPanelComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; + const colors = currentTheme.palette; const tab = this.activeTab.id; const hint = tab === 'installed' @@ -485,7 +484,8 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderInstalled(lines: string[], width: number): void { - const { colors, installed } = this.opts; + const { installed } = this.opts; + const colors = currentTheme.palette; if (installed.length === 0) { lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); } else { @@ -498,7 +498,7 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { - const { colors } = this.opts; + 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); @@ -524,7 +524,7 @@ export class PluginsPanelComponent extends Container implements Focusable { width: number, entries: readonly PluginMarketplaceEntry[], ): void { - const { colors } = this.opts; + const colors = currentTheme.palette; if (this.market.status === 'loading' || this.market.status === 'idle') { lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…')); return; @@ -558,7 +558,7 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { - const { colors } = this.opts; + 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); @@ -575,9 +575,10 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderCustom(lines: string[], width: number): void { - lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', this.opts.colors)); + 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, this.opts.colors)); + lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); } } 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 87da8d28c7..c652c28a1a 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 @@ -9,7 +9,8 @@ import { 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 = /\u001b\[[0-9;]*m/g; @@ -69,7 +70,6 @@ function makePanel(opts: { const panel = new PluginsPanelComponent({ installed, installedIds: new Set(installed.map((p) => p.id)), - colors: darkColors, initialTab: opts.initialTab, selectedId: opts.selectedId, pluginHint: opts.pluginHint, @@ -145,6 +145,23 @@ describe('plugins selector dialogs', () => { expect(out).toContain('1 installed'); }); + 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('toggles an installed plugin with Space', () => { const { panel, onSelect } = makePanel({ installed: [superpowers] }); panel.handleInput(' '); @@ -308,7 +325,6 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, - colors: darkColors, onSelect: (selection) => { selections.push(selection); }, From 5b50532a33d3443f5aee1ca81465a5fe8146409e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 22:34:44 +0800 Subject: [PATCH 14/14] 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. --- apps/kimi-code/src/tui/commands/config.ts | 1 - apps/kimi-code/src/tui/commands/provider.ts | 2 -- .../dialogs/tabbed-model-selector.ts | 5 ++-- .../dialogs/tabbed-model-selector.test.ts | 26 +++++++++++++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 20333973a1..9b91d4ba01 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -309,7 +309,6 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentValue: host.state.appState.model, selectedValue, currentThinking: host.state.appState.thinking, - colors: host.state.theme.palette, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 98c33c5453..242252bfbc 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -234,7 +234,6 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), currentThinking: host.state.appState.thinking, - colors: host.state.theme.palette, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -325,7 +324,6 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise currentValue: host.state.appState.model, selectedValue: firstNewAlias, currentThinking: host.state.appState.thinking, - colors: host.state.theme.palette, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 4471a95bdd..15897e3ee6 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -22,7 +22,7 @@ import { type Focusable, } from '@earendil-works/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { renderTabStrip } from '#/tui/utils/tab-strip'; import { @@ -40,7 +40,6 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; - readonly colors: ColorPalette; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -105,7 +104,7 @@ export class TabbedModelSelectorComponent extends Container implements Focusable labels: this.tabs.map((tab) => tab.label), activeIndex: this.activeIndex, width, - colors: this.opts.colors, + colors: currentTheme.palette, }); const out: string[] = [ inner[0] ?? '', diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index d545103afb..bfd5ede216 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -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'); @@ -35,7 +36,6 @@ function make(): { }, currentValue: 'k2', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -45,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', () => { @@ -67,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');