From d5c285462f72b542bea7cecf010f52cd091bc87c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:43:41 +0000 Subject: [PATCH 1/3] Initial plan From b16935f1fce72d8fefbbeccbfb52e24ba847a7b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:54:54 +0000 Subject: [PATCH 2/3] feat: add VirtualList class and --virtualize flag for work-item tree Agent-Logs-Url: https://github.com/TheWizardsCode/ContextHub/sessions/845ce879-be31-4e91-9a8f-897061b43acb Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- src/commands/tui.ts | 2 + src/tui/controller.ts | 172 +++++++++++++++++++++------ src/tui/layout.ts | 28 +++++ src/tui/virtual-list.ts | 172 +++++++++++++++++++++++++++ test/tui/virtual-list.test.ts | 218 ++++++++++++++++++++++++++++++++++ 5 files changed, 554 insertions(+), 38 deletions(-) create mode 100644 src/tui/virtual-list.ts create mode 100644 test/tui/virtual-list.test.ts diff --git a/src/commands/tui.ts b/src/commands/tui.ts index 507b87a9..f86ec2bc 100644 --- a/src/commands/tui.ts +++ b/src/commands/tui.ts @@ -33,6 +33,7 @@ export default function register(ctx: PluginContext): void { .option('--all', 'Include completed/deleted items in the list') .option('--prefix ', 'Override the default prefix') .option('--perf', 'Enable performance instrumentation (write perf metrics and show perf debug output)') + .option('--virtualize', 'Use virtual-scroll rendering (only materializes visible rows; improves performance for large lists)') .action(async (options: TuiOptions) => { // Forward the perf flag to the controller so instrumentation can be enabled await controller.start(options); @@ -45,4 +46,5 @@ interface TuiOptions { prefix?: string; all?: boolean; perf?: boolean; + virtualize?: boolean; } diff --git a/src/tui/controller.ts b/src/tui/controller.ts index cca41feb..3b5674a2 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -127,7 +127,7 @@ export class TuiController { private readonly deps: TuiControllerDeps = {} ) {} - async start(options: { inProgress?: boolean; prefix?: string; all?: boolean; perf?: boolean }): Promise { + async start(options: { inProgress?: boolean; prefix?: string; all?: boolean; perf?: boolean; virtualize?: boolean }): Promise { const { program, utils } = this.ctx; // Allow tests to inject a mocked blessed implementation via the ctx object. // If not provided, fall back to the real blessed import. @@ -145,6 +145,7 @@ export class TuiController { const db = utils.getDatabase(options.prefix); const isVerbose = !!program.opts().verbose; const perfEnabled = Boolean((options as any).perf); + const virtualizeEnabled = Boolean((options as any).virtualize); // Debug logging helper. Emit when either verbose mode is enabled or @@ -181,12 +182,13 @@ export class TuiController { blessed: blessedImpl, screenOptions: { terminal: TUI_FALLBACK_TERMINAL }, disableColorCapabilityOverride: true, + virtualize: virtualizeEnabled, }; const currentTerm = process.env.TERM || 'unknown'; const useSafeTerminalFallback = shouldUseSafeTerminalFallback(); const initialLayoutOptions = useSafeTerminalFallback ? fallbackLayoutOptions - : { blessed: blessedImpl }; + : { blessed: blessedImpl, virtualize: virtualizeEnabled }; if (useSafeTerminalFallback) { console.error(`[wl tui] TERM=${currentTerm} can trigger tmux terminfo parse issues; using fallback terminal ${TUI_FALLBACK_TERMINAL}.`); @@ -222,6 +224,18 @@ export class TuiController { opencodeUi, } = layout; const list = listComponent.getList(); + /** Virtual-scroll viewport manager — present only when --virtualize is set. */ + const vl = layout.virtualList; + + /** + * Returns the globally-correct selected index into the visible-nodes array. + * When virtual scrolling is active the blessed list only holds a viewport + * slice, so `list.selected` is relative to that slice; we add the offset. + */ + const getGlobalSelectedIndex = (): number => { + const viewportIdx = typeof list.selected === 'number' ? (list.selected as number) : 0; + return vl ? vl.offset + viewportIdx : viewportIdx; + }; // Register quit key early so Ctrl-C works even when we take the // early-return path for the empty-state UI. Prefer the full // shutdown() helper when it's available; otherwise perform a @@ -1811,11 +1825,25 @@ export class TuiController { return line; }); state.listLines = lines; - list.setItems(lines); - // Keep selection in bounds - const idx = Math.max(0, Math.min(selectIndex, lines.length - 1)); - list.select(idx); - updateDetailForIndex(idx, visible); + // ── Virtual-scroll rendering ────────────────────────────────────── + if (vl) { + // Update viewport height from the current list widget dimensions, + // subtracting 2 for the border rows. Fall back to stored height. + const listH = typeof list.height === 'number' ? list.height as number : 0; + if (listH > 2) vl.setViewportHeight(listH - 2); + vl.setTotalItems(lines.length); + vl.selectAbsolute(Math.max(0, Math.min(lines.length - 1, selectIndex))); + const viewportLines = vl.slice(lines); + list.setItems(viewportLines); + list.select(vl.selectedIndexInViewport); + updateDetailForIndex(vl.selectedIndex, visible); + } else { + list.setItems(lines); + // Keep selection in bounds + const idx = Math.max(0, Math.min(selectIndex, lines.length - 1)); + list.select(idx); + updateDetailForIndex(idx, visible); + } // Update footer/help try { if (state.moveMode) { @@ -2279,7 +2307,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { // selection when possible. Capture them before we replace // state.items below. const beforeRefreshSelectedId = getSelectedItem()?.id; - const beforeRefreshSelectedIndex = typeof (list as any).selected === 'number' ? (list as any).selected as number : undefined; + const beforeRefreshSelectedIndex = getGlobalSelectedIndex(); const child = spawnImpl('wl', args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; @@ -2417,7 +2445,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (watchDebounce) clearTimeout(watchDebounce); watchDebounce = setTimeout(() => { watchDebounce = null; - const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0; + const selectedIndex = getGlobalSelectedIndex(); // If the watcher provided a specific filename (eg. 'worklog.db' or // 'worklog.db-wal') then refresh unconditionally — the event // directly refers to the database file. When filename is undefined @@ -2502,7 +2530,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { } function getSelectedItem(): Item | null { - const idx = list.selected as number; + const idx = getGlobalSelectedIndex(); const visible = buildVisible(); const node = visible[idx] || visible[0]; return node?.item || null; @@ -2573,7 +2601,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { expandInProgressAncestors(); const visible = buildVisible(); const movedIndex = visible.findIndex(node => node.item.id === selected.id); - renderListAndDetail(movedIndex >= 0 ? movedIndex : (list.selected as number)); + renderListAndDetail(movedIndex >= 0 ? movedIndex : (getGlobalSelectedIndex())); } async function copySelectedId() { @@ -2598,7 +2626,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { showToast('No item selected'); return; } - const currentIndex = list.selected as number; + const currentIndex = getGlobalSelectedIndex(); const nextIndex = Math.max(0, currentIndex - 1); if (stage === 'deleted') { @@ -2943,7 +2971,11 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { const updateListSelection = (idx: number, source?: string) => { const scrollStart = perfEnabled ? performance.now() : null; const visible = buildVisible(); - updateDetailForIndex(idx, visible); + // In virtual-scroll mode the caller may pass a viewport-relative index. + // Convert to the global (full-list) index before updating the detail pane. + const globalIdx = vl ? vl.offset + idx : idx; + if (vl) vl.selectAbsolute(globalIdx); + updateDetailForIndex(globalIdx, visible); screen.render(); if (perfEnabled && scrollStart !== null) { const scrollEnd = performance.now(); @@ -2966,12 +2998,64 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { }; try { (list as any).__opencode_select_item = listSelectItemHandler; list.on('select item', listSelectItemHandler); } catch (_) {} - // Update details immediately when navigating with keys or mouse + // Update details immediately when navigating with keys or mouse. + // When virtual scrolling is active we also detect viewport-edge navigation + // and scroll the viewport window to follow the cursor. const listKeypressHandler = (_ch: any, key: any) => { try { const nav = key && key.name && ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end'].includes(key.name); - if (nav) { - const idx = list.selected as number; + if (!nav) return; + if (vl) { + const viewportIdx = getGlobalSelectedIndex(); + const totalVisible = vl.totalItems; + if (totalVisible === 0) return; + + const maxViewportIdx = Math.min(vl.viewportHeight, totalVisible - vl.offset) - 1; + const isUp = key.name === 'up' || key.name === 'k'; + const isDown = key.name === 'down' || key.name === 'j'; + const isPageUp = key.name === 'pageup'; + const isPageDown = key.name === 'pagedown'; + const isHome = key.name === 'home'; + const isEnd = key.name === 'end'; + + if (isHome) { + renderListAndDetail(0); + return; + } + if (isEnd) { + renderListAndDetail(totalVisible - 1); + return; + } + if (isPageUp) { + renderListAndDetail(Math.max(0, vl.selectedIndex - vl.viewportHeight)); + return; + } + if (isPageDown) { + renderListAndDetail(Math.min(totalVisible - 1, vl.selectedIndex + vl.viewportHeight)); + return; + } + + // At the top edge of the viewport, pressing up should scroll the window. + if (isUp && viewportIdx === 0) { + if (vl.offset > 0) { + vl.scrollBy(-1); + renderListAndDetail(vl.selectedIndex); + } + return; + } + // At the bottom edge of the viewport, pressing down should scroll the window. + if (isDown && viewportIdx >= maxViewportIdx) { + if (vl.offset + vl.viewportHeight < totalVisible) { + vl.scrollBy(1); + renderListAndDetail(vl.selectedIndex); + } + return; + } + + // Normal movement within viewport: update selection and detail. + updateListSelection(viewportIdx, 'keypress'); + } else { + const idx = getGlobalSelectedIndex(); updateListSelection(idx, 'keypress'); } } catch (err) { @@ -3067,12 +3151,12 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (!sourceItem?.parentId) { showToast(`${sourceItem?.title || sourceId} is already at root level`); exitMoveMode(state); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } try { const updated = db.update(sourceId, { parentId: null }); - if (!updated) { showToast('Move failed'); exitMoveMode(state); renderListAndDetail(list.selected as number); return; } + if (!updated) { showToast('Move failed'); exitMoveMode(state); renderListAndDetail(getGlobalSelectedIndex()); return; } invalidateDetailCache(sourceId); showToast(`Moved ${sourceItem?.title || sourceId} to root level`); } catch (err) { showToast('Move failed'); } @@ -3086,7 +3170,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { // Reparent under target try { const updated = db.update(sourceId, { parentId: targetId }); - if (!updated) { showToast('Move failed'); exitMoveMode(state); renderListAndDetail(list.selected as number); return; } + if (!updated) { showToast('Move failed'); exitMoveMode(state); renderListAndDetail(getGlobalSelectedIndex()); return; } invalidateDetailCache(sourceId); const sourceItem = state.itemsById.get(sourceId); const targetItem = state.itemsById.get(targetId); @@ -3100,7 +3184,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (mIdx >= 0) renderListAndDetail(mIdx); return; } - const idx = list.selected as number; + const idx = getGlobalSelectedIndex(); const visible = buildVisible(); const node = visible[idx]; if (node && node.hasChildren) { @@ -3111,7 +3195,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { screen.key(KEY_NAV_LEFT, () => { if (!updateDialog.hidden) return; - const idx = list.selected as number; + const idx = getGlobalSelectedIndex(); const visible = buildVisible(); const node = visible[idx]; if (!node) return; @@ -3140,7 +3224,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { // Toggle expand/collapse with space screen.key(KEY_TOGGLE_EXPAND, () => { const start = performance.now(); - const idx = list.selected as number; + const idx = getGlobalSelectedIndex(); const visible = buildVisible(); const node = visible[idx]; if (!node || !node.hasChildren) { @@ -3262,7 +3346,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (state.moveMode) { exitMoveMode(state); showToast('Move cancelled'); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } // Do not shut down the entire TUI on a bare Escape press when no @@ -3616,7 +3700,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { invalidateDetailCache(item.id); showToast(has ? 'Do-not-delegate: OFF' : 'Do-not-delegate: ON'); // Refresh list and detail keeping selection - refreshFromDatabase(list.selected as number); + refreshFromDatabase(getGlobalSelectedIndex()); } catch (err) { showToast('Update failed'); } @@ -3694,7 +3778,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (result.success) { // Refresh the list to show updated status/assignee - refreshFromDatabase(list.selected as number); + refreshFromDatabase(getGlobalSelectedIndex()); const url = result.issueUrl || `Issue #${result.issueNumber || '?'}`; showToast(`Delegated: ${url}`); @@ -3815,7 +3899,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { } invalidateDetailCache(item.id); showToast(nextValue ? 'Needs review: ON' : 'Needs review: OFF'); - refreshFromDatabase(list.selected as number); + refreshFromDatabase(getGlobalSelectedIndex()); } catch (err) { showToast('Update failed'); } @@ -3837,7 +3921,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { // Enter move mode: store the source item enterMoveMode(state, item.id); showToast('Move mode: select target, press m/Enter; Esc to cancel'); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } @@ -3856,7 +3940,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (!sourceItem?.parentId) { showToast(`${sourceItem?.title || sourceId} is already at root level`); exitMoveMode(state); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } try { @@ -3864,7 +3948,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { if (!updated) { showToast('Move failed'); exitMoveMode(state); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } invalidateDetailCache(sourceId); @@ -3891,7 +3975,7 @@ const visible = buildVisible(); if (!updated) { showToast('Move failed'); exitMoveMode(state); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } invalidateDetailCache(sourceId); @@ -4024,7 +4108,7 @@ const visible = buildVisible(); state.showClosed = !state.showClosed; rebuildTree(); expandInProgressAncestors(); - renderListAndDetail(list.selected as number); + renderListAndDetail(getGlobalSelectedIndex()); return; } } catch (err) { @@ -4155,7 +4239,7 @@ const visible = buildVisible(); } invalidateDetailCache(item.id); showToast('Updated'); - refreshFromDatabase(Math.max(0, (list.selected as number) - 0)); + refreshFromDatabase(Math.max(0, (getGlobalSelectedIndex()) - 0)); } catch (err) { const message = err instanceof Error ? err.message @@ -4298,12 +4382,24 @@ const visible = buildVisible(); if (coords && coords.row >= 0) { const scroll = (list as any).childBase ?? 0; const lineIndex = coords.row + scroll; - if (lineIndex >= 0 && lineIndex < state.listLines.length) { - if (typeof list.select === 'function') list.select(lineIndex); - updateListSelection(lineIndex, 'screen-mouse'); - list.focus(); - paneFocusIndex = getFocusPanes().indexOf(list); - applyFocusStylesForPane(list); + if (vl) { + // In virtual mode, lineIndex is relative to the viewport slice. + // Convert to a global index before using it. + const globalLineIndex = vl.offset + lineIndex; + if (globalLineIndex >= 0 && globalLineIndex < state.listLines.length) { + renderListAndDetail(globalLineIndex); + list.focus(); + paneFocusIndex = getFocusPanes().indexOf(list); + applyFocusStylesForPane(list); + } + } else { + if (lineIndex >= 0 && lineIndex < state.listLines.length) { + if (typeof list.select === 'function') list.select(lineIndex); + updateListSelection(lineIndex, 'screen-mouse'); + list.focus(); + paneFocusIndex = getFocusPanes().indexOf(list); + applyFocusStylesForPane(list); + } } } } diff --git a/src/tui/layout.ts b/src/tui/layout.ts index 1a022d6e..2d9b51fd 100644 --- a/src/tui/layout.ts +++ b/src/tui/layout.ts @@ -25,6 +25,7 @@ import { OverlaysComponent, ToastComponent, } from './components/index.js'; +import { VirtualList } from './virtual-list.js'; // ── Public types ───────────────────────────────────────────────────── @@ -55,6 +56,12 @@ export interface TuiLayout { // "Next recommendation" dialog (raw blessed widgets, not yet wrapped in a component) nextDialog: NextDialogWidgets; + + /** + * Virtual-scroll manager for the work-item list. + * Present only when `CreateLayoutOptions.virtualize` is `true`. + */ + virtualList?: VirtualList; } // ── Options ────────────────────────────────────────────────────────── @@ -75,6 +82,16 @@ export interface CreateLayoutOptions { * Useful for startup fallback paths where terminfo parsing is unreliable. */ disableColorCapabilityOverride?: boolean; + + /** + * When `true`, a {@link VirtualList} viewport manager is created and + * returned in `layout.virtualList`. The caller is responsible for using + * it to compute which slice of items to pass to `listComponent.setItems()` + * and for keeping the selection in sync via `virtualList.selectAbsolute()`. + * + * Enabled via the `--virtualize` CLI flag. + */ + virtualize?: boolean; } // ── Factory ────────────────────────────────────────────────────────── @@ -240,6 +257,16 @@ export function createLayout(options: CreateLayoutOptions = {}): TuiLayout { blessed: blessedImpl, }).create(); + // ── Virtual list (optional) ───────────────────────────────────────── + let virtualList: VirtualList | undefined; + if (options.virtualize) { + // Viewport height heuristic: list occupies ~50% of the screen height + // minus borders (2 rows). Defaults to 20 until a real height is known; + // callers should call virtualList.setViewportHeight() after layout. + const initialHeight = Math.max(1, (screen.height as number) / 2 - 2) || 20; + virtualList = new VirtualList({ totalItems: 0, viewportHeight: initialHeight }); + } + // ── Return layout ─────────────────────────────────────────────────── return { screen, @@ -253,6 +280,7 @@ export function createLayout(options: CreateLayoutOptions = {}): TuiLayout { helpMenu, modalDialogs, opencodeUi, + ...(virtualList !== undefined ? { virtualList } : {}), nextDialog: { overlay: nextOverlay, dialog: nextDialogBox, diff --git a/src/tui/virtual-list.ts b/src/tui/virtual-list.ts new file mode 100644 index 00000000..4d5fbab4 --- /dev/null +++ b/src/tui/virtual-list.ts @@ -0,0 +1,172 @@ +/** + * VirtualList — lightweight virtual-scroll viewport for the work-item tree. + * + * Keeps track of a contiguous *viewport* window over a flat array of item + * labels so that only the visible rows need to be passed to the blessed List + * widget at any given time. All indexing arithmetic lives here so it can be + * tested independently of the blessed runtime. + * + * Introduced as part of the `--virtualize` flag (WL virtual-scroll feature). + */ + +export interface VirtualListOptions { + /** Total number of items in the full list. */ + totalItems: number; + /** Number of rows the viewport can display at once. */ + viewportHeight: number; +} + +export class VirtualList { + private _totalItems: number; + private _viewportHeight: number; + + /** Index of the first item currently visible (0-based). */ + private _offset: number = 0; + + /** Index of the selected item within the full list (0-based). */ + private _selectedIndex: number = 0; + + constructor(options: VirtualListOptions) { + this._totalItems = Math.max(0, options.totalItems); + this._viewportHeight = Math.max(1, options.viewportHeight); + } + + // ── Accessors ───────────────────────────────────────────────────── + + get totalItems(): number { + return this._totalItems; + } + + get viewportHeight(): number { + return this._viewportHeight; + } + + /** The scroll offset: index of the first visible item. */ + get offset(): number { + return this._offset; + } + + /** The globally selected index (into the full item list). */ + get selectedIndex(): number { + return this._selectedIndex; + } + + /** + * The selected index relative to the current viewport window + * (i.e. the row that should be highlighted inside the blessed List). + */ + get selectedIndexInViewport(): number { + return this._selectedIndex - this._offset; + } + + // ── Mutation helpers ────────────────────────────────────────────── + + /** + * Update the total number of items (e.g. after the tree is rebuilt). + * Clamps existing offset/selection to valid ranges. + */ + setTotalItems(n: number): void { + this._totalItems = Math.max(0, n); + this._clamp(); + } + + /** + * Update the viewport height (e.g. on terminal resize). + * Re-clamps the offset so the selection remains visible. + */ + setViewportHeight(h: number): void { + this._viewportHeight = Math.max(1, h); + this._clamp(); + } + + /** + * Move the selection by `delta` rows (positive = down, negative = up). + * Scrolls the viewport window to follow the cursor. + */ + moveBy(delta: number): void { + this._selectedIndex = Math.max( + 0, + Math.min(this._totalItems - 1, this._selectedIndex + delta), + ); + this._scrollToSelection(); + } + + /** + * Jump the selection to an absolute index in the full list. + */ + selectAbsolute(index: number): void { + this._selectedIndex = Math.max( + 0, + Math.min(this._totalItems - 1, index), + ); + this._scrollToSelection(); + } + + /** + * Scroll the viewport by `delta` rows without moving the selection + * (clamped to valid range). Updates selection if it falls outside the + * new viewport. + */ + scrollBy(delta: number): void { + this._offset = Math.max( + 0, + Math.min(this._maxOffset(), this._offset + delta), + ); + // Keep selection within the new viewport + if (this._selectedIndex < this._offset) { + this._selectedIndex = this._offset; + } + const lastVisible = this._offset + this._viewportHeight - 1; + if (this._selectedIndex > lastVisible) { + this._selectedIndex = lastVisible; + } + this._clamp(); + } + + // ── Slice helper ────────────────────────────────────────────────── + + /** + * Return the slice of `allItems` that should be visible in the current + * viewport. `allItems` must have exactly `totalItems` entries. + */ + slice(allItems: T[]): T[] { + const end = Math.min(this._offset + this._viewportHeight, allItems.length); + return allItems.slice(this._offset, end); + } + + // ── Private helpers ─────────────────────────────────────────────── + + private _maxOffset(): number { + return Math.max(0, this._totalItems - this._viewportHeight); + } + + /** Ensure offset and selectedIndex are within valid bounds. */ + private _clamp(): void { + this._selectedIndex = Math.max( + 0, + Math.min(this._totalItems > 0 ? this._totalItems - 1 : 0, this._selectedIndex), + ); + this._offset = Math.max(0, Math.min(this._maxOffset(), this._offset)); + // If selection is now outside viewport, re-scroll + this._scrollToSelection(); + } + + /** + * Adjust `_offset` so the selected item is always visible. + * Uses a "scroll-ahead" of 0 (selection lands exactly at edge). + */ + private _scrollToSelection(): void { + if (this._totalItems === 0) { + this._offset = 0; + return; + } + if (this._selectedIndex < this._offset) { + this._offset = this._selectedIndex; + } + const lastVisible = this._offset + this._viewportHeight - 1; + if (this._selectedIndex > lastVisible) { + this._offset = this._selectedIndex - this._viewportHeight + 1; + } + this._offset = Math.max(0, Math.min(this._maxOffset(), this._offset)); + } +} diff --git a/test/tui/virtual-list.test.ts b/test/tui/virtual-list.test.ts new file mode 100644 index 00000000..f43cb30e --- /dev/null +++ b/test/tui/virtual-list.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { VirtualList } from '../../src/tui/virtual-list.js'; + +describe('VirtualList', () => { + // ── Constructor ──────────────────────────────────────────────────── + + it('initializes with correct defaults', () => { + const vl = new VirtualList({ totalItems: 100, viewportHeight: 20 }); + expect(vl.totalItems).toBe(100); + expect(vl.viewportHeight).toBe(20); + expect(vl.offset).toBe(0); + expect(vl.selectedIndex).toBe(0); + expect(vl.selectedIndexInViewport).toBe(0); + }); + + it('clamps negative totalItems to 0', () => { + const vl = new VirtualList({ totalItems: -5, viewportHeight: 10 }); + expect(vl.totalItems).toBe(0); + }); + + it('clamps viewportHeight below 1 to 1', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 0 }); + expect(vl.viewportHeight).toBe(1); + }); + + // ── slice() ──────────────────────────────────────────────────────── + + it('slice() returns the first viewport window', () => { + const items = Array.from({ length: 50 }, (_, i) => `item-${i}`); + const vl = new VirtualList({ totalItems: 50, viewportHeight: 10 }); + expect(vl.slice(items)).toEqual(items.slice(0, 10)); + }); + + it('slice() returns correct window after scrolling down', () => { + const items = Array.from({ length: 50 }, (_, i) => `item-${i}`); + const vl = new VirtualList({ totalItems: 50, viewportHeight: 10 }); + vl.moveBy(15); // moves selection to index 15, scrolls viewport + const sliced = vl.slice(items); + expect(sliced.length).toBe(10); + expect(sliced[0]).toBe(`item-${vl.offset}`); + }); + + it('slice() handles items shorter than viewportHeight', () => { + const items = ['a', 'b', 'c']; + const vl = new VirtualList({ totalItems: 3, viewportHeight: 10 }); + expect(vl.slice(items)).toEqual(['a', 'b', 'c']); + }); + + it('slice() returns empty array for empty list', () => { + const vl = new VirtualList({ totalItems: 0, viewportHeight: 10 }); + expect(vl.slice([])).toEqual([]); + }); + + // ── moveBy() ────────────────────────────────────────────────────── + + it('moveBy(1) increments selectedIndex', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 5 }); + vl.moveBy(1); + expect(vl.selectedIndex).toBe(1); + }); + + it('moveBy does not go below 0', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 5 }); + vl.moveBy(-5); + expect(vl.selectedIndex).toBe(0); + expect(vl.offset).toBe(0); + }); + + it('moveBy does not exceed totalItems - 1', () => { + const vl = new VirtualList({ totalItems: 5, viewportHeight: 3 }); + vl.moveBy(100); + expect(vl.selectedIndex).toBe(4); + }); + + it('moveBy scrolls viewport when selection exits bottom', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + // Selection is at 0, viewport is [0..4]. Moving by 5 puts selection at 5. + vl.moveBy(5); + expect(vl.selectedIndex).toBe(5); + // Viewport must have scrolled so selection is visible + expect(vl.offset).toBeLessThanOrEqual(5); + expect(vl.offset + vl.viewportHeight - 1).toBeGreaterThanOrEqual(5); + expect(vl.selectedIndexInViewport).toBe(vl.selectedIndex - vl.offset); + }); + + it('moveBy scrolls viewport when selection exits top', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.selectAbsolute(10); + const offsetAfterJump = vl.offset; + vl.moveBy(-5); // go back up 5 rows + expect(vl.selectedIndex).toBe(5); + // Viewport should have adjusted so 5 is visible + expect(vl.offset).toBeLessThanOrEqual(5); + expect(vl.offset + vl.viewportHeight - 1).toBeGreaterThanOrEqual(5); + // offset must not exceed the position before the jump + expect(vl.offset).toBeLessThanOrEqual(offsetAfterJump); + }); + + // ── selectAbsolute() ────────────────────────────────────────────── + + it('selectAbsolute() sets selection and scrolls into view', () => { + const vl = new VirtualList({ totalItems: 100, viewportHeight: 10 }); + vl.selectAbsolute(50); + expect(vl.selectedIndex).toBe(50); + expect(vl.offset).toBeLessThanOrEqual(50); + expect(vl.offset + vl.viewportHeight - 1).toBeGreaterThanOrEqual(50); + }); + + it('selectAbsolute() clamps to 0 for negative values', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 5 }); + vl.selectAbsolute(-3); + expect(vl.selectedIndex).toBe(0); + }); + + it('selectAbsolute() clamps to last item when index >= totalItems', () => { + const vl = new VirtualList({ totalItems: 5, viewportHeight: 3 }); + vl.selectAbsolute(99); + expect(vl.selectedIndex).toBe(4); + }); + + // ── selectedIndexInViewport ─────────────────────────────────────── + + it('selectedIndexInViewport reflects the relative position', () => { + const vl = new VirtualList({ totalItems: 30, viewportHeight: 10 }); + vl.selectAbsolute(15); + expect(vl.selectedIndexInViewport).toBe(vl.selectedIndex - vl.offset); + }); + + it('selectedIndexInViewport is 0 initially', () => { + const vl = new VirtualList({ totalItems: 30, viewportHeight: 10 }); + expect(vl.selectedIndexInViewport).toBe(0); + }); + + // ── scrollBy() ─────────────────────────────────────────────────── + + it('scrollBy(1) advances the viewport offset', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.scrollBy(1); + expect(vl.offset).toBe(1); + }); + + it('scrollBy clamps at 0 going up', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.scrollBy(-10); + expect(vl.offset).toBe(0); + }); + + it('scrollBy clamps at maxOffset going down', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 5 }); + vl.scrollBy(100); + // maxOffset = 10 - 5 = 5 + expect(vl.offset).toBe(5); + }); + + it('scrollBy adjusts selection to stay within new viewport', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + // selection is 0, offset 0 => scroll down 3 + vl.scrollBy(3); + expect(vl.offset).toBe(3); + // selection must be >= offset + expect(vl.selectedIndex).toBeGreaterThanOrEqual(vl.offset); + }); + + // ── setTotalItems() ─────────────────────────────────────────────── + + it('setTotalItems() re-clamps selection when list shrinks', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.selectAbsolute(15); + vl.setTotalItems(5); + expect(vl.selectedIndex).toBeLessThanOrEqual(4); + expect(vl.selectedIndex).toBeGreaterThanOrEqual(0); + }); + + it('setTotalItems(0) resets selection and offset to 0', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.selectAbsolute(10); + vl.setTotalItems(0); + expect(vl.selectedIndex).toBe(0); + expect(vl.offset).toBe(0); + }); + + // ── setViewportHeight() ─────────────────────────────────────────── + + it('setViewportHeight() updates height and clamps offset', () => { + const vl = new VirtualList({ totalItems: 20, viewportHeight: 5 }); + vl.selectAbsolute(18); + const prevOffset = vl.offset; + vl.setViewportHeight(10); // larger viewport + // offset may decrease so selection is visible + expect(vl.offset).toBeLessThanOrEqual(prevOffset); + expect(vl.offset + vl.viewportHeight - 1).toBeGreaterThanOrEqual(vl.selectedIndex); + }); + + // ── Edge cases ──────────────────────────────────────────────────── + + it('handles single-item list without errors', () => { + const vl = new VirtualList({ totalItems: 1, viewportHeight: 10 }); + expect(vl.selectedIndex).toBe(0); + expect(vl.offset).toBe(0); + vl.moveBy(1); + expect(vl.selectedIndex).toBe(0); + vl.moveBy(-1); + expect(vl.selectedIndex).toBe(0); + }); + + it('viewportHeight larger than totalItems returns all items in slice', () => { + const items = ['a', 'b', 'c']; + const vl = new VirtualList({ totalItems: 3, viewportHeight: 100 }); + expect(vl.slice(items)).toHaveLength(3); + }); + + it('offset never exceeds totalItems - viewportHeight', () => { + const vl = new VirtualList({ totalItems: 10, viewportHeight: 5 }); + vl.selectAbsolute(9); + // maxOffset = 10 - 5 = 5 + expect(vl.offset).toBeLessThanOrEqual(5); + }); +}); From 5d7084a33e58ddea15fafb2dbb3c2090096e502a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:57:11 +0000 Subject: [PATCH 3/3] fix: use viewport-relative list.selected in virtual keypress edge detection Agent-Logs-Url: https://github.com/TheWizardsCode/ContextHub/sessions/845ce879-be31-4e91-9a8f-897061b43acb Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- src/tui/controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 3b5674a2..5b921d75 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -3006,7 +3006,8 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { const nav = key && key.name && ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end'].includes(key.name); if (!nav) return; if (vl) { - const viewportIdx = getGlobalSelectedIndex(); + // In virtual mode, list.selected is relative to the viewport slice. + const viewportIdx = typeof list.selected === 'number' ? (list.selected as number) : 0; const totalVisible = vl.totalItems; if (totalVisible === 0) return;