Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions tests/queueWhileWaiting.test.ts
Original file line number Diff line number Diff line change
@@ -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(); }
}
});
Loading