From d0182725b0956a37d48df3759b429730f1d28205 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 15:37:26 +0800 Subject: [PATCH 1/4] feat(tui): add Kimi WebBridge install entry to /plugins panel Surface a hardcoded Kimi WebBridge entry at the top of the Official tab in the /plugins panel. Selecting it opens the WebBridge install page in the user's browser instead of going through the plugin install flow, since WebBridge is a browser extension plus local daemon rather than an installable plugin package. --- .changeset/tui-web-bridge-plugin-entry.md | 5 ++ apps/kimi-code/src/tui/commands/plugins.ts | 7 +++ .../components/dialogs/plugins-selector.ts | 49 ++++++++++++++-- .../dialogs/plugins-selector.test.ts | 58 +++++++++++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 .changeset/tui-web-bridge-plugin-entry.md diff --git a/.changeset/tui-web-bridge-plugin-entry.md b/.changeset/tui-web-bridge-plugin-entry.md new file mode 100644 index 0000000000..30708d9712 --- /dev/null +++ b/.changeset/tui-web-bridge-plugin-entry.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index ff90e4914f..b69f0724a9 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -22,6 +22,7 @@ import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -411,6 +412,12 @@ async function handlePluginsPanelSelection( isOfficialPluginSource(selection.source), ); return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; } } 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 c5769def6f..17261f7ff4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -28,6 +28,24 @@ const INSTALL_TRUST_EXIT = 'exit'; const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; +// Hardcoded Web Bridge promotion: a built-in entry that always leads the +// Official tab, even when the marketplace catalog is unavailable. Selecting it +// opens the install page in the browser rather than installing from a source, +// because Web Bridge is a browser extension + daemon, not a plugin package. +const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge'; +const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + source: WEB_BRIDGE_URL, + tier: 'official', + homepage: WEB_BRIDGE_URL, + description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', +}; + +function isWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry.id === WEB_BRIDGE_ENTRY.id; +} + interface PluginsOverviewItem { readonly value: string; readonly kind: 'plugin' | 'action'; @@ -304,7 +322,8 @@ export type PluginsPanelSelection = | { readonly kind: 'details'; readonly id: string } | { readonly kind: 'reload' } | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { readonly kind: 'install-source'; readonly source: string }; + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; export interface PluginsPanelOptions { readonly installed: readonly PluginSummary[]; @@ -402,7 +421,16 @@ export class PluginsPanelComponent extends Container implements Focusable { } private get officialEntries(): readonly PluginMarketplaceEntry[] { - return this.marketplaceEntries.filter((entry) => entry.tier === 'official'); + // The hardcoded Web Bridge entry always leads the Official tab, even when + // the catalog is loading or unreachable. Dedupe by id so a catalog that + // also lists it does not render a second row. + return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + return this.marketplaceEntries.filter( + (entry) => entry.tier === 'official' && !isWebBridgeEntry(entry), + ); } private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { @@ -516,6 +544,10 @@ export class PluginsPanelComponent extends Container implements Focusable { if (matchesKey(data, Key.enter)) { const entry = entries[this.selectedIndex]; if (entry === undefined) return; + if (isWebBridgeEntry(entry)) { + this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); + return; + } this.opts.onSelect({ kind: 'install', entry }); } } @@ -622,6 +654,7 @@ export class PluginsPanelComponent extends Container implements Focusable { lines: string[], width: number, entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, ): void { const colors = currentTheme.palette; if (this.market.status === 'loading' || this.market.status === 'idle') { @@ -637,7 +670,7 @@ export class PluginsPanelComponent extends Container implements Focusable { 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)); + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); } } const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; @@ -649,7 +682,11 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderOfficial(lines: string[], width: number): void { - this.renderMarketplaceTab(lines, width, this.officialEntries); + // Web Bridge is pinned above the catalog and stays visible while the + // catalog loads or errors, since it's built into the TUI rather than + // fetched. Catalog rows shift down by one index to match. + lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); + this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1); } private renderThirdParty(lines: string[], width: number): void { @@ -662,7 +699,9 @@ export class PluginsPanelComponent extends Container implements Focusable { 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, this.installedVersions); + const status = isWebBridgeEntry(entry) + ? 'webpage' + : marketplaceEntryStatus(entry, this.installedVersions); const line = prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); const descWidth = Math.max(1, width - 4); 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 05a9b3beff..0f6d783edb 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 @@ -289,6 +289,64 @@ describe('plugins selector dialogs', () => { expect(out).toContain('0 installed · 1 available'); }); + it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => { + const { panel } = makePanel({ initialTab: 'official' }); + // The catalog is still loading, but the built-in Web Bridge entry is shown + // immediately because it is baked into the TUI, not fetched. + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge webpage'); + expect(out).toContain('Loading marketplace'); + }); + + it('keeps the Web Bridge entry visible when the Official catalog errors', () => { + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge webpage'); + expect(out).toContain('Marketplace unavailable: fetch failed'); + }); + + it('opens the Web Bridge webpage on Enter instead of installing', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + // Web Bridge is pinned at index 0, so Enter selects it directly. + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'open-url', + url: 'https://www.kimi.com/features/webbridge', + label: 'Kimi WebBridge', + }); + }); + + it('installs a catalog official entry after navigating past Web Bridge', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\u001B[B'); // ↓ → kimi-datasource + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-datasource' }), + }); + }); + + it('does not duplicate Web Bridge when the catalog also lists it', () => { + const entries = [ + { + id: 'kimi-webbridge', + tier: 'official' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ...officialEntries, + ]; + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + // The label should appear exactly once — the hardcoded row wins, the + // catalog copy is filtered out. + expect(out.split('Kimi WebBridge').length - 1).toBe(1); + }); + it('installs the selected Third-party entry on Enter', () => { const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); From c14347cb013641ec74b9f23b8d5258c73817898c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 9 Jul 2026 18:31:04 +0800 Subject: [PATCH 2/4] fix(tui): restrict WebBridge open-url shortcut to the pinned row Match the hardcoded pinned WebBridge entry by object reference instead of by id. A curated or custom marketplace entry on the Third-party tab can legitimately reuse the kimi-webbridge id; routing by id hijacked Enter on those rows and opened the WebBridge page instead of installing. The Official tab still dedupes a same-id official catalog entry so the pinned row is not duplicated. --- .../components/dialogs/plugins-selector.ts | 16 +++++++++---- .../dialogs/plugins-selector.test.ts | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 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 17261f7ff4..96b22c3472 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -42,8 +42,11 @@ const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', }; -function isWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { - return entry.id === WEB_BRIDGE_ENTRY.id; +// Only the hardcoded pinned row should open the WebBridge install page. Match +// by reference (not id) so a catalog entry on another tab that happens to +// reuse the same id still installs normally instead of being hijacked. +function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry === WEB_BRIDGE_ENTRY; } interface PluginsOverviewItem { @@ -428,8 +431,11 @@ export class PluginsPanelComponent extends Container implements Focusable { } private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + // Dedupe by id (not reference): if the official catalog also lists + // kimi-webbridge, the pinned row already represents it, so suppress the + // catalog copy to avoid a duplicate row on the Official tab. return this.marketplaceEntries.filter( - (entry) => entry.tier === 'official' && !isWebBridgeEntry(entry), + (entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id, ); } @@ -544,7 +550,7 @@ export class PluginsPanelComponent extends Container implements Focusable { if (matchesKey(data, Key.enter)) { const entry = entries[this.selectedIndex]; if (entry === undefined) return; - if (isWebBridgeEntry(entry)) { + if (isPinnedWebBridgeEntry(entry)) { this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); return; } @@ -699,7 +705,7 @@ export class PluginsPanelComponent extends Container implements Focusable { 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 = isWebBridgeEntry(entry) + const status = isPinnedWebBridgeEntry(entry) ? 'webpage' : marketplaceEntryStatus(entry, this.installedVersions); const line = 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 0f6d783edb..9b500b62bd 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 @@ -347,6 +347,29 @@ describe('plugins selector dialogs', () => { expect(out.split('Kimi WebBridge').length - 1).toBe(1); }); + it('installs a Third-party entry whose id matches the pinned WebBridge', () => { + // A curated/custom marketplace entry can legitimately reuse the + // kimi-webbridge id; on the Third-party tab it must install normally, not + // open the WebBridge page (that shortcut is reserved for the pinned row). + const entries = [ + { + id: 'kimi-webbridge', + tier: 'curated' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge install'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }), + }); + }); + it('installs the selected Third-party entry on Enter', () => { const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); From 40e431fefcbd44cef6d8bbabccd77671d7675409 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 9 Jul 2026 18:55:08 +0800 Subject: [PATCH 3/4] fix(tui): label WebBridge plugins row as "open in browser" The previous "webpage" status did not make it clear that selecting this row opens an external page rather than installing in-app. "open in browser" states the action directly and contrasts with the install label on regular plugin rows. --- apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts | 2 +- .../test/tui/components/dialogs/plugins-selector.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 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 96b22c3472..d7066c77ca 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -706,7 +706,7 @@ export class PluginsPanelComponent extends Container implements Focusable { const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = isPinnedWebBridgeEntry(entry) - ? 'webpage' + ? 'open in browser' : marketplaceEntryStatus(entry, this.installedVersions); const line = prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); 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 9b500b62bd..5bd80da5bf 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 @@ -294,7 +294,7 @@ describe('plugins selector dialogs', () => { // The catalog is still loading, but the built-in Web Bridge entry is shown // immediately because it is baked into the TUI, not fetched. const out = strip(renderRaw(panel)); - expect(out).toContain('Kimi WebBridge webpage'); + expect(out).toContain('Kimi WebBridge open in browser'); expect(out).toContain('Loading marketplace'); }); @@ -302,7 +302,7 @@ describe('plugins selector dialogs', () => { const { panel } = makePanel({ initialTab: 'official' }); panel.setMarketplaceError('fetch failed'); const out = strip(renderRaw(panel)); - expect(out).toContain('Kimi WebBridge webpage'); + expect(out).toContain('Kimi WebBridge open in browser'); expect(out).toContain('Marketplace unavailable: fetch failed'); }); From c862c196f19972c8b73c199043a7970e261986d7 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 9 Jul 2026 19:43:42 +0800 Subject: [PATCH 4/4] test(tui): navigate past pinned WebBridge row in marketplace install tests Two message-flow tests pressed Enter on the Official tab assuming index 0 was the Kimi Datasource entry. The hardcoded Kimi WebBridge row now leads that tab, so move down one row before installing. --- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 3503e42715..a014a45860 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 @@ -3781,6 +3781,9 @@ command = "vim" await vi.waitFor(() => { expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); panel.handleInput('\r'); await vi.waitFor(() => { @@ -3987,6 +3990,9 @@ command = "vim" await vi.waitFor(() => { expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); panel.handleInput('\r'); await vi.waitFor(() => {