Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-web-bridge-plugin-entry.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down
55 changes: 50 additions & 5 deletions apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ 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',
};

// 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 {
readonly value: string;
readonly kind: 'plugin' | 'action';
Expand Down Expand Up @@ -304,7 +325,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[];
Expand Down Expand Up @@ -402,7 +424,19 @@ 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[] {
// 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' && entry.id !== WEB_BRIDGE_ENTRY.id,
);
}

private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
Expand Down Expand Up @@ -516,6 +550,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (matchesKey(data, Key.enter)) {
const entry = entries[this.selectedIndex];
if (entry === undefined) return;
if (isPinnedWebBridgeEntry(entry)) {
this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName });
return;
}
this.opts.onSelect({ kind: 'install', entry });
}
}
Expand Down Expand Up @@ -622,6 +660,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') {
Expand All @@ -637,7 +676,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;
Expand All @@ -649,7 +688,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 {
Expand All @@ -662,7 +705,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 = isPinnedWebBridgeEntry(entry)
? 'open in browser'
: marketplaceEntryStatus(entry, this.installedVersions);
const line =
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
const descWidth = Math.max(1, width - 4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,87 @@ 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 open in browser');
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 open in browser');
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 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');
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading