From 79d12278a2a0414379d62dc3fb1f8863b8e787c2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 9 Oct 2026 09:47:52 -0700 Subject: [PATCH 1/2] Reduce terminal flicker in the file picker --- docs/specs/dor-tool.md | 1 + dor/src/commands/fuzzy.ts | 4 ++ dor/src/commands/open-picker.ts | 21 ++++++++-- dor/test/open-picker.test.mjs | 72 ++++++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index cfd3e951d..adf639121 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -232,6 +232,7 @@ Source of truth: `BUILTIN_HANDLERS` in `dor-tools-builtin/src/file-viewer-format - **Must list the files under the resolved CWD as git does in a work tree** — tracked and untracked, less ignored and deleted. Outside one, a walk lists each work tree it reaches likewise and skips dot-entries, `node_modules`, and the macOS home `Library`. - **Must offer the highlighted file's `tool.openHandlers` answer in order**: what [Opening local files](#opening-local-files) selects, then later matching rules' `tool` and `preview`, then each built-in supporting it — each once, with what it runs and the rule or built-in that offers it. +- **Must preserve the query and selected file across terminal resizes**, including transitions between the side panel and compact handler status. Source of truth: `runFilePicker` in `dor/src/commands/open-picker.ts`; `listOpenHandlers` in `lib/src/host/tool-open.ts`. diff --git a/dor/src/commands/fuzzy.ts b/dor/src/commands/fuzzy.ts index da5c26242..ec14c20c5 100644 --- a/dor/src/commands/fuzzy.ts +++ b/dor/src/commands/fuzzy.ts @@ -205,6 +205,10 @@ export class Ranker { this.ranked = []; this.pending = []; this.selected = undefined; + // Seed the next frame before the picker redraws. Otherwise each keystroke + // paints an empty list until the deferred scan and redraw timer catch up. + // Large searches still yield after one bounded slice. + if (this.scanning) this.step(SLICE_MS); this.schedule(); } diff --git a/dor/src/commands/open-picker.ts b/dor/src/commands/open-picker.ts index 414052f7c..b67dc6a99 100644 --- a/dor/src/commands/open-picker.ts +++ b/dor/src/commands/open-picker.ts @@ -68,6 +68,8 @@ export function runFilePicker(options: PickerOptions): Promise(); let listRows = 0; let redrawTimer: ReturnType | undefined; + let previousLines: string[] = []; + let previousColumns = 0; const listing = new AbortController(); return new Promise((resolve) => { @@ -241,10 +243,21 @@ export function runFilePicker(options: PickerOptions): Promise { out += `${CSI}${index + 1};1H${line}${SGR.reset}${CSI}K`; }); - out += `${CSI}1;${3 + displayWidth(shown)}H${CSI}?25h${CSI}?2026l`; - terminal.write(out); + // Handler replies and ranking ticks often change nothing visible. Keep + // those frames silent, and leave unchanged rows alone on real updates. + // A resize invalidates the cache because the terminal may reflow rows. + const resized = columns !== previousColumns || lines.length !== previousLines.length; + let out = ''; + lines.forEach((line, index) => { + if (!resized && line === previousLines[index]) return; + out += `${CSI}${index + 1};1H${line}${SGR.reset}`; + // At the right margin the cursor still occupies the last cell (wrap + // pending). Erasing there would delete the character just written. + if (displayWidth(line) < columns) out += `${CSI}K`; + }); + previousLines = lines; + previousColumns = columns; + if (out) terminal.write(`${CSI}?2026h${CSI}?25l${out}${CSI}1;${3 + displayWidth(shown)}H${CSI}?25h${CSI}?2026l`); }; const renderPanel = (width: number): Line[] => { diff --git a/dor/test/open-picker.test.mjs b/dor/test/open-picker.test.mjs index 72674a258..2fb02dc43 100644 --- a/dor/test/open-picker.test.mjs +++ b/dor/test/open-picker.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runCli } from '../dist/cli.js'; import { fuzzyMatch, Ranker } from '../dist/commands/fuzzy.js'; -import { parseKeys } from '../dist/commands/open-picker.js'; +import { parseKeys, runFilePicker } from '../dist/commands/open-picker.js'; import { listFiles } from '../dist/commands/file-list.js'; import { execFileSync } from 'node:child_process'; import { setImmediate } from 'node:timers/promises'; @@ -75,13 +75,15 @@ test('parseKeys decodes keys, mouse, paste, and a lone escape', () => { function fakeTerminal({ columns = 120, rows = 20 } = {}) { let onInput; + let onResize; let output = ''; return { columns: () => columns, rows: () => rows, write: (text) => { output += text; }, - listen(input) { onInput = input; return () => { onInput = undefined; }; }, + listen(input, resize) { onInput = input; onResize = resize; return () => { onInput = undefined; onResize = undefined; }; }, send(chunk) { onInput(chunk); }, + resize(width, height) { columns = width; rows = height; onResize(); }, get output() { return output; }, get listening() { return onInput !== undefined; }, }; @@ -114,6 +116,72 @@ async function until(predicate, label) { assert.fail(`timed out waiting for ${label}`); } +test('picker redraws only changed rows, skips identical frames, and repaints after resize', async () => { + const terminal = fakeTerminal({ columns: 60, rows: 8 }); + const result = runFilePicker({ + terminal, + fixedTool: 'builtin:file', + async listFiles(onFiles) { onFiles(['a'.repeat(58), 'b.ts']); return { truncated: false }; }, + async handlers() { assert.fail('fixed handler needs no lookup'); }, + }); + try { + await until(() => terminal.output.includes('2/2'), 'completed listing'); + // Both the query/count row and the first file fill the terminal width. + assert.doesNotMatch(terminal.output, /2\/2\x1b\[0m\x1b\[0m\x1b\[K/); + assert.doesNotMatch(terminal.output, /a{58}\x1b\[0m\x1b\[0m\x1b\[K/); + const before = terminal.output; + terminal.send('\x1b[A'); // Already at the top. + terminal.resize(60, 8); + assert.equal(terminal.output, before, 'unchanged frames emit no bytes or cursor toggles'); + + terminal.send('\x1b[B'); + const update = terminal.output.slice(before.length); + assert.deepEqual([...update.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [2, 3]); + assert.ok(update.endsWith('\x1b[1;3H\x1b[?25h\x1b[?2026l'), 'returns the cursor to the query'); + + const unfiltered = terminal.output.length; + terminal.send('b'); + const filtered = terminal.output.slice(unfiltered); + assert.match(filtered, /b\x1b\[0m\x1b\[1m\.ts\x1b\[0m\x1b\[0m\x1b\[K/, 'shorter replacement clears the old row tail'); + assert.doesNotMatch(filtered, /0…\/2/, 'typing does not paint an empty list before ranking'); + const narrow = terminal.output.length; + terminal.resize(120, 10); + assert.deepEqual([...terminal.output.slice(narrow).matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.match(terminal.output.slice(narrow), /Opens with/); + const wide = terminal.output.length; + terminal.resize(60, 6); + assert.deepEqual([...terminal.output.slice(wide).matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [1, 2, 3, 4, 5, 6]); + assert.match(terminal.output.slice(wide), /1;4H/, 'query cursor survives resize'); + terminal.send('\r'); + assert.deepEqual(await result, { file: 'b.ts' }); + } finally { + if (terminal.listening) terminal.send('\x03'); + await result; + } +}); + +test('handler replies leave unchanged file rows alone', async () => { + const terminal = fakeTerminal({ columns: 60 }); + let reply; + const result = runFilePicker({ + terminal, + async listFiles(onFiles) { onFiles(['a.ts', 'b.ts']); return { truncated: false }; }, + handlers: () => new Promise(resolve => { reply = resolve; }), + }); + try { + await until(() => reply !== undefined, 'handler request'); + const before = terminal.output.length; + reply({ handlers: HANDLERS, config: '/config', warnings: [], target: '/a.ts', directory: false }); + await until(() => terminal.output.includes('builtin:file'), 'handler reply'); + const update = terminal.output.slice(before); + assert.doesNotMatch(update, /a\.ts|b\.ts/); + assert.deepEqual([...update.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [19, 20]); + } finally { + terminal.send('\x03'); + await result; + } +}); + async function withTree(run) { const dir = await mkdtemp(join(tmpdir(), 'dor-picker-')); try { From 739d3fb89415ab1dc329a1f7ce0d80234d962338 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 9 Oct 2026 09:51:49 -0700 Subject: [PATCH 2/2] Simplify the file-picker flicker fix Erase each changed row before writing it, so a character in the last column survives without measuring the row's width. Key the frame cache on the terminal size directly. Seed ranking once per input chunk from the picker rather than on every query edit, so a chunk of several edits pays one bounded slice. Share test helpers for painted rows and output since the last check, and trim the spec rule to its contract. Co-Authored-By: Claude Opus 5.5 --- docs/specs/dor-tool.md | 2 +- dor/src/commands/fuzzy.ts | 9 +++---- dor/src/commands/open-picker.ts | 18 +++++++------- dor/test/open-picker.test.mjs | 42 ++++++++++++++++++--------------- 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index adf639121..a9e246e21 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -232,7 +232,7 @@ Source of truth: `BUILTIN_HANDLERS` in `dor-tools-builtin/src/file-viewer-format - **Must list the files under the resolved CWD as git does in a work tree** — tracked and untracked, less ignored and deleted. Outside one, a walk lists each work tree it reaches likewise and skips dot-entries, `node_modules`, and the macOS home `Library`. - **Must offer the highlighted file's `tool.openHandlers` answer in order**: what [Opening local files](#opening-local-files) selects, then later matching rules' `tool` and `preview`, then each built-in supporting it — each once, with what it runs and the rule or built-in that offers it. -- **Must preserve the query and selected file across terminal resizes**, including transitions between the side panel and compact handler status. +- **Must preserve the query and selected file across terminal resizes.** Source of truth: `runFilePicker` in `dor/src/commands/open-picker.ts`; `listOpenHandlers` in `lib/src/host/tool-open.ts`. diff --git a/dor/src/commands/fuzzy.ts b/dor/src/commands/fuzzy.ts index ec14c20c5..fc38d6bbf 100644 --- a/dor/src/commands/fuzzy.ts +++ b/dor/src/commands/fuzzy.ts @@ -205,13 +205,14 @@ export class Ranker { this.ranked = []; this.pending = []; this.selected = undefined; - // Seed the next frame before the picker redraws. Otherwise each keystroke - // paints an empty list until the deferred scan and redraw timer catch up. - // Large searches still yield after one bounded slice. - if (this.scanning) this.step(SLICE_MS); this.schedule(); } + /** One bounded slice now, so a frame drawn right away shows matches. */ + seed(): void { + if (this.scanning) this.step(SLICE_MS); + } + /** Ranks everything listed so far, now: Enter opens what is best. */ flush(): void { this.step(Infinity); diff --git a/dor/src/commands/open-picker.ts b/dor/src/commands/open-picker.ts index b67dc6a99..89880670a 100644 --- a/dor/src/commands/open-picker.ts +++ b/dor/src/commands/open-picker.ts @@ -69,7 +69,7 @@ export function runFilePicker(options: PickerOptions): Promise | undefined; let previousLines: string[] = []; - let previousColumns = 0; + let previousSize = ''; const listing = new AbortController(); return new Promise((resolve) => { @@ -196,6 +196,9 @@ export function runFilePicker(options: PickerOptions): Promise { - if (!resized && line === previousLines[index]) return; - out += `${CSI}${index + 1};1H${line}${SGR.reset}`; - // At the right margin the cursor still occupies the last cell (wrap - // pending). Erasing there would delete the character just written. - if (displayWidth(line) < columns) out += `${CSI}K`; + if (size === previousSize && line === previousLines[index]) return; + out += `${CSI}${index + 1};1H${CSI}2K${line}${SGR.reset}`; }); previousLines = lines; - previousColumns = columns; + previousSize = size; if (out) terminal.write(`${CSI}?2026h${CSI}?25l${out}${CSI}1;${3 + displayWidth(shown)}H${CSI}?25h${CSI}?2026l`); }; diff --git a/dor/test/open-picker.test.mjs b/dor/test/open-picker.test.mjs index 2fb02dc43..08ce03900 100644 --- a/dor/test/open-picker.test.mjs +++ b/dor/test/open-picker.test.mjs @@ -77,6 +77,7 @@ function fakeTerminal({ columns = 120, rows = 20 } = {}) { let onInput; let onResize; let output = ''; + let taken = 0; return { columns: () => columns, rows: () => rows, @@ -85,6 +86,8 @@ function fakeTerminal({ columns = 120, rows = 20 } = {}) { send(chunk) { onInput(chunk); }, resize(width, height) { columns = width; rows = height; onResize(); }, get output() { return output; }, + /** Output written since the previous call. */ + take() { const text = output.slice(taken); taken = output.length; return text; }, get listening() { return onInput !== undefined; }, }; } @@ -116,6 +119,9 @@ async function until(predicate, label) { assert.fail(`timed out waiting for ${label}`); } +/** The rows a frame repainted, in order. */ +const paintedRows = (text) => [...text.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])); + test('picker redraws only changed rows, skips identical frames, and repaints after resize', async () => { const terminal = fakeTerminal({ columns: 60, rows: 8 }); const result = runFilePicker({ @@ -126,32 +132,30 @@ test('picker redraws only changed rows, skips identical frames, and repaints aft }); try { await until(() => terminal.output.includes('2/2'), 'completed listing'); - // Both the query/count row and the first file fill the terminal width. - assert.doesNotMatch(terminal.output, /2\/2\x1b\[0m\x1b\[0m\x1b\[K/); - assert.doesNotMatch(terminal.output, /a{58}\x1b\[0m\x1b\[0m\x1b\[K/); - const before = terminal.output; + // The query/count row and the first file fill the terminal width, so an + // erase after either would delete its last character. + assert.doesNotMatch(terminal.take(), /\x1b\[K/, 'rows are erased only before they are written'); terminal.send('\x1b[A'); // Already at the top. terminal.resize(60, 8); - assert.equal(terminal.output, before, 'unchanged frames emit no bytes or cursor toggles'); + assert.equal(terminal.take(), '', 'unchanged frames emit no bytes or cursor toggles'); terminal.send('\x1b[B'); - const update = terminal.output.slice(before.length); - assert.deepEqual([...update.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [2, 3]); + const update = terminal.take(); + assert.deepEqual(paintedRows(update), [2, 3]); assert.ok(update.endsWith('\x1b[1;3H\x1b[?25h\x1b[?2026l'), 'returns the cursor to the query'); - const unfiltered = terminal.output.length; terminal.send('b'); - const filtered = terminal.output.slice(unfiltered); - assert.match(filtered, /b\x1b\[0m\x1b\[1m\.ts\x1b\[0m\x1b\[0m\x1b\[K/, 'shorter replacement clears the old row tail'); + const filtered = terminal.take(); + assert.match(filtered, /\x1b\[2;1H\x1b\[2K[^H]*b\x1b\[0m\x1b\[1m\.ts/, 'shorter replacement clears the old row first'); assert.doesNotMatch(filtered, /0…\/2/, 'typing does not paint an empty list before ranking'); - const narrow = terminal.output.length; terminal.resize(120, 10); - assert.deepEqual([...terminal.output.slice(narrow).matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - assert.match(terminal.output.slice(narrow), /Opens with/); - const wide = terminal.output.length; + const wide = terminal.take(); + assert.deepEqual(paintedRows(wide), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.match(wide, /Opens with/); terminal.resize(60, 6); - assert.deepEqual([...terminal.output.slice(wide).matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [1, 2, 3, 4, 5, 6]); - assert.match(terminal.output.slice(wide), /1;4H/, 'query cursor survives resize'); + const narrow = terminal.take(); + assert.deepEqual(paintedRows(narrow), [1, 2, 3, 4, 5, 6]); + assert.match(narrow, /1;4H/, 'query cursor survives resize'); terminal.send('\r'); assert.deepEqual(await result, { file: 'b.ts' }); } finally { @@ -170,12 +174,12 @@ test('handler replies leave unchanged file rows alone', async () => { }); try { await until(() => reply !== undefined, 'handler request'); - const before = terminal.output.length; + terminal.take(); reply({ handlers: HANDLERS, config: '/config', warnings: [], target: '/a.ts', directory: false }); await until(() => terminal.output.includes('builtin:file'), 'handler reply'); - const update = terminal.output.slice(before); + const update = terminal.take(); assert.doesNotMatch(update, /a\.ts|b\.ts/); - assert.deepEqual([...update.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])), [19, 20]); + assert.deepEqual(paintedRows(update), [19, 20]); } finally { terminal.send('\x03'); await result;