diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..85270308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. +- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input, and a command that ends while typing was held for its check never runs it (a note says so). Type-ahead on an idle machine is unchanged. ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 1f7f529a..77be1168 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -65,10 +65,24 @@ Probes back off (every 10 s after a minute of quiet), and the first look comes 1 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. +The hold ends when the look answers or evidence arrives. Until then nothing typed is drawn, sent or queued: the keys +may be a hidden reply, and an Enter may answer the command or queue the next one (#426). The look says what it saw: +`hidden` (echo off for line input), `visible` (the terminal echoes), `unknown` (no sample said how it echoes: a probe +failed or timed out), `idle` (nothing runs) or `unavailable` (no way to look on this platform). `visible` routes the +keys as before (a terminal would show them too); `hidden` comes with a hidden request, so they go straight to the +program. Echo off with line input is a hidden request whatever else is known (confirmed when the group is idle, +likely when it computes or the kernel says it is blocked elsewhere), so NMSh never draws what the terminal would not +echo. An `unknown` answer gets one more look; if that is blind too, or no answer comes within 5 s, the keys stay held +and hidden: the activity rows say `Can't tell whether is reading your typing` and Ctrl+S sends it to the +program, Tab shows it in the composer (for review; a final Enter is not replayed), Esc discards it. Later evidence +decides it the same way. A look still running after 800 ms says `Checking whether … · your typing is kept`. + +A command that ends while its typing was held past 800 ms never runs it: NMSh never learned whether it was a reply +(a timed `read -s` that gave up), so it is discarded with a note. A quick end (type-ahead after a short command) runs +it as before. Ctrl+C discards held typing, as a terminal flushes its input queue on an interrupt, and is itself +never held. A program that took the terminal meanwhile (Fish's reader) gets the keys as raw keys. An older service +does not say what its look saw; its answers are routed as before. 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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..d102ded9 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -211,7 +211,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, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queuePreview, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queueSettingsPreview} from '../status/queueSettingsPreview.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; @@ -221,7 +221,7 @@ import {buildQueueSummary, recognizedExecutables, reviewedTextSafe, type QueueSu import type {QueueTimeline} from '../session/ExecutionTimeline.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; -import {directInput, type InputState, type InputTiming} from '../session/inputState.js'; +import {directInput, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; @@ -422,8 +422,27 @@ const WARNING = lazyForeground(UI_COLORS.warning); const ERROR = lazyForeground(UI_COLORS.failure); /** How long after Ctrl+C or Ctrl+Z typing is taken as type-ahead for the shell prompt rather than queued. */ const STOP_TYPE_AHEAD_MS = 2000; -/** 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; +/** + * Keys held for an on-demand look at the running command's input state are routed when the look answers. A look is + * itself bounded (two probes of at most 1.5 s each); this only guards against an answer that never comes. + */ +const OWNERSHIP_LOOK_MS = 5000; +/** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ +const OWNERSHIP_LOOK_SLOW_MS = 800; +/** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ +type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; +/** Keys held while NMSh looks at whether the running command reads input (#426). */ +interface OwnershipHold { + keys: Key[]; + startId: number; + /** A question was open when the hold began: the typing is a reply. */ + answering: boolean; + /** The look has run past OWNERSHIP_LOOK_SLOW_MS: the composer says the typing is kept. */ + slow: boolean; + /** No usable evidence: the typing stays held and hidden until evidence arrives or the person chooses. */ + uncertain: boolean; + settle: (how: HoldSettle) => void; +} /** 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). */ @@ -847,7 +866,7 @@ export class TerminalApp { * 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 ownershipLook?: OwnershipHold; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -1389,7 +1408,7 @@ export class TerminalApp { } if (this.passthrough && !this.startupPending) { // Keys held for an ownership look were typed first: they reach the program first. - this.ownershipLook?.release(); + this.ownershipLook?.settle('route'); this.session.write(data); return; } @@ -2295,9 +2314,14 @@ export class TerminalApp { } 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; } - this.ownershipLook.release(); + const hold = this.ownershipLook; + // Ctrl+C is never held, and what was typed before it whose reader NMSh did not know is discarded, as a terminal + // flushes its input queue on an interrupt: it never becomes a queued entry or the next command (#426). + if (key.kind === 'interrupt') hold.settle('discard'); + else if (hold.uncertain && key.kind === 'sendToProgram') { hold.settle('send'); return; } + else if (hold.uncertain && key.kind === 'complete') { hold.settle('reveal'); return; } + else if (hold.uncertain && key.kind === 'escape') { hold.settle('discard'); return; } + else { hold.keys.push(key); return; } } else if (this.needsOwnershipLook(key)) { this.holdForOwnershipLook(key); return; @@ -4377,6 +4401,8 @@ export class TerminalApp { this.lastPtyRows = 0; this.lastPtyColumns = 0; } + // Keys still held for this command's look are settled now that it is over (#426). + this.ownershipLook?.settle('route'); // A command may have changed what workspace facts say (npm version, terraform workspace select, aws configure). this.contextEngine.invalidate('command'); void this.refreshContext(cwd); @@ -11431,6 +11457,8 @@ export class TerminalApp { const queue = listed?.[0] ?? this.queueLine(columns); if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; const request = this.inputState?.request; + // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. + if (this.ownershipLook?.uncertain) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); @@ -11453,6 +11481,8 @@ export class TerminalApp { 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'; + if (this.ownershipLook?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command'), waiting: true}; + if (this.ownershipLook?.slow) return {text: `Checking whether ${program} is waiting for input · your typing is kept`, waiting: false}; // 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}; @@ -11848,7 +11878,7 @@ export class TerminalApp { 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(); + this.ownershipLook?.settle('route'); return; } const request = state.request; @@ -11861,7 +11891,7 @@ export class TerminalApp { 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(); + if (request) this.ownershipLook?.settle('route'); this.render(); } @@ -11878,44 +11908,112 @@ export class TerminalApp { 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. */ + /** + * Hold the key (and any typed after it) until the session has looked, then route them on that evidence. Until then + * nothing typed is drawn, sent or queued: it may be a hidden reply, and its Enter may answer the command or queue the + * next one (#426). A look usually answers in milliseconds; after OWNERSHIP_LOOK_SLOW_MS the composer says the typing + * is kept. When the look saw nothing usable, or never answers within OWNERSHIP_LOOK_MS, the typing stays held and + * hidden (uncertain) until evidence arrives or the person chooses: Ctrl+S sends it to the program, Tab shows it in + * the composer, Esc discards it. It is never routed on a guess. Ctrl+C discards it, as a terminal flushes its input. + * A command that ends while its typing was held past the slow mark never runs it. + */ 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. + // The command line's own first word (sudo, not the command sudo runs): what the person sees running. + const program = running.command.trim().split(/\s+/u)[0] || 'the command'; + const typing = (item: Key) => item.kind === 'text' || item.kind === 'paste' || item.kind === 'enter' || item.kind === 'newline'; + const discarded = (message: string) => { if (keys.some(typing)) this.output.addFrontendInteraction(program, message, INFO); }; + let settled = false; + const settle = (how: HoldSettle) => { + if (settled) return; + settled = true; + clearTimeout(bound); + clearTimeout(slow); + if (this.ownershipLook === hold) this.ownershipLook = undefined; 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); + const doubted = hold.slow || hold.uncertain; + if (how === 'discard') { + if (doubted) discarded('Your held typing was discarded, not sent or run.'); + this.render(); + return; + } + if (how === 'send') { + // What the composer shows and what was held, in the order typed, as the bytes a terminal sends. + const bytes = `${this.editor.text}${keys.map(item => directKeyBytes(item) ?? '').join('')}`; + this.editor.clear(); + this.runningDraft = undefined; + if (bytes) this.session.write(bytes); + if (/[\r\n]/u.test(bytes)) this.answerSent(); + this.render(); + return; + } + if (how === 'reveal') { + // Shown for review, never routed: a final Enter is not replayed, earlier ones become new lines. + let end = keys.length; + while (end > 0 && keys[end - 1]!.kind === 'enter') end -= 1; + this.replayingHeld = true; + try { for (const item of keys.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } + this.render(); + return; + } + // The command ended while NMSh looked. A reply to its open question is discarded, never run; so is typing held + // past the slow mark, since NMSh never learned whether it was a reply (a timed read that gave up). Anything else + // is what it would have been without the look: typing for the next command, Enter running it after. + if (ended && (hold.answering || doubted) && keys.some(typing)) { + discarded(hold.answering ? 'The command finished before your reply was sent; the reply was discarded, not run.' + : `${program} ended before NMSh could tell whether your typing was for it; it was discarded, not run.`); + const rest = keys.filter(item => !typing(item)); + this.replayingHeld = true; + try { for (const item of rest) this.handleKey(item); } finally { this.replayingHeld = false; } + this.render(); + return; } // 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; } + try { for (const item of keys) 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}; + // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). + const doubt = () => { + if (settled) return; + if (this.running?.startId !== running.startId || this.passthrough || this.inputState?.request || this.inputState?.terminalOwner) { settle('route'); return; } + hold.uncertain = true; + this.render(); + }; + const bound = setTimeout(doubt, OWNERSHIP_LOOK_MS); + bound.unref?.(); + const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); + slow.unref?.(); + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, settle}; this.ownershipLook = hold; - void this.lookWhenStarted(running.startId, Date.now() + OWNERSHIP_LOOK_MS).then(release, release); + // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: + // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is + // routed as it always was (documented limit). + let relooked = false; + const deadline = Date.now() + OWNERSHIP_LOOK_MS; + const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); + const answered = (result: LookResult | undefined) => { + if (settled) return; + const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) + || (result === undefined && this.session.features.has('input-look-result')); + if (!blind) settle('route'); + else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } + else doubt(); + }; + look(); } /** 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 { + 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?.(); + if (this.running?.startId !== startId) return 'idle'; + if (this.running.awaitingExec) return 'unknown'; + return this.session.lookInput?.(); } /** Forward one key to the waiting program as the bytes a terminal sends; nothing is kept, shown or logged. */ diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index 2ab9d635..7aba6785 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -6,6 +6,7 @@ import {shellAdapter} from '../shell/adapters/registry.js'; import {SERVICE_FEATURES, type ServiceFeature} from './SessionProtocol.js'; import {QueueDispatcher, type QueueShell} from './QueueDispatcher.js'; import type {QueueOp} from './CommandQueue.js'; +import type {LookResult} from './inputState.js'; import {SessionTasks} from '../backgroundTasks/sessionTasks.js'; import type {TaskOp} from '../backgroundTasks/protocol.js'; import {SESSION_ID_ENV, SESSION_MODE_ENV, type SessionClient, type SessionClientEvents, type SessionOptions} from './SessionClient.js'; @@ -78,11 +79,9 @@ 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 { + async lookInput(): Promise { const watch = this.shell.inputWatch; - if (!watch) return false; - await watch.lookNow(); - return true; + return watch ? watch.lookNow() : undefined; } private queueShell(): QueueShell { diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index b3d64110..c69c8590 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -2,8 +2,8 @@ import {EventEmitter} from 'node:events'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {OpenLine, promptShaped, promptText} from './openLine.js'; import {AlternateScreenTracker} from './TerminalModes.js'; -import type {ForegroundProcess, TerminalProbe, TerminalSample} from './terminalProbe.js'; -import {type InputMode, type InputRequest, type InputState} from './inputState.js'; +import type {ForegroundProcess, TerminalModes, TerminalProbe, TerminalSample} from './terminalProbe.js'; +import {type InputMode, type InputRequest, type InputState, type LookResult} from './inputState.js'; /** * Watches one session's running command for a credible request for input. Lives beside the PTY (in the session @@ -60,6 +60,13 @@ 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; +/** + * What two samples say about the foreground group's CPU use. true: computing (on a CPU while measured, or using one + * between samples); false: idle between this sample and the previous one; 'runnable': on a CPU in this sample with + * nothing earlier to compare, which alone does not say it computes; undefined: not yet known. + */ +type Busy = boolean | 'runnable' | undefined; + const defaultTimer = (callback: () => void, ms: number) => { const timer = setTimeout(callback, ms); timer.unref?.(); @@ -178,16 +185,24 @@ export class InputWatch extends EventEmitter { * 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. + * Says what it saw (#426): whether the terminal echoes typing (from any sample taken, even one overtaken by newer + * output, since echo is a setting of the terminal), that it could not tell, or that nothing runs. */ - async lookNow(): Promise { + async lookNow(): Promise { + let seen: TerminalModes | undefined; for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running) return 'idle'; + if (!this.options.probe) return 'unavailable'; this.followUp = false; - await this.look(true); - if (!this.followUp || this.request) return; + seen = (await this.look(true))?.modes ?? seen; + if (!this.followUp || this.request) break; await new Promise(done => { this.setTimer(done, FOLLOW_UP_MS); }); } + if (this.disposed || !this.running) return 'idle'; + if (this.request?.mode === 'hidden') return 'hidden'; + if (!seen) return 'unknown'; + return seen.canonical && !seen.echo ? 'hidden' : 'visible'; } dispose(): void { @@ -229,27 +244,28 @@ export class InputWatch extends EventEmitter { this.cancelTimer = this.setTimer(() => { this.cancelTimer = undefined; void this.look(); }, delay); } - /** 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; + /** One sample, returned even when newer output made it stale; onDemand looks (lookNow) leave the back-off as it was. */ + private async look(onDemand = false): Promise { + if (this.disposed || !this.running || this.probing) return undefined; if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); this.schedule(); - return; + return undefined; } const tty = this.options.tty?.(); const probe = this.options.probe; - if (!tty || !probe) return; + if (!tty || !probe) return undefined; const generation = this.generation; this.lastProbeAt = this.now(); let sample: TerminalSample | undefined; 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 (this.disposed || !this.running) return undefined; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); + return sample; } /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ @@ -282,14 +298,24 @@ export class InputWatch extends EventEmitter { this.emit('change', this.state); } - private decide(sample: TerminalSample, busy: boolean | undefined, now: number): {confidence: 'confirmed' | 'likely'; mode: InputMode} | undefined { + private decide(sample: TerminalSample, busy: Busy, now: number): {confidence: 'confirmed' | 'likely'; mode: InputMode} | undefined { const modes = sample.modes; const mode: InputMode = !modes ? 'line' : !modes.canonical ? 'key' : !modes.echo ? 'hidden' : 'line'; if (sample.readingTerminal === true) return {confidence: 'confirmed', mode}; + // Echo off with line input: the terminal shows nothing typed, so neither does NMSh, whatever else is known. Without + // the other evidence (the group computes, or the kernel says it is blocked elsewhere) it is only likely (#426). + if (modes && modes.canonical && !modes.echo) { + return {confidence: sample.readingTerminal === false || busy === true || busy === 'runnable' ? 'likely' : 'confirmed', mode: 'hidden'}; + } // 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'}; const shellHolds = shellHoldsTerminal(sample.foreground); + if (busy === 'runnable') { + // On a CPU in the only sample there is. On a loaded machine the shell is still runnable for a moment after + // writing its exec marker, before its builtin blocks in read: the follow-up says whether it computes. + if (shellHolds && modes) this.followUp = true; + return undefined; + } 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'}; @@ -303,20 +329,26 @@ export class InputWatch extends EventEmitter { return undefined; } - /** true: computing; false: idle between this sample and the previous one; undefined: not yet known. */ - private busy(foreground: ForegroundProcess[], now: number): boolean | undefined { + private busy(foreground: ForegroundProcess[], now: number): Busy { if (!foreground.length) return undefined; - let known = !foreground.some(item => item.state.startsWith('R')) ? true : undefined; - let busy = known === undefined; + let busy = false; + let measured = true; + let runnable = false; for (const item of foreground) { + const onCpu = item.state.startsWith('R'); const before = this.previous.get(item.pid); - if (item.cpuMs === undefined || !before) { if (known) known = false; continue; } + if (item.cpuMs === undefined || !before) { + measured = false; + if (onCpu) runnable = true; + continue; + } const elapsed = now - before.at; - if (elapsed > 0 && (item.cpuMs - before.cpuMs) / elapsed > BUSY_SHARE) busy = true; + if (onCpu || (elapsed > 0 && (item.cpuMs - before.cpuMs) / elapsed > BUSY_SHARE)) busy = true; } this.remember(foreground, now); if (busy) return true; - return known ? false : undefined; + if (runnable) return 'runnable'; + return measured ? false : undefined; } private remember(foreground: ForegroundProcess[], now: number): void { diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index 41c6b228..d33b405f 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -1,3 +1,4 @@ +import type {LookResult} from './inputState.js'; import type {EventEmitter} from 'node:events'; import type {ShellMarker} from '../shell/ShellProtocol.js'; import type {TranscriptSession} from '../sessions/TranscriptStore.js'; @@ -91,10 +92,11 @@ export interface SessionClient extends EventEmitter { */ task?(change: TaskOp): 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. + * Look at the running command's input state now; resolves once the look is done (any change it found has already + * been emitted as inputState) with what it saw, or undefined when whatever owns the shell cannot look on demand, + * is gone, or is an older service that does not say (it lacks 'input-look-result'). */ - lookInput?(): Promise; + 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 638da23d..8a583543 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -26,7 +26,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', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history', 'input-look-result'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; /** @@ -114,8 +114,11 @@ 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} + /** + * The look asked for by input-look id is done; only to frontends that listed 'input-look'. result: what it saw + * (a LookResult), from services that advertise 'input-look-result'; older frontends ignore it. + */ + | {type: 'input-looked'; id: number; result?: string} | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} /** This session's background tasks (TaskRecord[] as JSON), on attach and whenever one changes. */ | {type: 'tasks'; state: string} @@ -203,7 +206,7 @@ const SHAPES: Record = { dismiss: {sessionId: 'string'}, queue: {change: 'string'}, 'input-look': {id: 'int'}, - 'input-looked': {id: 'int'}, + 'input-looked': {id: 'int', result: 'string?'}, 'queue-state': {state: 'string', dispatched: 'int?', refused: 'string?', dropped: 'int?'}, task: {change: 'string'}, tasks: {state: 'string'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 8f506b39..93107113 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -310,7 +310,7 @@ export class SessionService { 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}); }); + void session.shell.inputWatch.lookNow().then(result => { if (owned === session) send({type: 'input-looked', id, result}); }); break; } case 'input': diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 507d474d..287b10d9 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -7,6 +7,7 @@ import {taskUpdateFrom, tasksFrom, type TaskOp} from '../backgroundTasks/protoco import {CLIENT_FEATURES, FrameDecoder, PROTOCOL_VERSION, encodeMessage, inputStateFrom, parseFeatures, queueStateFrom, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; import type {SessionInfo} from './SessionProtocol.js'; import type {SessionNotice} from './SessionNotices.js'; +import {lookResultFrom, type LookResult} from './inputState.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import type {AttachedSession, SessionClient, SessionClientEvents, SessionOptions} from './SessionClient.js'; @@ -219,7 +220,7 @@ 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 looks = new Map void>(); private nextLook = 1; switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { @@ -249,7 +250,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 === 'input-looked') { this.looks.get(message.id)?.(lookResultFrom(message.result)); 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}), @@ -270,7 +271,7 @@ 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); + for (const done of this.looks.values()) done(undefined); this.looks.clear(); this.emit('exit', {exitCode, ...(signal === undefined ? {} : {signal}), ...(lost ? {lost} : {})}); } @@ -284,9 +285,9 @@ export class SocketSessionClient extends EventEmitter imple write(data: string): void { this.send({type: 'input', data}); } queue(change: QueueOp): void { if ((change.op === 'failure-policy' || change.expectedRevision !== undefined) && !this.features.has('queue-timeline')) return; if (this.features.has('queue')) this.send({type: 'queue', change: JSON.stringify(change)}); } task(change: TaskOp): void { if (this.features.has('background-tasks')) this.send({type: 'task', change: JSON.stringify(change)}); } - lookInput(): Promise { + 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); + if (!this.features.has('input-look') || this.exited || this.socket.destroyed) return Promise.resolve(undefined); const id = this.nextLook++; return new Promise(resolve => { this.looks.set(id, resolve); this.send({type: 'input-look', id}); }); } diff --git a/src/session/inputState.ts b/src/session/inputState.ts index cfdb7af8..c87b241a 100644 --- a/src/session/inputState.ts +++ b/src/session/inputState.ts @@ -47,7 +47,23 @@ export function waitedSoFar(state: InputState | undefined, now: number): number return state.timing.waitedMs + (state.request ? Math.max(0, now - state.request.since) : 0); } -/** Keys go to the program one by one (hidden line or key input) instead of through the composer's line editor. */ +/** + * Keys go to the program one by one (hidden line or key input) instead of through the composer's line editor. A + * hidden line does so even when it is only likely: the terminal echoes nothing typed, so NMSh draws nothing (#426). + */ export function directInput(request: InputRequest | undefined): boolean { - return request?.confidence === 'confirmed' && (request.mode === 'hidden' || request.mode === 'key'); + return request?.mode === 'hidden' || (request?.confidence === 'confirmed' && request.mode === 'key'); +} + +/** + * What an on-demand look saw of the terminal (#426). hidden: echo is off for line input, so nothing typed may be + * drawn; visible: the terminal echoes, so typing may be shown; unknown: no sample said how it echoes (a probe failed or + * timed out); idle: nothing was running; unavailable: this platform has no way to look. + */ +export type LookResult = 'hidden' | 'visible' | 'unknown' | 'idle' | 'unavailable'; +export const LOOK_RESULTS: readonly LookResult[] = ['hidden', 'visible', 'unknown', 'idle', 'unavailable']; + +/** A look result from the wire: one of the known values, else no answer (never guessed). */ +export function lookResultFrom(value: unknown): LookResult | undefined { + return typeof value === 'string' && (LOOK_RESULTS as readonly string[]).includes(value) ? value as LookResult : undefined; } diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index f8461968..c304c0a7 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -84,3 +84,22 @@ 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)` : ''}`; } + +/** How to settle typing NMSh is holding because it cannot tell who reads it (#426): the composer's hint and the rows'. */ +export function heldInputActions(program: string): string { + return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; +} + +/** + * The activity rows while typing is held without evidence of who reads it: what happened, then how to choose. Nothing + * typed is shown; the person decides, or later evidence does. + */ +export function heldInputRows(program: string, columns: number): [string, string] { + const attention = foreground(UI_COLORS.attention); + const secondary = foreground(UI_COLORS.secondary); + const head = `${inputGlyph({confidence: 'likely'})} Can't tell whether ${program} is reading your typing`; + const tail = ' · held, not shown'; + const first = `${attention}${BOLD}${truncateText(head, columns)}${RESET}${displayWidth(head + tail) <= columns ? `${secondary}${tail}${RESET}` : ''}`; + const indent = ' '; + return [first, `${indent}${secondary}${truncateText(heldInputActions(program), Math.max(1, columns - indent.length))}${RESET}`]; +} diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index c10e66f9..ca620c7d 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; 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 {directInput, lookResultFrom, waitedSoFar, type InputState} from '../src/session/inputState.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'; @@ -281,6 +281,113 @@ test('watch: lookNow samples at once, outside the backed-off schedule; nothing r assert.equal(line.watch.state.request, undefined); }); +test('watch (#426): a first sample that catches the shell still on a CPU is not proof it computes; the follow-up decides', async () => { + // A loaded machine: the shell wrote its exec marker and is about to block in `read`, but is still runnable when the + // first look samples it. With nothing earlier to compare, one sample cannot say it computes. + const h = harness("read -r QA_REPLY; printf 'ANSWER=%s\\n' \"$QA_REPLY\""); + h.set({modes: lineModes, foreground: shellFg('Rs+', 10)}); + const looked = h.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + h.set({modes: lineModes, foreground: shellFg('Ss+', 10)}); + await h.advance(100); + await looked; + assert.equal(h.watch.state.request?.confidence, 'likely', 'blocked and idle in the follow-up: a line read'); + assert.equal(h.watch.state.request?.mode, 'line'); + + // The same with line input off (`read -k 1`): confirmed key input once the follow-up sees it blocked. + const key = harness('read -k 1 x; echo got:$x'); + key.set({modes: keyModes, foreground: shellFg('Rs+', 10)}); + const keyLooked = key.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + key.set({modes: keyModes, foreground: shellFg('Ss+', 10)}); + await key.advance(100); + await keyLooked; + assert.equal(key.watch.state.request?.mode, 'key'); + assert.equal(key.watch.state.request?.confidence, 'confirmed'); + + // A shell that is computing stays not waiting: on a CPU in both samples, and using it in between. + const busy = harness('while :; do :; done'); + busy.set({modes: lineModes, foreground: shellFg('Rs+', 10)}); + const busyLooked = busy.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + busy.set({modes: lineModes, foreground: shellFg('Rs+', 90)}); + await busy.advance(100); + await busyLooked; + assert.equal(busy.watch.state.request, undefined); + assert.ok(busy.changes.every(state => !state.request), 'never reported, not even for a moment'); + + // Another program on a CPU with no prompt and ordinary line settings: still nothing, and the look ends after its one + // sample (it settles without the follow-up timer running). + const other = harness('make'); + other.set({modes: lineModes, foreground: fg('R+', 10, 'make')}); + let otherDone = false; + const otherLooked = other.watch.lookNow().then(() => { otherDone = true; }); + await new Promise(done => setImmediate(done)); + assert.equal(otherDone, true, 'no follow-up was asked for'); + await otherLooked; + assert.equal(other.watch.state.request, undefined); +}); + +test('watch (#426): echo off with line input is hidden input whatever else is known; typed text is never drawn', async () => { + // On a CPU in the only sample, computing across samples, or (Linux) blocked elsewhere: a terminal would still echo + // nothing typed, so NMSh shows nothing either. Without that evidence the reply is "likely" hidden, not confirmed. + const cases: Array<[string, TerminalSample[]]> = [ + ['runnable in the only sample', [{modes: hiddenModes, foreground: fg('R+', 5, 'sudo')}]], + ['computing across samples', [{modes: hiddenModes, foreground: fg('S+', 5, 'sudo')}, {modes: hiddenModes, foreground: fg('R+', 900, 'sudo')}]], + ['blocked elsewhere (Linux)', [{modes: hiddenModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}]], + ]; + for (const [name, samples] of cases) { + const h = harness('sudo -k true'); + h.watch.onOutput('Password:', h.at()); + for (const sample of samples) { h.set(sample); await h.advance(1000); } + const request = h.watch.state.request; + assert.equal(request?.mode, 'hidden', `${name}: hidden`); + assert.equal(request?.confidence, 'likely', `${name}: not claimed as a confirmed wait`); + assert.equal(directInput(request), true, `${name}: keys go to the program, never into the composer`); + } + // Echo off without line input (raw mode, a full-screen or proxied program) is unchanged: not a hidden line. + const raw = harness('docker run -it image'); + raw.set({modes: {canonical: false, echo: false, signals: false}, foreground: fg('R+', 100, 'docker')}); + await raw.advance(5000); + assert.equal(raw.watch.state.request, undefined); +}); + +test('watch (#426): lookNow reports what it saw: hidden, visible, unknown, nothing running, or no way to look', async () => { + const hidden = harness('read -s x'); + hidden.set({modes: hiddenModes, foreground: shellFg()}); + assert.equal(await hidden.watch.lookNow(), 'hidden'); + const visible = harness('sleep 30'); + visible.set({modes: lineModes, foreground: fg('S+', 1, 'sleep')}); + assert.equal(await visible.watch.lookNow(), 'visible'); + // ps answered but stty did not: the terminal's echo is unknown. + const noModes = harness('sudo -k true'); + noModes.set({foreground: fg('S+', 1, 'sudo')}); + assert.equal(await noModes.watch.lookNow(), 'unknown'); + // Both probes failed or timed out. + const failed = harness('sudo -k true'); + failed.set(undefined); + assert.equal(await failed.watch.lookNow(), 'unknown'); + // The command ended: there is nothing to look at. + const ended = harness('ls'); + ended.watch.onPrompt(ended.at()); + assert.equal(await ended.watch.lookNow(), 'idle'); + // No probe on this platform. + const none = new InputWatch({tty: () => '/dev/ttys9'}); + none.onExec('sleep 1'); + assert.equal(await none.lookNow(), 'unavailable'); + none.dispose(); + // A sample overtaken by newer output still says how the terminal echoes; only its request evidence is stale. + let resolve!: (sample: TerminalSample) => void; + const watch = new InputWatch({probe: () => new Promise(done => { resolve = done; }), tty: () => '/dev/ttys9'}); + watch.onExec('npm test'); + const looking = watch.lookNow(); + for (let index = 0; index < 3; index++) await Promise.resolve(); + watch.onOutput('ok 1 - test\r\n'); + resolve({modes: lineModes, foreground: fg('R+', 1, 'node')}); + assert.equal(await looking, 'visible'); + watch.dispose(); +}); + 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'); @@ -339,9 +446,14 @@ test('watch: the kernel\'s report decides when it exists (Linux): reading confir assert.equal(reading.watch.state.request?.mode, 'line'); const elsewhere = harness('ssh-keygen'); elsewhere.watch.onOutput('Enter passphrase: ', elsewhere.at()); + elsewhere.set({modes: lineModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}); + await elsewhere.advance(5000); + assert.equal(elsewhere.watch.state.request, undefined, 'blocked elsewhere vetoes an ordinary line read'); + // With echo off the veto only lowers confidence: typed text must never be drawn (#426). elsewhere.set({modes: hiddenModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}); await elsewhere.advance(5000); - assert.equal(elsewhere.watch.state.request, undefined); + assert.equal(elsewhere.watch.state.request?.mode, 'hidden'); + assert.equal(elsewhere.watch.state.request?.confidence, 'likely'); }); test('watch: several questions in one command accumulate waiting time; completion closes an open wait', async () => { @@ -443,11 +555,16 @@ test('protocol: input-state round-trips; unknown values are dropped, never guess 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) { + for (const message of [{type: 'input-look', id: 7}, {type: 'input-looked', id: 7}, {type: 'input-looked', id: 8, result: 'hidden'}] as const) { const look = decodeMessage(encodeMessage(message).trimEnd()); assert.ok(look.ok); assert.deepEqual(look.message, message); } + // What a look saw is one of a closed set; anything else is no answer, never guessed (#426). + assert.equal(lookResultFrom('hidden'), 'hidden'); + assert.equal(lookResultFrom('visible'), 'visible'); + assert.equal(lookResultFrom('maybe'), undefined); + assert.equal(lookResultFrom(undefined), undefined); 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')); diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts new file mode 100644 index 00000000..bdaec9f2 --- /dev/null +++ b/tests/inputOwnershipLook.test.ts @@ -0,0 +1,395 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputState, LookResult} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426: while a command runs and NMSh has no evidence yet that it reads input, the keys that decide where typing goes + * (the first key of a draft, an Enter that would queue) wait for an on-demand look. Until NMSh knows who reads them, + * nothing typed is drawn, sent, queued or run: it may be a password. A look says what it saw (hidden, visible, + * unknown, nothing running); when it cannot tell, or never answers, the typing stays held and hidden until the person + * chooses (Ctrl+S sends it, Tab shows it, Esc discards it) or later evidence decides. A command that ends while the + * typing is held past the slow mark never runs it; Ctrl+C discards held typing, as a terminal flushes its input. + * + * Deterministic: each look is a promise the test settles with what it "saw", and the deadlines run on mocked timers. + * Every key goes through the real TerminalApp key handler; no shell is started. + */ + +const READ = `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`; +const SECRET = 'QSECRETQ'; +/** When a look still running says so in the composer. */ +const SLOW_MS = 800; +/** The bound on keys waiting for a look that does not answer. */ +const BOUND_MS = 5000; +const CTRL_S = '\u0013'; +const TAB = '\t'; + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + const originalQueue = frame.session.queue.bind(frame.session); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + /** Every look stays open until the test settles it with what it saw. */ + const slowLooks = () => Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + /** Every look answers at once with what it saw, as it does on an idle machine. */ + const quickLooks = (...answer: [LookResult | undefined] | []) => { + const result = answer.length ? answer[0] : 'visible'; + Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return result; }, configurable: true}); + }; + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const enter = () => frame.app['onInput']('\r'); + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + /** The session reports what the look found, before it says the look is done (as the protocol orders them). */ + const found = (request: NonNullable) => { + frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); + settle(request.mode === 'hidden' ? 'hidden' : 'visible'); + }; + /** Nothing anywhere a person or a log could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + return {...frame, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, + interrupts: () => interrupts, looks: () => looks, settle: (result: LookResult | undefined) => settle(result)}; +} + +const settled = () => new Promise(done => setImmediate(done)); +const KEPT = /Checking whether \S+ is waiting for input · your typing is kept/u; +const HELD = /Can't tell whether \S+ is reading your typing/u; + +test('#426: a reply to a prompt-less read is not queued when the look outlasts 800 ms', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(READ); + f.type('hello'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + assert.deepEqual(f.adds(), [], 'the Enter waits for the look: it is not queued'); + assert.deepEqual(f.written, [], 'nothing reached the program yet'); + assert.equal(f.text(), '', 'nothing is drawn as a draft before NMSh knows who reads it'); + assert.match(f.screen(), KEPT, 'the composer says the typing is kept'); + // The look finds the shell's own read, holding its terminal and idle. + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['hello\r'], 'the answer reaches the read, once'); + assert.deepEqual(f.adds(), [], 'nothing was queued'); + assert.equal(f.text(), ''); + assert.doesNotMatch(f.screen(), KEPT, 'the note goes with the hold'); + } finally { f.dispose(); } +}); + +test('#426: a hidden reply typed before NMSh could look is never drawn, queued or run', async t => { + for (const [command, output] of [['read -s REPLY', ''], ['sudo -k true', 'Password:']] as const) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(command, output); + f.type(SECRET); + f.enter(); + for (const at of [SLOW_MS - 1, 1, 1000, 2000]) { + t.mock.timers.tick(at); + f.nowhere(SECRET, `${command}, ${at} ms on`); + } + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); + await settled(); + assert.equal(f.written.join(''), `${SECRET}\r`, `${command}: the hidden line and its Enter go to the program, in order`); + f.nowhere(SECRET, `${command}, after`); + } finally { f.dispose(); } + } +}); + +test('#426: keys typed after a held Enter stay behind it, in order', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(READ); + f.type('hello'); + f.enter(); + f.type('ls'); + t.mock.timers.tick(SLOW_MS + 100); + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['hello\r']); + assert.equal(f.text(), 'ls', 'what followed the Enter is the next draft'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('#426 timeout: a look that never answers leaves the typing held and hidden, never routed; Ctrl+S sends it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(BOUND_MS + 1000); + f.nowhere(SECRET, 'after the bound'); + assert.deepEqual(f.written, [], 'nothing was sent without evidence or a choice'); + assert.match(f.screen(), HELD, 'the activity rows say NMSh cannot tell'); + assert.match(f.screen(), /Ctrl\+S sends it to read · Tab shows it · Esc discards it/u, 'and how to choose'); + // More typing is held too, hidden, in order. + f.type('X'); + f.nowhere(`${SECRET}X`, 'typing more'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), `${SECRET}\rX`, 'Ctrl+S sends exactly what was typed, in order, to the program'); + f.nowhere(SECRET, 'after sending'); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426 timeout: a look that could not see the terminal (a failed probe) holds the typing the same way', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.quickLooks('unknown'); + f.begin('sudo -k true', 'Password:'); + f.type(SECRET); + f.enter(); + await settled(); + f.nowhere(SECRET, 'after an unknown look'); + assert.deepEqual(f.written, []); + assert.match(f.screen(), HELD); + // Esc discards: nothing is sent, shown or queued, and typing is ordinary again. + // (A lone ESC byte waits in the key decoder for a possible sequence; the key itself is what Esc delivers.) + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + assert.deepEqual(f.written, []); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426: one failed look gets one more; if that one sees the terminal echoing, type-ahead flows as usual', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('npm test'); + f.type('echo next'); + f.settle('unknown'); + await settled(); + assert.equal(f.looks(), 2, 'a second look was asked for'); + assert.doesNotMatch(f.screen(), HELD, 'not yet held: still looking'); + f.settle('visible'); + await settled(); + assert.equal(f.text(), 'echo next'); + assert.doesNotMatch(f.screen(), HELD); + // Both looks blind: held, and no third look. + const g = fixture(); + try { + g.slowLooks(); + g.begin('sudo -k true', 'Password:'); + g.type(SECRET); + g.settle('unknown'); + await settled(); + g.settle('unknown'); + await settled(); + assert.equal(g.looks(), 2); + assert.match(g.screen(), HELD); + g.nowhere(SECRET, 'two blind looks'); + } finally { g.dispose(); } + } finally { f.dispose(); } +}); + +test('#426 timeout: Tab shows held typing in the composer for review; only the person\'s own Enter routes it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('sleep 30'); + f.type('echo next'); + f.enter(); + t.mock.timers.tick(BOUND_MS); + assert.match(f.screen(), HELD); + assert.equal(f.text(), ''); + f.app['onInput'](TAB); + assert.equal(f.text(), 'echo next', 'shown as a draft; the trailing Enter is not replayed'); + assert.deepEqual(f.adds(), [], 'showing never queues'); + assert.deepEqual(f.written, []); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426 timeout: evidence that arrives while typing is held decides it (a hidden read gets it, never drawn)', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(BOUND_MS); + assert.match(f.screen(), HELD); + // The watch's own schedule sees the hidden read later. + f.session.emit('inputState', {request: {since: 0, confidence: 'confirmed', mode: 'hidden'}, timing: {waitedMs: 0, waits: 0}}); + await settled(); + assert.equal(f.written.join(''), `${SECRET}\r`); + f.nowhere(SECRET, 'after the evidence'); + } finally { f.dispose(); } +}); + +test('#426: a look that saw the terminal echoing routes type-ahead at once; no held state', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.quickLooks('visible'); + f.begin('sleep 30'); + f.type('echo next'); + await settled(); + assert.equal(f.text(), 'echo next'); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), ['echo next'], 'queued as soon as the look answered'); + assert.equal(f.looks(), 2, 'one look for the draft, one for the Enter'); + assert.doesNotMatch(f.screen(), HELD); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('#426: an older service (a look with no result) routes as before', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + (f.session.features as Set).delete('input-look-result'); + f.quickLooks(undefined); + f.begin('sleep 30'); + f.type('echo next'); + await settled(); + assert.equal(f.text(), 'echo next'); + assert.doesNotMatch(f.screen(), HELD); + // The same silent answer from a service that does say what it saw (the socket closed mid-look) is not evidence. + const g = fixture(); + try { + g.quickLooks(undefined); + g.begin('sudo -k true', 'Password:'); + g.type(SECRET); + await settled(); + g.nowhere(SECRET, 'a silent answer from a current service'); + assert.match(g.screen(), HELD); + } finally { g.dispose(); } + } finally { f.dispose(); } +}); + +test('#426: type-ahead is not delayed beyond the look: it appears as soon as the look answers', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('sleep 30'); + f.type('echo n'); + t.mock.timers.tick(SLOW_MS - 1); + assert.doesNotMatch(f.screen(), KEPT, 'a look that answers within 800 ms shows no note'); + f.settle('visible'); + await settled(); + assert.equal(f.text(), 'echo n'); + assert.doesNotMatch(f.screen(), KEPT); + } finally { f.dispose(); } +}); + +test('#426: an answer to a question the program printed goes as soon as the look answers, never queued', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('./install.sh', 'Continue? [y/N] '); + f.type('y'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + assert.deepEqual(f.written, [], 'held while NMSh cannot tell whether the answer is hidden'); + assert.deepEqual(f.adds(), []); + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['y\r']); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('#426: a command that ends quickly while keys are held: the type-ahead runs as the next command, once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('ls'); + f.type('echo after'); + f.enter(); + f.complete(0); + f.settle('idle'); + await settled(); + assert.deepEqual(f.adds(), [], 'the command is over: nothing is queued'); + assert.deepEqual(f.submitted, ['echo after'], 'the line runs as the next command, as Enter at the prompt does'); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('#426: a command that ends while typing is held past 800 ms never runs it; a note says it was discarded', async t => { + for (const reason of ['the look answers late', 'the look never answers']) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + // A timed read: it gives up before NMSh could tell it was reading, and the shell is back at its prompt. + f.begin('read -s -t 2 REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 200); + f.complete(1); + if (reason === 'the look answers late') f.settle('idle'); + else t.mock.timers.tick(BOUND_MS); + await settled(); + f.nowhere(SECRET, reason); + assert.deepEqual(f.written, [], `${reason}: nothing reached anything`); + assert.match(f.transcript(), /discarded, not run/u, `${reason}: a note says what happened`); + } finally { f.dispose(); } + } +}); + +test('#426: Ctrl+C discards held typing, as a terminal flushes its input on interrupt, then interrupts', async t => { + for (const when of ['while the look runs', 'after the bound']) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(when === 'while the look runs' ? 100 : BOUND_MS); + f.app['onInput']('\u0003'); + assert.equal(f.interrupts(), 1, `${when}: Ctrl+C is never held`); + f.nowhere(SECRET, when); + assert.deepEqual(f.written, [], `${when}: the held typing never reached the program`); + assert.equal(f.app['queueState'].entries.length, 0, `${when}: nothing waits in the queue to be resumed`); + assert.doesNotMatch(f.screen(), HELD); + // The look answering afterwards changes nothing. + f.settle('hidden'); + await settled(); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } + } +});