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(); } + } +});