Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async function showPluginsPicker(
initialTab: options?.initialTab,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
terminal: host.state.terminal,
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
Expand Down
74 changes: 72 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Input,
Key,
matchesKey,
ProcessTerminal,
truncateToWidth,
visibleWidth,
type Focusable,
Expand Down Expand Up @@ -312,6 +313,8 @@ export interface PluginsPanelOptions {
readonly initialTab?: PluginsPanelTabId;
readonly selectedId?: string;
readonly pluginHint?: { readonly id: string; readonly text: string };
/** Terminal used to compute viewport size for long plugin lists. */
readonly terminal?: ProcessTerminal;
readonly onSelect: (selection: PluginsPanelSelection) => void;
readonly onCancel: () => void;
/** Called the first time the Official or Third-party tab needs its catalog.
Expand All @@ -336,15 +339,19 @@ export class PluginsPanelComponent extends Container implements Focusable {
focused = false;

private readonly opts: PluginsPanelOptions;
private readonly terminal?: ProcessTerminal;
private readonly customInput = new Input();
private activeTabIndex: number;
private selectedIndex = 0;
/** Line offset of the topmost visible item line (used for long lists). */
private scrollOffset = 0;
private market: MarketState = { status: 'idle' };
private installing: string | undefined;

constructor(opts: PluginsPanelOptions) {
super();
this.opts = opts;
this.terminal = opts.terminal;
this.activeTabIndex = Math.max(
0,
PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')),
Expand Down Expand Up @@ -385,6 +392,59 @@ export class PluginsPanelComponent extends Container implements Focusable {
this.invalidate();
}

/** Number of rows the terminal reports, with a sane minimum fallback. */
private get terminalRows(): number {
return Math.max(10, this.terminal?.rows ?? 24);
}

/**
* Rows available for the plugin list after accounting for the panel chrome:
* top border, title, hint, tab strip, bottom border, and the footer lines
* rendered by each tab.
*/
private listAvailableRows(): number {
return Math.max(1, this.terminalRows - 10);
}

/**
* Given the fully-rendered lines for all items and the index of the first
* line of each item, return the slice that should be visible and update
* `scrollOffset` so the selected item stays in view.
*/
private visibleItemLines(
allItemLines: readonly string[],
itemStartIndices: readonly number[],
selectedIndex: number,
): string[] {
const availableRows = this.listAvailableRows();
const totalLines = allItemLines.length;
if (totalLines <= availableRows) {
this.scrollOffset = 0;
return [...allItemLines];
}

const selectedStart = itemStartIndices[selectedIndex] ?? 0;
const selectedEnd = (itemStartIndices[selectedIndex + 1] ?? totalLines) - 1;
const selectedHeight = selectedEnd - selectedStart + 1;

if (selectedStart < this.scrollOffset) {
// Scrolling up: keep the top of the selected item (pointer line) visible.
this.scrollOffset = selectedStart;
} else if (selectedEnd >= this.scrollOffset + availableRows) {
// Scrolling down. If the selected item is taller than the viewport,
// prioritize its first line so the pointer/name is never scrolled away.
this.scrollOffset =
selectedHeight > availableRows
? selectedStart
: selectedEnd - availableRows + 1;
}

const maxOffset = Math.max(0, totalLines - availableRows);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));

return allItemLines.slice(this.scrollOffset, this.scrollOffset + availableRows);
}

private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] {
return PLUGINS_PANEL_TABS[this.activeTabIndex]!;
}
Expand Down Expand Up @@ -429,13 +489,15 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (matchesKey(data, Key.tab)) {
this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length;
this.selectedIndex = 0;
this.scrollOffset = 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.scrollOffset = 0;
this.requestMarketplaceIfNeeded();
return;
}
Expand Down Expand Up @@ -566,9 +628,13 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (installed.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No plugins installed.'));
} else {
const allItemLines: string[] = [];
const itemStartIndices: number[] = [];
for (let i = 0; i < installed.length; i++) {
lines.push(...this.renderInstalledRow(installed[i]!, i, width));
itemStartIndices.push(allItemLines.length);
allItemLines.push(...this.renderInstalledRow(installed[i]!, i, width));
}
lines.push(...this.visibleItemLines(allItemLines, itemStartIndices, this.selectedIndex));
}
lines.push('');
lines.push(mutedHintLine(` ${installed.length} installed`, colors));
Expand Down Expand Up @@ -636,9 +702,13 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (entries.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
} else {
const allItemLines: string[] = [];
const itemStartIndices: number[] = [];
for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
itemStartIndices.push(allItemLines.length);
allItemLines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
}
lines.push(...this.visibleItemLines(allItemLines, itemStartIndices, this.selectedIndex));
}
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
lines.push('');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProcessTerminal } from '@moonshot-ai/pi-tui';
import { describe, expect, it, vi } from 'vitest';
import chalk from 'chalk';

Expand Down Expand Up @@ -73,6 +74,7 @@ function makePanel(opts: {
initialTab?: 'installed' | 'official' | 'third-party' | 'custom';
selectedId?: string;
pluginHint?: { id: string; text: string };
terminal?: { rows: number };
}) {
const installed = opts.installed ?? [];
const onSelect = vi.fn<(s: PluginsPanelSelection) => void>();
Expand All @@ -83,6 +85,7 @@ function makePanel(opts: {
initialTab: opts.initialTab,
selectedId: opts.selectedId,
pluginHint: opts.pluginHint,
terminal: opts.terminal as unknown as ProcessTerminal,
onSelect,
onCancel: vi.fn(),
onRequestMarketplace,
Expand Down Expand Up @@ -550,4 +553,56 @@ describe('plugins selector dialogs', () => {

expect(results).toEqual([{ kind: 'confirm' }]);
});

it('scrolls the marketplace viewport so the selected item stays visible', () => {
// 30 marketplace entries; terminal is only 12 rows high, so the list area
// is about 2 rows. We verify the viewport follows the cursor rather than
// dumping the entire list and clipping from the top.
const entries = Array.from({ length: 30 }, (_, i) => ({
id: `plugin-${i}`,
displayName: `Plugin ${i}`,
source: `https://example.test/plugin-${i}.zip`,
}));
const { panel } = makePanel({ initialTab: 'third-party', terminal: { rows: 12 } });
panel.setMarketplace(entries, '/tmp/marketplace.json');

const renderHeight = () => panel.render(80).length;

// The full rendered list would be far taller than 12 rows.
expect(renderHeight()).toBeLessThanOrEqual(12);

// Initially the first item is visible and selected.
let out = strip(renderRaw(panel));
expect(out).toContain('Plugin 0');
expect(out).toContain('? Plugin 0');

// Move the selection down to the last item.
for (let i = 0; i < 29; i++) {
panel.handleInput('\u001B[B'); // ↓
}

// The rendered output must still fit the terminal and show the selected
// last item with the selection pointer.
expect(renderHeight()).toBeLessThanOrEqual(12);
out = strip(renderRaw(panel));
expect(out).toContain('? Plugin 29');
expect(out).toContain('Plugin 29');
});

it('keeps the pointer line visible when the selected item is taller than the viewport', () => {
const longDescription =
'This plugin has a very long description that wraps to multiple lines even at a reasonable terminal width, so the selected item is taller than the computed viewport. The pointer line must remain visible.';
const entries = [
{ id: 'short', displayName: 'Short plugin', source: 'https://example.test/short.zip' },
{ id: 'tall', displayName: 'Tall plugin', source: 'https://example.test/tall.zip', description: longDescription },
];
// 12 rows => available list rows is 2, so the tall item exceeds the viewport.
const { panel } = makePanel({ initialTab: 'third-party', terminal: { rows: 12 } });
panel.setMarketplace(entries, '/tmp/marketplace.json');

panel.handleInput('\u001B[B'); // move to the tall item

const out = strip(renderRaw(panel));
expect(out).toContain('? Tall plugin');
});
});