From 3daa58fd105f2d9a2f0997c1221c9e4eab014485 Mon Sep 17 00:00:00 2001 From: mikewong23571 Date: Wed, 1 Jul 2026 08:21:30 +0800 Subject: [PATCH] fix(plugins): add viewport scrolling to marketplace and installed lists The PluginsPanelComponent rendered every plugin entry regardless of terminal height. When the list exceeded the screen, the framework clipped from the top, so the default selection (first item) was off screen and arrow-key navigation provided no visible feedback. Add a line-based viewport: - Pass the terminal from the slash-command host to the panel. - Compute available rows after accounting for panel chrome. - Pre-render all item lines and slice only the visible window. - Adjust scrollOffset so the selected item stays in view when the user moves the cursor. Also reset scrollOffset when switching tabs. Fixes marketplace navigation on long plugin lists. --- apps/kimi-code/src/tui/commands/plugins.ts | 1 + .../components/dialogs/plugins-selector.ts | 74 ++++++++++++++++++- .../dialogs/plugins-selector.test.ts | 55 ++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) 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'); + }); });