diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index ff90e4914..8444a7d77 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -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 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 c5769def6..8e60a0eba 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -3,6 +3,7 @@ import { Input, Key, matchesKey, + ProcessTerminal, truncateToWidth, visibleWidth, type Focusable, @@ -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. @@ -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')), @@ -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]!; } @@ -429,6 +489,7 @@ 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; } @@ -436,6 +497,7 @@ export class PluginsPanelComponent extends Container implements Focusable { this.activeTabIndex = (this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length; this.selectedIndex = 0; + this.scrollOffset = 0; this.requestMarketplaceIfNeeded(); return; } @@ -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)); @@ -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(''); 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 05a9b3bef..ed2ca96fc 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 @@ -1,3 +1,4 @@ +import { ProcessTerminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; @@ -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>(); @@ -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, @@ -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'); + }); });