From 7a587d3e93275712ebbe844b6afdd566bec096a3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:30 +0530 Subject: [PATCH] Queue: while a program waits, the headline keeps the queue in view and says what waits for approval (QA F03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA (A9, Ghostty zsh, 68c1cbb7): an entry was queued, NMSh then saw the program wait for hidden input, the queue overview disappeared, and after the answer it came back with the entry marked Awaiting approval. Input safety was correct; the transition was confusing. The question taking the activity rows, with the queue's list stepping aside, is intentional and kept: the question needs both rows and the person's attention. But the headline only said "N queued", never that the entries now wait for approval, and on a narrow screen it dropped the queue entirely when the headline was long. The headline now says "≡ 2 queued · 2 for approval" when they do, shortens itself before the tail, and falls back to a bare "≡ 2" rather than nothing. Tests: tests/queueWhileWaiting.test.ts (both fail before); frame, presentation, input and queue suites 321/321. --- src/app/TerminalApp.ts | 17 +++++++++--- tests/queueWhileWaiting.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 tests/queueWhileWaiting.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..1f28cedc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -424,6 +424,8 @@ const WARNING = lazyForeground(UI_COLORS.warning); const ERROR = lazyForeground(UI_COLORS.failure); /** How long after Ctrl+C or Ctrl+Z typing is taken as type-ahead for the shell prompt rather than queued. */ const STOP_TYPE_AHEAD_MS = 2000; +/** The narrowest wait headline kept whole beside the queue's tail; narrower, the tail becomes a bare count. */ +const MIN_WAIT_HEADLINE = 18; /** * Keys held for an on-demand look at the running command's input state are routed when the look answers. A look is * itself bounded (two probes of at most 1.5 s each); this only guards against an answer that never comes. @@ -11599,10 +11601,17 @@ export class TerminalApp { // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); - const count = this.queueAvailable() ? this.queueState.entries.length : 0; - // The question keeps both rows; the queue is a count at the end of the headline, when it fits. - const tail = count ? `${SUBTLE} · ${queueGlyph()} ${count} queued${RESET}` : ''; - if (tail && rows[0] !== undefined && displayWidth(stripAnsi(rows[0])) + displayWidth(stripAnsi(tail)) <= columns) rows[0] += tail; + const entries = this.queueAvailable() ? this.queueState.entries : []; + // The question keeps both rows and the queue's list steps aside (QA F03): the headline still says the queue is + // there, and when its entries now wait for approval. The headline is shortened before the tail is, and a bare + // count is the last resort, so the queue never seems to vanish. + if (entries.length && rows[0] !== undefined) { + const approval = entries.filter(entry => entry.condition === 'approve' && !entry.approved).length; + const tails = [`${queueGlyph()} ${entries.length} queued${approval ? ` · ${approval} for approval` : ''}`, `${queueGlyph()} ${entries.length}`]; + const tail = tails.find(text => columns - displayWidth(text) - 3 >= MIN_WAIT_HEADLINE) ?? tails[1]!; + const room = Math.max(1, columns - displayWidth(tail) - 3); + rows[0] = `${truncateAnsi(rows[0], room)}${SUBTLE} · ${tail}${RESET}`; + } return rows.map(row => truncateAnsi(row, columns)); } const activity = truncateAnsi(this.currentActivity(), columns); diff --git a/tests/queueWhileWaiting.test.ts b/tests/queueWhileWaiting.test.ts new file mode 100644 index 00000000..35422009 --- /dev/null +++ b/tests/queueWhileWaiting.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * QA F03 (v0.22 physical QA, A9): while a program waits for input, its question takes the activity rows and the queue's + * list steps aside (intentional: the question needs both rows and the person's attention, and nothing may draw what is + * typed for it). The queue must still be visibly there: the headline keeps a queue tail at every width, shortening the + * headline before dropping it, and says when queued commands now wait for approval, so the list that returns after the + * question holds no surprise. + */ +function waiting(columns: number, command = 'sh -c "sleep 1; read -rs p; echo done"') { + const f = executionFrameFixture(columns, 30); + Object.defineProperty(f.session, 'queue', {value: (op: QueueOp) => { f.owner.request(op); }, configurable: true}); + Object.defineProperty(f.session, 'write', {value: () => {}, configurable: true}); + f.begin(command); + f.app['session'].queue({op: 'add', text: 'echo early'}); + f.app['session'].queue({op: 'add', text: 'echo later'}); + f.tick(300); + f.session.emit('inputState', {request: {since: Date.now(), confidence: 'confirmed', mode: 'hidden'}, timing: {waitedMs: 0, waits: 0}}); + f.tick(300); + const rows = f.capture().frame.rows.map(row => stripAnsi(row)); + return {f, rows, headline: rows.find(row => /Waiting for input/u.test(row)) ?? ''}; +} + +test('F03: while a program waits, the headline says the queue is there and that its entries now wait for approval', async () => { + const {f, headline} = waiting(110); + try { + await new Promise(done => setImmediate(done)); + const again = f.capture().frame.rows.map(row => stripAnsi(row)).find(row => /Waiting for input/u.test(row)) ?? headline; + assert.match(again, /≡ 2 queued · 2 for approval/u); + } finally { f.dispose(); } +}); + +test('F03: at narrow widths the queue tail stays: the headline is shortened first, then the tail becomes a count', async () => { + for (const columns of [52, 40, 30]) { + const {f} = waiting(columns, 'a-program-with-a-rather-long-name --and arguments'); + try { + await new Promise(done => setImmediate(done)); + const rows = f.capture().frame.rows.map(row => stripAnsi(row)); + const headline = rows.find(row => /Waiting|◆/u.test(row)) ?? ''; + assert.match(headline, /≡ 2/u, `${columns}: ${headline}`); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${columns}: ${row}`); + } finally { f.dispose(); } + } +});