From 2199c218ecd24547e98b82ede3126c80d4c51560 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Mon, 26 Oct 2026 19:00:36 +0530 Subject: [PATCH] feat(prompts): move the arrow to the edge of the page before the list scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inquirer's paginator held the arrow in the middle of the page and slid the list under it, then let the arrow move again over the last rows, so the arrow seemed stuck and the options jumped. Every list, checklist and the org and project pickers now page through pageScroller: the page stays still while the arrow moves to its top or bottom row, then scrolls one row at a time with the arrow kept on that row, and stops at both ends. launchPaginated gives each list and checkbox prompt its own scroller (it remembers the first row shown), which also prints "(Use ↑/↓ to see more options)", so the upstream hint no longer needs rewriting. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Harshi Shah Co-Authored-By: Claude --- .talismanrc | 2 +- AGENTS.md | 10 +++++- src/core/search-list.test.ts | 68 ++++++++++++++++++++++++------------ src/core/search-list.ts | 46 +++++++++++++----------- 4 files changed, 81 insertions(+), 45 deletions(-) diff --git a/.talismanrc b/.talismanrc index 23f34d9..3f48fa6 100644 --- a/.talismanrc +++ b/.talismanrc @@ -228,7 +228,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: c3276952d1eb16ea53f89363c9a70b59a19efc21fe087ea89ee70946c4b7cfc7 + checksum: dcebcddea3f29849c0b287e9cf8786a4e306394fe96b9ca4136525298eee6379 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" diff --git a/AGENTS.md b/AGENTS.md index d698829..33c5f0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -410,7 +410,15 @@ replaced, and still sees every listed answer. The same wrapper makes the pickers scroll like the lists: `PROMPT_PAGE_SIZE` rows at a time, and the arrow stops at the first and last entry. Upstream, the up and down arrow handlers wrap round and the bundled inquirer 3 paginator repeats the list three times to look endless, so the wrapper overrides both -arrow handlers and swaps the paginator for `scrollWindow`, a window that stops at both ends. +arrow handlers and swaps the paginator for `pageScroller`. + +*Every list scrolls the same way.* `registerSearchList` also registers `list` and `checkbox` as +`launchPaginated` subclasses, so all three prompt types page through `pageScroller` and print +`(Use ↑/↓ to see more options)` under a list that scrolls. inquirer's own paginator holds the arrow in +the middle of the page and slides the list under it, then lets the arrow move again near the end; +`pageScroller` keeps the page still while the arrow moves to its top or bottom row, and only then +scrolls one row at a time. Each prompt gets its own scroller, since it remembers which row the page +starts at. Together these are the prompt-side twin of `oneOf` on the flag side (`--cs-auth yes` has always been exit 2); the two now agree, and no prompt module needs its own check. diff --git a/src/core/search-list.test.ts b/src/core/search-list.test.ts index daf2cbc..e0f5d81 100644 --- a/src/core/search-list.test.ts +++ b/src/core/search-list.test.ts @@ -8,7 +8,7 @@ import { launchSearchList, refuseUnlisted, registerSearchList, - scrollWindow, + pageScroller, startingPointer, unlistedMessage, utilitiesLoader, @@ -20,7 +20,7 @@ const SearchList = load('inquirer-search-list') as SearchListClass; interface Prompt { opt: { validate: (value: unknown, answers?: unknown) => unknown; pageSize?: number }; pointer: number; - paginator: { paginate: unknown }; + paginator: { paginate(output: string, active: number, pageSize?: number): string }; render(): void; onDownKey(): void; onUpKey(): void; @@ -120,11 +120,11 @@ describe('launchSearchList', () => { expect(prompt.pointer).toBe(0); }); - it('shows PROMPT_PAGE_SIZE choices at a time through a window that does not repeat the list', () => { + it('shows PROMPT_PAGE_SIZE choices at a time through the page scroller', () => { const prompt = open({}); expect(prompt.opt.pageSize).toBe(PROMPT_PAGE_SIZE); - expect(prompt.paginator.paginate).toBe(scrollWindow); + expect(prompt.paginator.paginate(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n'), 0)).toContain(MORE_CHOICES_HINT); }); it('keeps a page size the question sets itself', () => { @@ -220,53 +220,75 @@ describe('launchSearchList', () => { }); }); -describe('scrollWindow', () => { +describe('pageScroller', () => { const LINES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n'); const HINT = '\u001b[2m(Use ↑/↓ to see more options)\u001b[22m'; - const shown = (active: number) => scrollWindow(LINES, active, 5).split('\n'); + + function walk(...actives: number[]): string[][] { + const scroller = pageScroller(); + + return actives.map((active) => scroller.paginate(LINES, active, 5).split('\n')); + } it('shows a list that fits the page whole, with no hint', () => { - expect(scrollWindow('a\nb\nc', 2, 5)).toBe('a\nb\nc'); + expect(pageScroller().paginate('a\nb\nc', 2, 5)).toBe('a\nb\nc'); }); it('starts at the top of a longer list and says there are more', () => { - expect(shown(0)).toEqual(['a', 'b', 'c', 'd', 'e', HINT]); + expect(walk(0)[0]).toEqual(['a', 'b', 'c', 'd', 'e', HINT]); }); - it('keeps the highlight in the middle while scrolling down', () => { - expect(shown(4)).toEqual(['c', 'd', 'e', 'f', 'g', HINT]); + it('keeps the page still while the arrow moves down to its last row', () => { + expect(walk(0, 1, 2, 3, 4)[4]).toEqual(['a', 'b', 'c', 'd', 'e', HINT]); + }); + + it('scrolls one row at a time once the arrow is on the last row, keeping it there', () => { + const pages = walk(0, 1, 2, 3, 4, 5, 6); + + expect(pages[5]).toEqual(['b', 'c', 'd', 'e', 'f', HINT]); + expect(pages[6]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]); }); it('ends at the last choice and never shows the first one again after it', () => { - expect(shown(7)).toEqual(['d', 'e', 'f', 'g', 'h', HINT]); + expect(walk(0, 1, 2, 3, 4, 5, 6, 7, 7)[8]).toEqual(['d', 'e', 'f', 'g', 'h', HINT]); + }); + + it('keeps the page still while the arrow moves back up, and scrolls once it goes above the top row', () => { + const pages = walk(7, 6, 5, 4, 3, 2); + + expect(pages[4]).toEqual(['d', 'e', 'f', 'g', 'h', HINT]); + expect(pages[5]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]); + }); + + it('opens on the page that holds a choice offered further down, with it on the last row', () => { + expect(walk(6)[0]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]); }); }); describe('launchPaginated', () => { interface Paginated { - paginator: { paginate(output: string, active: number): string }; + paginator: { paginate(output: string, active: number, pageSize?: number): string }; } class Upstream { - paginator = { - paginate: (output: string) => `${output}\n\u001b[2m(Move up and down to reveal more choices)\u001b[22m`, - }; + paginator = { paginate: (output: string) => output }; } - it('points at the up and down arrows instead of the upstream wording', () => { + it('pages with its own scroller, which keeps the arrow moving and says how to see more', () => { const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated; + const prompt = new Prompt(); + const lines = Array.from({ length: 10 }, (_, index) => `c${index}`).join('\n'); - expect(new Prompt().paginator.paginate('a\nb', 0)).toBe(`a\nb\n\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`); + expect(prompt.paginator.paginate(lines, 3, 7).split('\n')).toEqual([ + 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', `\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`, + ]); expect(MORE_CHOICES_HINT).toBe('(Use ↑/↓ to see more options)'); }); - it('leaves a page without the hint untouched', () => { - class Short { - paginator = { paginate: (output: string) => output }; - } - const Prompt = launchPaginated(Short as never) as unknown as new () => Paginated; + it('gives each prompt a scroller of its own', () => { + const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated; - expect(new Prompt().paginator.paginate('a\nb', 0)).toBe('a\nb'); + expect(new Prompt().paginator).not.toBe(new Prompt().paginator); }); }); diff --git a/src/core/search-list.ts b/src/core/search-list.ts index 2d93cc2..b9b6d93 100644 --- a/src/core/search-list.ts +++ b/src/core/search-list.ts @@ -58,39 +58,45 @@ export function startingPointer(list: SearchChoice[], initial: unknown): number export const MORE_CHOICES_HINT = '(Use ↑/↓ to see more options)'; -const UPSTREAM_HINT = '(Move up and down to reveal more choices)'; - interface Paginator { paginate(output: string, active: number, pageSize?: number): string; } export type PaginatedClass = new (...params: never[]) => { paginator: Paginator }; +export function pageScroller(): Paginator { + let top = 0; + + return { + paginate(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string { + const lines = output.split('\n'); + + if (lines.length <= pageSize) { + return output; + } + + if (active < top) { + top = active; + } else if (active >= top + pageSize) { + top = active - pageSize + 1; + } + + top = Math.min(Math.max(top, 0), lines.length - pageSize); + + return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join('\n'); + }, + }; +} + export function launchPaginated(Prompt: PaginatedClass): PaginatedClass { return class LaunchPaginated extends Prompt { constructor(...params: never[]) { super(...params); - const paginate = this.paginator.paginate.bind(this.paginator); - this.paginator.paginate = (output, active, pageSize) => - paginate(output, active, pageSize).replace(UPSTREAM_HINT, MORE_CHOICES_HINT); + this.paginator = pageScroller(); } }; } -export function scrollWindow(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string { - const lines = output.split('\n'); - - if (lines.length <= pageSize) { - return output; - } - - const top = Math.min(Math.max(active - Math.floor(pageSize / 2), 0), lines.length - pageSize); - - return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join( - '\n', - ); -} - export function launchSearchList(SearchList: SearchListClass): SearchListClass { return class LaunchSearchList extends SearchList { constructor(...params: never[]) { @@ -98,7 +104,7 @@ export function launchSearchList(SearchList: SearchListClass): SearchListClass { this.pointer = startingPointer(this.list, this.opt.default); this.opt.validate = refuseUnlisted(this.opt.validate); this.opt.pageSize = this.opt.pageSize ?? PROMPT_PAGE_SIZE; - this.paginator = { paginate: scrollWindow }; + this.paginator = pageScroller(); } onDownKey(): void {