mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(tui): add Kimi WebBridge install entry to /plugins panel (#1494)
* 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. * 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. * 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. * 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.
This commit is contained in:
parent
170ae44205
commit
a3548035a8
5 changed files with 149 additions and 5 deletions
5
.changeset/tui-web-bridge-plugin-entry.md
Normal file
5
.changeset/tui-web-bridge-plugin-entry.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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[];
|
||||
|
|
@ -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[] {
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue