From 624524409d922498c129594ddb2915db1e465f73 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 12 Jun 2026 12:16:57 -0700 Subject: [PATCH] gw switch polish * Selected item is now always visible on initial load * no more selection looping --- src/tui/switch-picker.ts | 111 ++++++++++++++++++++++++++ test/tui/switch-picker.test.ts | 137 ++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/src/tui/switch-picker.ts b/src/tui/switch-picker.ts index 2a8f306..3f27f54 100644 --- a/src/tui/switch-picker.ts +++ b/src/tui/switch-picker.ts @@ -12,6 +12,17 @@ interface SwitchPickerChoice { message: string; hint: string; searchText: string; + index?: number; +} + +interface SwitchPickerPromptInternal { + choices: SwitchPickerChoice[]; + index: number; + limit: number; + alert(): unknown; + down(): unknown; + reset(...args: unknown[]): unknown; + up(): unknown; } interface EnquirerPromptInternal { @@ -51,6 +62,102 @@ function formatPickerLabel( return `${isCurrent ? '*' : ' '} ${branchName} ${folderName}`; } +function getChoiceOrder(choice: SwitchPickerChoice, fallback: number): number { + return typeof choice.index === 'number' ? choice.index : fallback; +} + +function isAtPickerBoundary( + choices: SwitchPickerChoice[], + index: number, + direction: 'up' | 'down' +): boolean { + const focused = choices[index]; + if (!focused) { + return true; + } + + const focusedOrder = getChoiceOrder(focused, index); + const boundaryOrder = choices.reduce((boundary, choice, choiceIndex) => { + const order = getChoiceOrder(choice, choiceIndex); + return direction === 'up' + ? Math.min(boundary, order) + : Math.max(boundary, order); + }, focusedOrder); + + return focusedOrder === boundaryOrder; +} + +export function getSwitchPickerViewport( + choices: T[], + selectedIndex: number, + limit: number +): { choices: T[]; index: number } { + if (choices.length === 0) { + return { choices, index: 0 }; + } + + const boundedSelectedIndex = Math.max( + 0, + Math.min(selectedIndex, choices.length - 1) + ); + const visibleLimit = Math.max(1, Math.min(limit, choices.length)); + const start = Math.max( + 0, + Math.min( + boundedSelectedIndex - visibleLimit + 1, + choices.length - visibleLimit + ) + ); + + if (start === 0) { + return { choices, index: boundedSelectedIndex }; + } + + return { + choices: choices.slice(start).concat(choices.slice(0, start)), + index: boundedSelectedIndex - start, + }; +} + +export function configureBoundedSwitchPickerPrompt( + prompt: SwitchPickerPromptInternal +): void { + // Enquirer scrolls autocomplete lists by rotating choices; keep that model, + // but stop delegating once the focused original choice is at a boundary. + const reset = prompt.reset.bind(prompt); + prompt.reset = async (...args: unknown[]) => { + const result = await reset(...args); + const viewport = getSwitchPickerViewport( + prompt.choices, + prompt.index, + prompt.limit + ); + + prompt.choices = viewport.choices; + prompt.index = viewport.index; + + return result; + }; + + const up = prompt.up.bind(prompt); + prompt.up = () => { + if (isAtPickerBoundary(prompt.choices, prompt.index, 'up')) { + return prompt.alert(); + } + + return up(); + }; + + const down = prompt.down.bind(prompt); + prompt.down = () => { + if (isAtPickerBoundary(prompt.choices, prompt.index, 'down')) { + return prompt.alert(); + } + + return down(); + }; +} + export async function cancelPromptSilently( this: EnquirerPromptInternal ): Promise { @@ -110,6 +217,10 @@ export async function pickSwitchWorktreePath( const initial = rows.findIndex((row) => row.isCurrent); const enquirer = new Enquirer<{ selected: string }>(); + enquirer.on('prompt', (prompt: SwitchPickerPromptInternal) => { + configureBoundedSwitchPickerPrompt(prompt); + }); + const promptOptions = { type: 'autocomplete', name: 'selected', diff --git a/test/tui/switch-picker.test.ts b/test/tui/switch-picker.test.ts index 23cf9f1..1b0dfb3 100644 --- a/test/tui/switch-picker.test.ts +++ b/test/tui/switch-picker.test.ts @@ -1,6 +1,40 @@ import { describe, expect, test, vi } from 'vitest'; -import { cancelPromptSilently } from '@/tui/switch-picker'; +import { + cancelPromptSilently, + configureBoundedSwitchPickerPrompt, + getSwitchPickerViewport, +} from '@/tui/switch-picker'; + +interface FakeChoice { + index: number; + hint: string; + message: string; + name: string; + searchText: string; + value: string; +} + +interface FakePrompt { + choices: FakeChoice[]; + index: number; + limit: number; + alert(): unknown; + down(): unknown; + reset(...args: unknown[]): unknown; + up(): unknown; +} + +function createChoices(length: number): FakeChoice[] { + return Array.from({ length }, (_, index) => ({ + index, + hint: String(index), + message: String(index), + name: String(index), + searchText: String(index), + value: String(index), + })); +} function createReadlineClosedError(): Error & { code: string } { const error = new Error('readline was closed') as Error & { code: string }; @@ -9,6 +43,107 @@ function createReadlineClosedError(): Error & { code: string } { } describe('switch picker', () => { + test('shows an initial selection beyond the first rendered window', () => { + const choices = createChoices(15); + const viewport = getSwitchPickerViewport(choices, 12, 10); + + expect(viewport.choices.slice(0, 10).map((choice) => choice.index)).toEqual( + [3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + ); + expect(viewport.index).toBe(9); + expect(viewport.choices[viewport.index].index).toBe(12); + }); + + test('keeps an initial selection that is already visible in place', () => { + const choices = createChoices(15); + const viewport = getSwitchPickerViewport(choices, 4, 10); + + expect(viewport.choices).toBe(choices); + expect(viewport.index).toBe(4); + }); + + test('positions the initial selection using the actual rendered limit', async () => { + const prompt: FakePrompt = { + choices: createChoices(20), + index: 12, + limit: 5, + alert: vi.fn(), + down: vi.fn(), + reset: vi.fn(), + up: vi.fn(), + }; + + configureBoundedSwitchPickerPrompt(prompt); + await prompt.reset(); + + expect(prompt.choices.slice(0, 5).map((choice) => choice.index)).toEqual([ + 8, 9, 10, 11, 12, + ]); + expect(prompt.index).toBe(4); + }); + + test('blocks up navigation at the first matching choice', () => { + const alert = vi.fn(); + const up = vi.fn(); + const prompt: FakePrompt = { + choices: createChoices(12), + index: 0, + limit: 10, + alert, + down: vi.fn(), + reset: vi.fn(), + up, + }; + + configureBoundedSwitchPickerPrompt(prompt); + prompt.up(); + + expect(alert).toHaveBeenCalledTimes(1); + expect(up).not.toHaveBeenCalled(); + }); + + test('blocks down navigation at the last matching choice', () => { + const choices = createChoices(12); + const alert = vi.fn(); + const down = vi.fn(); + const prompt: FakePrompt = { + choices: choices.slice(2).concat(choices.slice(0, 2)), + index: 9, + limit: 10, + alert, + down, + reset: vi.fn(), + up: vi.fn(), + }; + + configureBoundedSwitchPickerPrompt(prompt); + prompt.down(); + + expect(alert).toHaveBeenCalledTimes(1); + expect(down).not.toHaveBeenCalled(); + }); + + test('keeps scrolling when the visible edge is not the list boundary', () => { + const choices = createChoices(12); + const alert = vi.fn(); + const up = vi.fn(); + const prompt: FakePrompt = { + choices: choices.slice(2).concat(choices.slice(0, 2)), + index: 0, + limit: 10, + alert, + down: vi.fn(), + reset: vi.fn(), + up, + }; + + configureBoundedSwitchPickerPrompt(prompt); + prompt.up(); + + expect(alert).not.toHaveBeenCalled(); + expect(up).toHaveBeenCalledTimes(1); + }); + test('silently cancels when Enquirer readline cleanup has already closed', async () => { const stop = vi.fn(() => { throw createReadlineClosedError();