From 416bc51a4f6151236d959ef6a62d2d1653772287 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:32:51 +0530 Subject: [PATCH 1/5] fix: hold ambiguous program input for explicit recovery --- docs/design/input-awareness.md | 10 +- .../2026-10-28-resource-followup-plan.md | 23 +++ .../plans/2026-10-28-input-ownership.md | 27 +++ .../2026-10-28-input-ownership-design.md | 30 ++++ src/app/TerminalApp.ts | 79 +++++++-- src/status/inputStatus.ts | 12 +- tests/commandQueueLive.test.ts | 6 +- tests/fixtures/ownershipRaceEntry.mjs | 5 + tests/fixtures/ownershipRaceProbe.mjs | 28 +++ tests/inputOwnershipLive.test.ts | 35 ++-- tests/inputOwnershipLook.test.ts | 166 +++++++++++++++++- tests/inputOwnershipRaceLive.test.ts | 52 ++++++ tests/inputOwnershipSecrets.test.ts | 111 ++++-------- tests/inputOwnershipSecretsLive.test.ts | 20 ++- tests/inputQuarantine.test.ts | 16 +- tests/ptyResizeLive.test.ts | 4 +- tests/queueConditionOwnershipLive.test.ts | 2 +- tests/typingAfterRead.test.ts | 3 +- 18 files changed, 489 insertions(+), 140 deletions(-) create mode 100644 docs/performance/2026-10-28-resource-followup-plan.md create mode 100644 docs/superpowers/plans/2026-10-28-input-ownership.md create mode 100644 docs/superpowers/specs/2026-10-28-input-ownership-design.md create mode 100644 tests/fixtures/ownershipRaceEntry.mjs create mode 100644 tests/fixtures/ownershipRaceProbe.mjs create mode 100644 tests/inputOwnershipRaceLive.test.ts diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index eb8f0ba5..7ffe9e76 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -75,19 +75,17 @@ first key of a draft and an Enter that would queue, and asks the session to look 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 +failed or timed out), `idle` (nothing runs) or `unavailable` (no way to look on this platform). `visible` permits draft editing (a terminal would show it too), but says nothing about a reader. If the held keys include Enter and no reader was established, the entire draft moves into a confirmation hold, never the command-approval queue; `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`. +program, Tab shows it in the composer (for review; a final Enter is not replayed), Esc discards it. Later reliable reader evidence can resolve an ordinary lookup hold. A held Enter with only later likely evidence requires confirmation; once Enter is in confirmation quarantine, no later result sends it automatically. Ctrl+S explicitly sends all captured bytes in order to the same still-running command. Tab or Ctrl+Q restores the editable text for queue review, without replaying the final Enter; a fresh Enter explicitly queues it. Esc and Ctrl+C discard the entire capture. Completion/done, a different command, terminal handover and shutdown invalidate confirmation holds; stale decisions cannot recover them. After completion/done, confirmation typing stays hidden in the existing prompt hold for Tab/Esc review, with Ctrl+S disabled; it is never sent to a later command. Existing approval-queue entries are never sent automatically. 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 +An ordinary lookup hold (not confirmation quarantine) 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 +never held. An ordinary lookup hold can follow authenticated native-reader ownership as raw keys. Confirmation quarantine is cancelled at terminal handover, before newer keys reach the new owner. 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. diff --git a/docs/performance/2026-10-28-resource-followup-plan.md b/docs/performance/2026-10-28-resource-followup-plan.md new file mode 100644 index 00000000..3cda65dd --- /dev/null +++ b/docs/performance/2026-10-28-resource-followup-plan.md @@ -0,0 +1,23 @@ +# Deferred resource investigation + +Execute separately after the input-ownership correction has completed review and validation. This is an investigation plan, not authorization for a broad memory optimization or a claim that the input gate has passed. + +## Evidence to preserve + +The actual original be3f6abe versus merged c690de05 benchmark raw data remains at `/Volumes/T7Tmp/ai-dev/nmsh-stabilization-followup-20261027/`, with provenance, samples, harnesses and measurements.json. The ownership checkpoint records their hashes; nothing is pooled or overwritten. + +Warm median output/history results are substantial: 10k Unicode output 42503.28 to 269.84 ms, archive restore 684.59 to 251.93 ms, PageUp 872.55 to 40.11 ms, history typing 448.71 to 31.47 ms. Warm first-command output improves 1510.81 to 753.13 ms and durable completion 1643.01 to 858.59 ms. These are warm observations, not true-cold startup gains. + +Costs remain visible: idle CPU 3.35% to 3.75% of one core; idle frontend RSS 129.28 to 152.78 MiB; idle service RSS 49.86 to 60.53 MiB; output-finish service RSS 20.38 to 70.62 MiB. Point-in-time RSS is not retained heap, peak memory or leak evidence. The output-finish snapshots differ in elapsed process lifetime: baseline 38–49 s, merged 0.27–0.30 s. Frontend after short typing is a different observation (155.17 to 153.95 MiB), not a reason to erase the idle increase. + +Runtime/idle n=3 warm sessions; history n=4; long editing n=7 with correlated key samples. Small-n nearest-rank p95 is the observed maximum for runtime/history. Five-second CPU windows cover frontend/service/shell, omit other descendants, and are quantized by ps. No OS-cache purge, physical pixel latency, energy or sustained retention claim. Prior +6.7% history-typing p95 and +2.3% deletion p95 remain separate experiments without causal attribution. + +## Investigation sequence + +1. Reconstruct the matched benchmark environment with locked dependencies, built CLI, private HOME/service, fixed geometry/provider/shell and independently recorded exact heads. Keep original/raw results immutable. Alternate run order in a predefined bounded matrix, record all samples and external host activity, and separate source-loader costs from built runtime. +2. Idle CPU/memory: record all owned roles and descendants at common elapsed times and command lifecycle points over longer predefined windows. Collect CPU deltas, RSS, heap/GC/allocation evidence and timer/probe/render counts separately. Account for service backlog limits and sampler overhead. Do not attribute RSS differences to a leak without retained-object evidence. +3. Startup loading: measure authenticated shell readiness, first composer frame, first output and durable completion separately; record import/initialization paths and time spent starting shell/service/providers. Keep first-observed and warm samples separate. Define a reproducible cold-cache protocol before claiming any cold result; do not purge user caches or alter existing sessions. +4. Long sessions: use a bounded repeated output/history/edit workload with exact record/order checks, fixed idle checkpoints, detach/reattach and session closure. Measure retained heap and RSS across cycles, journals/backlogs, listener/process counts and reclamation after owned sessions close. Separate bounded caches from monotonic retention. +5. Form one evidence-backed hypothesis and a focused follow-up issue per cost. Agree acceptance budgets from the current milestone before code changes; validate any improvement against input safety and output/history performance. No broad cleanup or dependency churn. + +All scratch and artifacts use verified T7 storage. Physical Ghostty/Zed QA remains a separate manual gate. diff --git a/docs/superpowers/plans/2026-10-28-input-ownership.md b/docs/superpowers/plans/2026-10-28-input-ownership.md new file mode 100644 index 00000000..a3f686a8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-input-ownership.md @@ -0,0 +1,27 @@ +# Input ownership implementation plan + +> Implement inline using executing-plans; use independent review at the end. + +**Goal:** safely recover ambiguous answers through explicit confirmation. +**Architecture:** localized TerminalApp ownership hold; unchanged InputWatch/protocol/service approval semantics. +**Tech stack:** existing TypeScript/Node/PTYS. +**Spec:** ../specs/2026-10-28-input-ownership-design.md + +## Constraints and review focus +No automatic merge/release, no changes to PR513, no deadline/CI relaxation. Check combined and separate holds, late likely/hidden evidence, cancellation leaving editor drafts, owner/lifecycle invalidation, and explicit queue intent. + +## Task 1: ownership quarantine +- [x] Add deterministic tests in inputOwnershipLook.test.ts for explicit confirmation, late inference, completion, cancellation, queue and ordering. Run on unchanged source and retain expected failures. +- [x] Add confirmation hold state and atomic draft capture in TerminalApp.ts. Guard late state routing and lifecycle transitions; preserve older-service behavior and explicit actions. +- [x] Run affected unit and real-PTY tests; document changed Enter semantics in input-awareness.md. + +## Task 2: validation checkpoint +- [x] Controlled real-PTY old-red/new-green, immediate/delayed reads/password/non-reader/unknown cases; bounded stress. +- [x] Independent whole-change review and address safety findings; full npm run verify. +- [ ] Commit/push focused branch and open dev PR. Obtain exact-head hosted CI once, no retries for green. Keep unmerged. +- [ ] Report exact evidence/risks, unchanged PR513 and performance observations; leave separate deferred resource investigation plan. + +## Ledger +Fresh dev c690de05; primary checkout unchanged. Design reviewed before implementation. Native owner change cancels quarantine before handover to preserve privacy/order. + +Final local ledger: canonical `npm run verify` passed with TERM=xterm-256color, COLORTERM=truecolor, NO_COLOR unset and NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev. The longer T7 path's two unchanged test failures were reproduced on exact dev and resolved by the shortest compliant root, without test changes. Fixed two-round/two-worker PTY stress passed; original source fails the controlled approval-enqueue assertion and corrected source recovers exactly once with Ctrl+S. Independent findings are addressed. Completion retains review-only typing rather than losing it; combined submissions are guarded after the real editor/action parser; merely likely hidden ownership never releases held bytes. Hosted results belong to the PR checkpoint. No automatic merge. diff --git a/docs/superpowers/specs/2026-10-28-input-ownership-design.md b/docs/superpowers/specs/2026-10-28-input-ownership-design.md new file mode 100644 index 00000000..a2f00d78 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-input-ownership-design.md @@ -0,0 +1,30 @@ +# Ambiguous Enter ownership + +Approved by the maintainer in this session, including explicit confirmation for ambiguous Enter. + +Echo-visible evidence permits draft editing, not reader delivery. If a look resolves visible with no reader and the held keys include Enter, capture the entire draft and keys in the existing ownership hold, clear the composer, and require a user choice. Later visible/likely evidence never forwards it. Ctrl+S sends once only while the same command is live; Ctrl+Q explicitly queues after reviewable echo evidence; Tab reveals editing keys without final Enter; Esc/Ctrl+C discard. No approval-queue entry is recovered automatically. Unknown/hidden input remains private. + +Completion/done retains the capture in the existing prompt hold for Tab/Esc review, with delivery invalidated. A different command, terminal ownership changes and shutdown invalidate pending delivery; stale asynchronous decisions do nothing. Native passthrough cancels the ambiguous capture before handing over rather than delaying the terminal UI or delivering old bytes. This is a conservative builder decision under the approved owner-change requirement. Existing non-ambiguous reader routing remains unchanged. No wire protocol/detector changes or new dependencies. + +Alternatives: automatic late recovery from likely evidence is rejected because echo and likely reads are not proof of ownership. Sending service-owned approval entries would need a separate guarded removal/send transaction, so is deferred. + +Validation: deterministic old-source red/corrected-source green with real key routing, controlled real-PTY immediate/delayed line and password reads, non-reader/false-positive/unknown/completion/order/queue/cancellation coverage, independent review, canonical verification and exact-head hosted CI. Physical Ghostty/Zed remains NOT RUN. + +## State transitions + +| Current state | Evidence or action | Result | +|---|---|---| +| First-key lookup hold | Echo visible, no Enter | Real composer edits may replay; echo is presentation evidence only | +| First-key hold including Enter | Real editor parses a known NMSh slash action | Existing NMSh action path; no program answer | +| Replayed program Enter / separate Enter lookup | No reader or merely likely reader | Capture entire draft into confirmation quarantine; no approval entry | +| Ordinary lookup hold | Merely likely hidden reader | Confirmation quarantine, including unfinished typing; nothing drawn or delivered | +| Ordinary lookup hold | Confirmed reader / authenticated native owner | Existing same-command routing; keys remain ordered | +| Confirmation quarantine | Any later lookup/request | Retain capture; no automatic delivery | +| Confirmation quarantine | Ctrl+S, same command live | Send captured bytes once, in typed order; clear capture | +| Confirmation quarantine | Tab / Ctrl+Q | Queue-composer review; final Enter is removed; new Enter is explicit queue intent | +| Confirmation quarantine | Done / prompt return | Existing prompt hold, hidden and review-only; sending permanently invalidated | +| Pending confirmation | New exec / native terminal handover / shutdown / detach | Discard pending capture before new owner receives typing | +| Prompt hold | Tab / Esc / Ctrl+C | Review without submission / discard; no later program receives it | +| Existing approval entry | New reader evidence | Existing quarantine semantics; never recovered automatically | + +The submission guard runs after the real editor and NMSh action parser; no duplicate editor or slash parser was introduced. Queue-review drafts are excluded from later reader auto-transfer. The detector and wire protocol remain unchanged. Source review identified and tests reproduced the submitted-command passthrough callback bypass, stale queue-review draft transfer, and combined slash-action interception; the final boundaries cover each. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cbfa17a..e9a98209 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -475,7 +475,7 @@ const OWNERSHIP_LOOK_SLOW_MS = 800; */ const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** 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'; +type HoldSettle = 'route' | 'discard' | 'send' | 'reveal' | 'queue'; /** * The keys Tab replays when it shows held typing: text and the keys that edit it, never one that acts. A Ctrl+Q (queue), * Ctrl+S, history, palette or failure key typed while the typing was held must not queue, send or run it (#426 gate). @@ -486,6 +486,8 @@ const REVEALABLE_KEYS = new Set(['text', 'paste', 'enter', 'newline /** Keys held while NMSh looks at whether the running command reads input (#426). */ interface OwnershipHold { keys: Key[]; + /** Ownership was inconclusive: later inference cannot authorize delivery without a user choice. */ + confirmation?: boolean; startId: number; /** A question was open when the hold began: the typing is a reply. */ answering: boolean; @@ -1320,6 +1322,8 @@ export class TerminalApp { * byte goes to the program. Reclaimed in onShellPrompt. */ private handOverTerminal(restore = ''): void { + // Every passthrough entry (including submit's output callback) invalidates an ambiguous capture. + if (this.ownershipLook?.confirmation) this.ownershipLook.settle('discard'); // Images uploaded to the terminal are deleted before a program takes the screen. this.graphics?.releaseAll(); this.browserPanel?.session.forgetPictures(); @@ -1369,6 +1373,7 @@ export class TerminalApp { this.pendingSummarySave = undefined; this.effects.cancel(); if (this.running) { + if (!this.running.awaitingExec && this.ownershipLook?.confirmation) this.ownershipLook.settle('discard'); this.running.awaitingExec = false; this.running.historyAllowed = historyAllowed; this.chooseActivityPair(this.running); @@ -2536,6 +2541,7 @@ export class TerminalApp { if (key.kind === 'interrupt') hold.settle('discard'); // Held at the prompt there is no program to send it to: only showing or discarding it settles it. else if (hold.atPrompt && key.kind === 'sendToProgram') return; + else if (hold.confirmation && key.kind === 'queueCommand' && this.queueAvailable()) { hold.settle('queue'); return; } 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; } @@ -13025,7 +13031,7 @@ export class TerminalApp { 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. Typing held // at the prompt for a command that ended is not this command's: the composer says it. - if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns, Boolean(this.ownershipLook.confirmation) && this.queueAvailable()); // 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); @@ -13067,7 +13073,7 @@ export class TerminalApp { /** While a command runs, the empty composer says what Enter will do with what you type. */ private composerHint(): {text: string; waiting: boolean} | undefined { // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. - if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; + if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program, Boolean(this.ownershipLook.confirmation)), waiting: true}; if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; } @@ -13076,13 +13082,14 @@ 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?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command', Boolean(this.ownershipLook.confirmation) && this.queueAvailable()), 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}; if (!this.queueAvailable()) return undefined; - return {text: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; + const enter = this.session.lookInput && this.session.features.has('input-look') ? 'Enter checks who reads it' : 'Enter holds it for approval'; + return {text: `${enter} · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; } /** @@ -13388,6 +13395,13 @@ export class TerminalApp { // it, and it is no reply (#436). One begun before that may be a reply, and is held for approval below. const afterDone = Boolean(this.running.done && this.runningDraft?.startId === this.running.startId && this.runningDraft.afterDone); if (intent === 'enter' && text.trim() && (this.justStopped() || afterDone) && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text, afterDone && !this.justStopped()); return; } + // A first-key hold can contain edits and Enter together. Parse NMSh actions above first; a replayed + // program submission still needs reliable ownership, even when a later look only inferred a line reader. + if (intent === 'enter' && this.replayingHeld && text.trim() && !this.justStopped() && !this.running.done + && this.inputState?.request?.confidence !== 'confirmed' && this.session.lookInput && this.session.features.has('input-look')) { + this.holdForOwnershipLook({kind: 'enter'}, true); + return; + } const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); if (toProgram) { this.editor.clear(); @@ -13624,11 +13638,16 @@ export class TerminalApp { this.inputState = state; if (this.arcade) this.arcadeInputState(state); // The shell said the command is done: typing begun from now on is for the prompt, never a reply (#426, #436). - if (state.done) this.running.done = true; + if (state.done) { + this.running.done = true; + if (this.ownershipLook?.confirmation) this.ownershipLook.settle('route'); + } if (state.request && stoppedAt !== undefined && state.request.since >= stoppedAt) { this.running.askedAfterStop = true; if (this.stopAhead?.startId === this.running.startId) this.settleStopAhead('asked'); } + // Apply the confirmation boundary before a newly inferred hidden/key request can transfer an old draft. + if (state.request?.confidence === 'likely') this.ownershipLook?.settle('route'); 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 @@ -13642,12 +13661,12 @@ export class TerminalApp { const request = state.request; // 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.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + if (!this.queueCompose && !this.queueEdit && request && directInput(request) && draft && !draft.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { const typed = this.editor.text; this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); - } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; + } else if (!this.queueCompose && !this.queueEdit && request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; if (request?.mode === 'hidden') this.stashDraftTypedBefore(request); if (request) this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. @@ -13759,7 +13778,7 @@ export class TerminalApp { * A command that ends while its typing was held past the slow mark never runs it. One that ends while a look saw * hidden input right after a hidden answer leaves the typing held at the prompt for the person (Tab or Esc). */ - private holdForOwnershipLook(key: Key): void { + private holdForOwnershipLook(key: Key, requireConfirmation = false): void { const running = this.running!; const keys = [key]; // The command line's own first word (sudo, not the command sudo runs): what the person sees running. @@ -13773,9 +13792,24 @@ export class TerminalApp { if (hold.atPrompt && how !== 'reveal' && how !== 'discard') return; const ended = hold.ended || this.running?.startId !== running.startId; const doubted = hold.slow || hold.uncertain || hold.sawHidden; + if (hold.confirmation) { + // A look or a later likely/confirmed request cannot approve a previously ambiguous Enter. + if (how === 'route') { + if (ended || this.running?.done) { + // Delivery is invalid now; keep the person's typing hidden for review, never for another program. + hold.atPrompt = true; + clearTimeout(bound); clearTimeout(slow); + this.render(); + } + return; + } + if ((ended || this.running?.done) && how !== 'reveal') how = 'discard'; + } // The command ended while a look still saw hidden input right after NMSh answered a hidden question. The typing // is the next command or a reply to a second question with no prompt that gave up first; nothing tells them // apart, and a time window is no proof. It is neither run nor dropped: it waits, hidden, for the person. + if (how === 'route' && !ended && !hold.confirmation && this.inputState?.request?.confidence === 'likely' + && ((key.kind === 'enter' && !hold.answering) || directInput(this.inputState.request))) { confirm(); return; } if (how === 'route' && ended && !hold.answering && !doubted && hold.sawAnsweredHidden && keys.some(typing)) { clearTimeout(bound); clearTimeout(slow); @@ -13803,7 +13837,9 @@ export class TerminalApp { this.render(); return; } - if (how === 'reveal') { + if (how === 'reveal' || how === 'queue') { + // Review is composer-only, even if a password/key reader appeared since the Enter. + if (hold.confirmation) this.queueCompose = true; this.revealHeld(keys); return; } @@ -13830,6 +13866,19 @@ export class TerminalApp { try { for (const item of keys) this.handleKey(item); } finally { this.replayingHeld = false; this.replayTypedWhile = undefined; } this.render(); }; + // Move the whole draft into the hold before exposing any choice: cancellation must leave no future command. + const confirm = () => { + if (!hold.confirmation) { + if (this.editor.text) keys.unshift({kind: 'text', value: this.editor.text}); + this.editor.clear(); + this.runningDraft = undefined; + hold.confirmation = true; + } + hold.uncertain = true; + clearTimeout(bound); + clearTimeout(slow); + this.render(); + }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). const doubt = () => { if (settled || hold.atPrompt) return; @@ -13855,7 +13904,7 @@ export class TerminalApp { 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 || hold.atPrompt) return; + if (settled || hold.atPrompt || hold.confirmation) return; // Hidden input seen just after NMSh answered this command's hidden question is usually that question, open until // its read returns, and the typing type-ahead; but it can be a second question with no prompt. Either way the // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). @@ -13867,11 +13916,16 @@ export class TerminalApp { if (result === 'idle') hold.ended = true; // An older service never says what it saw: that is no evidence either, so its typing stays held (fail closed). const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || result === undefined; + // Echo permits presentation, not submission to a reader. Combined first-key holds are checked + // by routeComposer after their editor keys and NMSh actions have been parsed. + if (result === 'visible' && !this.inputState?.request && key.kind === 'enter' + && this.running?.startId === running.startId && !this.running.done) { confirm(); return; } if (!blind) settle('route'); else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } else doubt(); }; - look(); + if (requireConfirmation) confirm(); + else look(); } /** Look once the shell has started the command (keys typed right after Enter arrive before its exec). */ @@ -14171,6 +14225,7 @@ export class TerminalApp { private stop(exitCode: number): void { this.pendingQueuePause = undefined; + if (this.ownershipLook) this.ownershipLook.settle('discard'); this.queueExpiry?.(); this.queueExpiry = undefined; if (this.stopped) return; // A window maximized for setup is put back before the frontend ends (bounded, synchronous, best effort). diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index 55f4f9f4..0f0ee3e6 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -86,28 +86,28 @@ export function completionWaitFact(timing: InputTiming | undefined): string { } /** 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`; +export function heldInputActions(program: string, queueReview = false): string { + return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it${queueReview ? ' · Ctrl+Q reviews for queue' : ''}`; } /** * Typing held at the prompt (#426): the command ended right after hidden input, and NMSh could not tell whether the * typing was the next command or a reply to a question with no prompt. Nothing typed is shown until the person chooses. */ -export function heldAtPromptActions(program: string): string { - return `Typed during ${program}'s hidden input · held, not run · Tab shows it · Esc discards it`; +export function heldAtPromptActions(program: string, confirmation = false): string { + return `${confirmation ? `Typed while ${program} ran` : `Typed during ${program}'s hidden input`} · held, not run · 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] { +export function heldInputRows(program: string, columns: number, queueReview = false): [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}`]; + return [first, `${indent}${secondary}${truncateText(heldInputActions(program, queueReview), Math.max(1, columns - indent.length))}${RESET}`]; } diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index 0020a69f..1a26bfa6 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -73,7 +73,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { // (A redraw writes only the rows that changed, so the header and the list rows are checked apart.) await frontend.waitFor(/› cd sub +Next/u, mark); await frontend.waitFor(/Queue · 6 commands/u, mark); - assert.match(drawn(frontend, mark), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to sh/u, 'the empty composer says what Enter and Ctrl+Q do'); + assert.match(drawn(frontend, mark), /Enter checks who reads it · Ctrl\+Q runs it after · Ctrl\+S sends to sh/u, 'the empty composer says what Enter and Ctrl+Q do'); writeFileSync(release, ''); await frontend.waitFor(/STATE sub kept fn-x (?:opt|on)/u, mark, 30_000); const commands = [holding, ...SET_STATE[shell], CHECK_STATE[shell]]; @@ -309,7 +309,7 @@ test('live: after Ctrl+Z or Ctrl+C, what you type next is for the shell prompt, } finally { await sandbox.dispose(); } }); -test('live: a program that survives Ctrl+C gets ordinary routing again: a later line is held for approval, never sent into the program or run', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { +test('live: a program that survives Ctrl+C gets ordinary routing again: a later line stays in ownership quarantine, never sent into the program or run', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { const {sandbox, frontend} = await start('zsh'); try { const mark = frontend.mark; @@ -322,7 +322,7 @@ test('live: a program that survives Ctrl+C gets ordinary routing again: a later frontend.pty.write('\u0003'); await pause(4200); frontend.pty.write('echo LATER\r'); - await frontend.waitFor(/typed while \S+ ran/u, mark); + await frontend.waitFor(/Can.t tell whether \S+ is reading your typing/u, mark); writeFileSync(release, ''); await frontend.waitFor(/SURVIVED/u, mark, 20_000); await pause(1500); diff --git a/tests/fixtures/ownershipRaceEntry.mjs b/tests/fixtures/ownershipRaceEntry.mjs new file mode 100644 index 00000000..3a211be5 --- /dev/null +++ b/tests/fixtures/ownershipRaceEntry.mjs @@ -0,0 +1,5 @@ +const probe = new URL('./ownershipRaceProbe.mjs', import.meta.url); +probe.searchParams.set('gate', process.env.NMSH_TEST_OWNERSHIP_GATE); +await import(probe.href); +process.execArgv.push('--import=' + probe.href); +await import('../../src/index.ts'); diff --git a/tests/fixtures/ownershipRaceProbe.mjs b/tests/fixtures/ownershipRaceProbe.mjs new file mode 100644 index 00000000..26a73172 --- /dev/null +++ b/tests/fixtures/ownershipRaceProbe.mjs @@ -0,0 +1,28 @@ +/** Controlled observation seam only. Real shell reads, terminal modes and all routing remain production code. */ +import {appendFileSync, existsSync} from 'node:fs'; +import {InputWatch} from '../../src/session/InputWatch.ts'; +import {QueryOrder} from '../../src/shell/QueryOrder.ts'; +// The service deliberately strips frontend environment; the fixture passes only its private gate in this module URL. +const root = new URL(import.meta.url).searchParams.get('gate'); +if (!root) throw new Error('ownership race fixture needs its private gate'); +const trace = event => appendFileSync(root + '.jsonl', JSON.stringify({pid: process.pid, ...event}) + '\n'); +process.on('uncaughtExceptionMonitor', error => trace({event: 'fixture-error', message: error.message})); +trace({event: 'loaded'}); +const apply = InputWatch.prototype.apply; +InputWatch.prototype.apply = function(sample, now) { + const controlled = this.running?.command.includes('QA_RACE') && !existsSync(root + '.release'); + if (controlled) { + trace({event: 'sample', modes: sample.modes, states: sample.foreground.map(p => p.state), readingTerminal: sample.readingTerminal}); + sample = {...sample, readingTerminal: undefined, foreground: sample.foreground.map(p => ({...p, state: 'R' + p.state.replaceAll('R', '')}))}; + } + return apply.call(this, sample, now); +}; +const input = QueryOrder.prototype.input; +QueryOrder.prototype.input = function(...args) { + if (!this.ownershipRaceTrace) { + this.ownershipRaceTrace = true; + const write = this.write; + this.write = bytes => { trace({event: 'pty-write', bytes: Buffer.byteLength(bytes)}); return write.call(this, bytes); }; + } + return input.apply(this, args); +}; diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index d8360d3e..d7ba7e55 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -69,7 +69,7 @@ const READS: Record = { 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 () => { + test(`live ${shell}: a waiting read gets immediate or delayed input, with explicit recovery when ambiguous; 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]) { @@ -82,7 +82,14 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await pause(reactionMs); await type(frontend, read.keys); if (read.enter) frontend.pty.write('\r'); - await frontend.waitFor(read.answered, mark, 10_000); + const replyDeadline = Date.now() + 10_000; + await until(() => read.answered.test(strip(frontend.output.slice(mark))) + || /Can.t tell whether \S+ is reading your typing/u.test(strip(frontend.output.slice(mark))), 10_000, `${what}: answer or explicit ownership choice`); + if (!read.answered.test(strip(frontend.output.slice(mark)))) { + assert.equal(await queued(sandbox), 0, `${what}: ambiguous input is not command approval`); + frontend.pty.write('\u0013'); + } + await frontend.waitFor(read.answered, mark, Math.max(1, replyDeadline - Date.now())); // The answered command completes (its record is journaled after the screen, later on a slower host; Fish's // reader has the terminal, so its output is not journaled) and the shell is back at its prompt. Until then // the program's output can be on screen while the command still runs, and an Enter then queues, as designed. @@ -106,7 +113,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { }); } -test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop holds the line for approval, never run by itself', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { +test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop stays in ownership quarantine and never runs by itself', {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. @@ -132,15 +139,14 @@ test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during 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 held`); + await frontend.waitFor(/Can.t tell whether (?:sleep|for) is reading your typing|Typed while (?:sleep|for) ran[^\n]*held, not run/u, mark, 5000); + assert.equal(await queued(sandbox), 0, 'ownership uncertainty never becomes command approval'); await until(async () => (await records(sandbox)).some(record => record.command === command), 15_000, `${command} to complete`); await pause(1500); assert.equal((await records(sandbox)).filter(record => record.command === entry).length, 0, `${entry} did not run by itself`); - const review = frontend.mark; - frontend.pty.write('\r'); - await frontend.waitFor(/Approve and run this command\?/u, review); - frontend.pty.write('\r'); - await until(async () => (await records(sandbox)).filter(record => record.command === entry).length === 1, 15_000, `${entry} to run once, approved`); + assert.match(strip(frontend.output.slice(mark)), /held, not run/u, 'completion invalidates delivery but preserves review'); + frontend.pty.write('\u001b[27u'); + await frontend.run('echo AFTER-QUARANTINE', /AFTER-QUARANTINE/u); } } finally { await sandbox.dispose(); } }); @@ -159,13 +165,18 @@ test('live zsh: a command typed right after a read returns, or right after Ctrl+ await frontend.waitFor(/got:y/u, mark, 10_000); // Entered before the shell's prompt was back, the line was typed while the command ran, so it is held for approval // (#426); entered after, it runs. Either way it runs exactly once, never by itself while unverified. - await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`) || /typed while read ran/u.test(strip(frontend.output.slice(mark))), + await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`) || /[Tt]yped while read ran/u.test(strip(frontend.output.slice(mark))), 15_000, 'the typed command to run, or to be held'); if (!(await records(sandbox)).some(record => record.command === `echo X >> ${marker}`)) { await until(async () => (await records(sandbox)).some(record => record.command.startsWith('read -k 1')), 15_000, 'the read to complete'); const review = frontend.mark; - frontend.pty.write('\r'); - await frontend.waitFor(/Approve and run this command\?/u, review); + if (await queued(sandbox)) { + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, review); + } else { + frontend.pty.write('\t'); + await frontend.waitFor(/echo X >>/u, review); + } frontend.pty.write('\r'); await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`), 15_000, 'the approved command to run'); } diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts index 80c9adcf..83c495d3 100644 --- a/tests/inputOwnershipLook.test.ts +++ b/tests/inputOwnershipLook.test.ts @@ -36,6 +36,7 @@ function fixture() { 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}); + const originalSubmit = frame.app['submit']; 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 = () => {}; @@ -69,7 +70,7 @@ function fixture() { 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, queueOps, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, + return {...frame, originalSubmit, queueOps, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, interrupts: () => interrupts, looks: () => looks, settle: (result: LookResult | undefined) => settle(result)}; } @@ -93,6 +94,8 @@ test('#426: a reply to a prompt-less read is not queued when the look outlasts 8 // 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, [], 'likely evidence alone cannot submit a held Enter'); + f.app['onInput'](CTRL_S); assert.deepEqual(f.written, ['hello\r'], 'the answer reaches the read, once'); assert.deepEqual(f.adds(), [], 'nothing was queued'); assert.equal(f.text(), ''); @@ -134,8 +137,10 @@ test('#426: keys typed after a held Enter stay behind it, in order', async t => 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.written, [], 'likely evidence alone cannot submit a held Enter'); + f.app['onInput'](CTRL_S); + assert.deepEqual(f.written, ['hello\rls'], 'explicit send preserves every held byte in order'); + assert.equal(f.text(), '', 'the explicit send leaves no future command draft'); assert.deepEqual(f.adds(), []); } finally { f.dispose(); } }); @@ -253,7 +258,7 @@ test('#426 timeout: evidence that arrives while typing is held decides it (a hid } finally { f.dispose(); } }); -test('#426: a look that saw the terminal echoing routes type-ahead at once; no held state', async t => { +test('#426: echo permits draft display but Enter without a reader stays held', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -264,9 +269,10 @@ test('#426: a look that saw the terminal echoing routes type-ahead at once; no h assert.equal(f.text(), 'echo next'); f.enter(); await settled(); - assert.deepEqual(f.adds(), ['echo next'], 'queued as soon as the look answered'); + assert.deepEqual(f.adds(), [], 'echo is not reader evidence'); + assert.equal(f.text(), '', 'the draft moved into the hold'); assert.equal(f.looks(), 2, 'one look for the draft, one for the Enter'); - assert.doesNotMatch(f.screen(), HELD); + assert.match(f.screen(), HELD); assert.deepEqual(f.written, []); } finally { f.dispose(); } }); @@ -311,7 +317,7 @@ test('#426: type-ahead is not delayed beyond the look: it appears as soon as the } finally { f.dispose(); } }); -test('#426: an answer to a question the program printed goes as soon as the look answers, never queued', async t => { +test('#426: a held answer to a printed question needs confirmation when the look only infers a reader', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -324,6 +330,8 @@ test('#426: an answer to a question the program printed goes as soon as the look assert.deepEqual(f.adds(), []); f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, [], 'a likely reader cannot submit the held Enter'); + f.app['onInput'](CTRL_S); assert.deepEqual(f.written, ['y\r']); assert.deepEqual(f.adds(), []); } finally { f.dispose(); } @@ -395,3 +403,147 @@ test('#426: Ctrl+C discards held typing, as a terminal flushes its input on inte } finally { f.dispose(); } } }); + +for (const combined of [false, true]) { + test(`#473: echo-only Enter (${combined ? 'combined' : 'separate'} hold) needs confirmation after late likely evidence`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + f.begin(READ); + f.type('hello'); + if (!combined) await settled(); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), [], 'an answer is not turned into an approval-held command'); + assert.equal(f.text(), '', 'the entire draft belongs to the hold, not the future prompt'); + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, [], 'late likely evidence does not send'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), 'hello\r', 'explicit send recovers the answer exactly once'); + f.settle('visible'); + await settled(); + assert.equal(f.written.join(''), 'hello\r', 'late lookup cannot duplicate delivery'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } + }); +} + +for (const action of ['interrupt', 'escape', 'complete', 'queueCommand', 'done', 'prompt', 'terminalOwner', 'passthrough'] as const) { + test(`#473: ambiguous Enter followed by ${action} never silently sends or executes`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + f.begin(READ); + f.type('hello'); + await settled(); + f.enter(); + await settled(); + if (action === 'done') f.session.emit('inputState', {done: true, timing: {waitedMs: 0, waits: 0}}); + else if (action === 'prompt') f.complete(0); + else if (action === 'terminalOwner') f.session.emit('inputState', {terminalOwner: true, timing: {waitedMs: 0, waits: 0}}); + else if (action === 'passthrough') f.app['onActiveModeChange']('PASSTHROUGH'); + else f.app['handleKey']({kind: action}); + await settled(); + assert.deepEqual(f.written, [], 'neither the answer nor its Enter is sent'); + assert.deepEqual(f.adds(), [], 'nothing queued without a fresh explicit Enter'); + assert.deepEqual(f.submitted, []); + if (action === 'complete' || action === 'queueCommand') { + assert.equal(f.text(), 'hello', 'review restores text without Enter'); + if (action === 'queueCommand') { + f.enter(); + assert.deepEqual(f.adds(), ['hello'], 'explicit queue review Enter queues once'); + assert.equal(f.queueOps.find(op => op.op === 'add')?.op, 'add'); + assert.deepEqual(f.written, []); + } + } else { + assert.equal(f.text(), '', 'cancellation cannot leave a command draft'); + if (action === 'done' || action === 'prompt') assert.equal(f.app['ownershipLook']?.atPrompt, true, 'completion invalidates delivery but keeps typing for explicit review'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join('').includes('hello'), false, 'invalidated capture is never recovered'); + } + } finally { f.dispose(); } + }); +} + +test('#473: a submitted command taking the alternate screen invalidates its held Enter before new raw keys', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); + Object.defineProperty(f.session, 'submit', {value: () => {}, configurable: true}); + f.app['editor'].insert(READ); + await f.originalSubmit.call(f.app); + f.app['onShellExec'](READ); + f.type('hello'); await settled(); f.enter(); await settled(); + assert.deepEqual(f.adds(), []); + f.app['onShellData']('\u001b[?1049h'); + assert.equal(f.app['passthrough'], true, 'the real submitted output callback handed over the terminal'); + assert.equal(f.app['ownershipLook'], undefined, 'ownership change invalidates the old decision'); + f.app['onInput']('X'); + assert.equal(f.written.join(''), 'X', 'only newly typed raw input reaches the new owner'); + } finally { f.dispose(); } +}); + +for (const result of ['unknown', undefined] as const) { + test(`#473: ${result ?? 'older-service'} lookup followed by false-positive likely wait never submits a held Enter`, async () => { + const f = fixture(); + try { + f.quickLooks(result); f.begin('wait'); f.type('hello'); f.enter(); await settled(); + f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); assert.equal(f.text(), ''); + f.app['handleKey']({kind: 'escape'}); f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); assert.equal(f.text(), ''); + } finally { f.dispose(); } + }); +} + +test('#473: a password transition cannot expose or auto-send a confirmation-held reply; Tab reviews without acting', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin('sleep 1; read -s pw'); f.type(SECRET); await settled(); f.enter(); await settled(); + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); await settled(); + f.type('X'); + f.nowhere(SECRET, 'later hidden reader'); assert.deepEqual(f.written, []); + f.app['onInput'](TAB); + assert.equal(f.text(), `${SECRET}\nX`, 'review preserves editing order but never replays Enter as submission'); + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); await settled(); + assert.deepEqual(f.written, [], 'a reader re-report cannot steal the reviewed queue draft'); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +for (const change of ['exec', 'stop'] as const) { + test(`#473: ${change} invalidates confirmation before a stale lookup can recover it`, async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin(READ); f.type('hello'); await settled(); f.enter(); await settled(); + if (change === 'exec') f.app['onShellExec']('echo other'); + else f.app['stop'](0); + f.settle('visible'); await settled(); + assert.equal(f.app['ownershipLook'], undefined); assert.equal(f.text(), ''); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } + }); +} + +test('#473: likely hidden inference keeps even an unfinished held reply private until explicit send', async () => { + const f = fixture(); + try { + f.slowLooks(); f.begin('sleep 1; read -s pw'); f.type(SECRET); + f.found({since: 0, confidence: 'likely', mode: 'hidden'}); await settled(); + f.nowhere(SECRET, 'echo off without reliable reader ownership'); + assert.deepEqual(f.written, [], 'merely likely hidden evidence cannot deliver held bytes'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), SECRET); + } finally { f.dispose(); } +}); + +test('#473: a whole slash action held by the first-key look is still parsed as an NMSh action', async () => { + const f = fixture(); + try { + f.quickLooks('visible'); f.begin('sleep 30'); f.type('/queue'); f.enter(); await settled(); + assert.deepEqual(f.submitted, ['/queue']); + assert.equal(f.app['ownershipLook'], undefined); + assert.deepEqual(f.written, []); assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipRaceLive.test.ts b/tests/inputOwnershipRaceLive.test.ts new file mode 100644 index 00000000..7409311e --- /dev/null +++ b/tests/inputOwnershipRaceLive.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const entry = fileURLToPath(new URL('./fixtures/ownershipRaceEntry.mjs', import.meta.url)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +for (const reactionMs of [0, 300]) { + test(`controlled real PTY: echo-only immediate read after ${reactionMs}ms needs explicit recovery, never command approval`, {skip: !supported, timeout: 60_000}, async t => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const gate = join(sandbox.home, 'ownership-gate'); + const frontend = sandbox.launch([], {cols: 120, rows: 36}, {NMSH_TEST_OWNERSHIP_GATE: gate}, entry); + const trace = () => existsSync(gate + '.jsonl') ? readFileSync(gate + '.jsonl', 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as {event: string; bytes?: number}) : []; + try { + // As in the existing live fixture: do not let an admin poll close an empty service before create/attach. + await frontend.waitFor(/zsh/u); + await until(async () => (await sandbox.sessions()).some(s => s.lastExit !== undefined), 30_000, () => 'authenticated prompt: ' + strip(frontend.output).slice(-1000)); + await frontend.run('echo READY', /READY/u); + const command = `read -r QA_RACE; printf 'RECOVERED=%s\\n' "$QA_RACE"`; + const before = trace().length; + const mark = frontend.mark; + frontend.pty.write(command + '\r'); + await pause(reactionMs); + frontend.pty.write('hello\r'); + // Old source reaches the queue instead. Do not wait for a lucky later inference or resend an answer. + await until(async () => /Can.t tell whether read is reading your typing/u.test(strip(frontend.output.slice(mark))) + || (await sandbox.sessions())[0]?.queued === 1, 5000, 'ownership hold or old-source approval enqueue'); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0, 'echo-only Enter must not become an approval entry'); + assert.match(strip(frontend.output.slice(mark)), /Can.t tell whether read is reading your typing/u); + assert.ok(trace().some(e => e.event === 'sample'), 'the control exercised real probe observations'); + writeFileSync(gate + '.release', ''); + await until(async () => (await sandbox.sessions())[0]?.inputMode === 'line', 10_000, 'genuine waiting-reader evidence after releasing the observation control'); + assert.equal(trace().slice(before).filter(e => e.event === 'pty-write').reduce((sum, e) => sum + e.bytes!, 0), Buffer.byteLength(command + '\r'), 'only the submitted command reached the PTY before confirmation'); + assert.equal((await sandbox.sessions())[0]?.running, command, 'late likely/confirmed evidence cannot auto-answer the quarantine'); + frontend.pty.write('\u0013'); + await frontend.waitFor(/RECOVERED=hello/u, mark, 10_000); + await until(async () => await frontend.completions(command) === 1, 15_000, 'one durable read completion'); + const records = (await sandbox.transcripts().list()).flatMap(s => s.transcript.records); + assert.equal(records.find(r => r.command === command)?.output.match(/RECOVERED=hello/gu)?.length, 1); + assert.equal(records.some(r => r.command === 'hello'), false); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); + await frontend.run('echo AFTER', /AFTER/u); + } catch (error) { + if (error instanceof Error) error.message += '; metadata=' + JSON.stringify(trace()); + throw error; + } finally { t.diagnostic(JSON.stringify({reactionMs, trace: trace()})); await sandbox.dispose(); } + }); +} diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 9d5f6bd8..918ec219 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -201,73 +201,43 @@ test('a second hidden question NMSh sees while the reply is held gets it, never } finally { f.dispose(); } }); -test('an ordinary command typed after a hidden answer, once the terminal echoes again: drawn, never discarded; Enter holds it for approval, Ctrl+Q runs it after', async t => { +test('an ordinary command after a hidden answer: echo permits editing, ambiguous Enter needs a choice, Ctrl+Q explicitly queues', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sudo -k make install', 'Password:'); - f.answerHidden('pw'); - f.state(); - f.tick(300); - f.type('make test'); - f.answer('visible'); - await settled(); - assert.equal(f.text(), 'make test', 'drawn as the next command'); - f.enter(); - f.answer('visible'); - await settled(); - assert.deepEqual(f.adds(), ['make test'], 'kept in the queue, not discarded'); - assert.equal(f.owner.state.entries[0]?.condition, 'approve', 'a plain Enter while sudo runs holds it for approval'); - f.type('make lint'); - f.answer('visible'); - await settled(); + f.answerHidden('pw'); f.state(); f.tick(300); + f.type('make test'); f.answer('visible'); await settled(); + assert.equal(f.text(), 'make test'); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), [], 'ownership hold, not approval queue'); f.key('\u0011'); - f.complete(0); - await settled(); - assert.deepEqual(f.dispatched, [], 'the held line is first, so nothing runs before the person approves it'); + assert.equal(f.text(), 'make test', 'Ctrl+Q reviews the held text without submitting'); f.enter(); - f.tick(1000); - f.enter(); - await settled(); - assert.deepEqual(f.dispatched, ['make test'], 'approved, it runs once'); - f.begin('make test'); - f.complete(0); - await settled(); - assert.deepEqual(f.dispatched, ['make test', 'make lint'], 'and the Ctrl+Q command after it, in order, with no approval'); - assert.doesNotMatch(f.screen(), HELD); + assert.deepEqual(f.adds(), ['make test']); + f.type('make lint'); f.answer('visible'); await settled(); f.key('\u0011'); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['make test']); + f.begin('make test'); f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['make test', 'make lint']); } finally { f.dispose(); } }); -test('a reply typed before a later hidden question with no prompt is queued (NMSh could not know), then waits for approval, never running by itself', async t => { +test('a reply entered before a later hidden question stays private and needs explicit send, never runs by itself', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('read -s a; sleep 2; read -s b'); - f.answerHidden('first'); - // The read returned; the command works with the terminal echoing; nothing asks. - f.state(); - f.tick(1500); - f.type(SECRET); - f.answer('visible'); - await settled(); - f.enter(); - f.answer('visible'); - await settled(); - assert.deepEqual(f.adds(), [SECRET], 'queued like any command typed while one runs (documented limit)'); - // The second question, with no prompt: from now on that entry may be its reply. - f.tick(500); - f.hidden(); - await settled(); - const entry = f.owner.state.entries.find(item => item.text === SECRET); - assert.equal(entry?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /hidden input/u, 'a note says why, without the text'); - assert.equal(f.transcript().includes(SECRET), false); - // The person answers the question again; the command succeeds: the entry still does not run. - f.type('second'); - f.enter(); - f.complete(0); - await settled(); - f.neverRun(SECRET, 'after the command succeeded'); + f.answerHidden('first'); f.state(); f.tick(1500); + f.type(SECRET); f.answer('visible'); await settled(); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), []); + f.tick(500); f.hidden(); await settled(); + assert.equal(f.written.join(''), 'first\r', 'even later hidden evidence cannot approve ambiguous Enter'); + f.nowhere(SECRET, 'late hidden question'); + f.key('\u0013'); + assert.equal(f.written.join(''), `first\r${SECRET}\r`); + f.complete(0); await settled(); f.neverRun(SECRET, 'after'); } finally { f.dispose(); } }); @@ -417,27 +387,19 @@ test('gate: a password and Enter typed before the program asks: not run, not lef } finally { f.dispose(); } }); -test('gate: a visible reply queued before NMSh saw the read waits for approval once the question is seen; never run after it', async t => { +test('gate: echo-only Enter stays held after a late likely read; explicit send recovers it without approval queueing', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin(`read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`); - // A loaded machine: the look cannot yet tell that the shell's builtin reads, so the Enter queues the reply. - f.type('hello'); - f.answer('visible'); await settled(); - f.enter(); - f.answer('visible'); await settled(); - assert.deepEqual(f.adds(), ['hello'], 'queued (documented limit under load)'); - f.tick(1500); - f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); - await settled(); - assert.equal(f.owner.state.entries.find(item => item.text === 'hello')?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /asked for input/u); - // The person answers again; the command succeeds; the reply is never run as a command. - f.type('hello'); - f.enter(); - f.complete(0); await settled(); - assert.deepEqual(f.dispatched, [], 'never run after the command'); + f.type('hello'); f.answer('visible'); await settled(); + f.enter(); f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), []); + f.tick(1500); f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); await settled(); + assert.deepEqual(f.written, [], 'likely inference cannot release the Enter'); + f.key('\u0013'); + assert.equal(f.written.join(''), 'hello\r'); + f.complete(0); await settled(); assert.deepEqual(f.dispatched, []); } finally { f.dispose(); } }); @@ -458,10 +420,9 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s f.answer('visible'); await settled(); f.enter(); f.answer('visible'); await settled(); - assert.deepEqual(f.adds(), [SECRET], 'queued (NMSh could not know)'); - const entry = f.owner.state.entries.find(item => item.text === SECRET); - assert.equal(entry?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); + assert.deepEqual(f.adds(), [], 'the ambiguous answer stays in the ownership hold'); + assert.equal(f.text(), '', 'no future command draft'); + assert.match(f.screen(), /Can.t tell whether sleep is reading/u); assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); // No question was ever reported: the read gave up unseen, and the command succeeded. f.tick(1500); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index 507fb011..4c77de9d 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -39,17 +39,21 @@ for (const shell of ['zsh', 'bash'] as const) { const command = 'read -rs a; sleep 3; read -rs b; echo "LENGTHS=${#a}/${#b}"'; frontend.pty.write(`${command}\r`); await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + const firstQuestion = (await sandbox.sessions())[0]?.inputSince; frontend.pty.write('first\r'); // The first read returned; `sleep` runs with the terminal echoing again and nothing asks. await pause(400); const typed = frontend.mark; for (const char of SECRET) { frontend.pty.write(char); await pause(15); } frontend.pty.write('\r'); - await frontend.waitFor(/1 queued/u, typed, 10_000); + await frontend.waitFor(/Can.t tell whether read is reading your typing/u, typed, 10_000); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0, 'an early password is not an approval entry'); + const held = frontend.mark; // The second read, with no prompt: NMSh reports it, and the entry may be its reply. - await frontend.waitFor(/asked for hidden input/u, typed, 20_000); - frontend.pty.write('second\r'); - await frontend.waitFor(/LENGTHS=5\/6/u, mark, 20_000); + await until(async () => { const current = (await sandbox.sessions())[0]; return current?.inputMode === 'hidden' && current.inputSince !== firstQuestion; }, 20_000, 'the second hidden reader'); + assert.equal(strip(frontend.output.slice(held)).includes(SECRET), false, 'late hidden input never reveals the capture'); + frontend.pty.write('\u0013'); + await frontend.waitFor(/LENGTHS=5\/10/u, mark, 20_000); const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); // Anything that would run after it has had time to start. @@ -60,7 +64,7 @@ for (const shell of ['zsh', 'bash'] as const) { const history = readFileSync(join(sandbox.home, histfile), 'utf8'); assert.match(history, /echo READY/u, 'the shell records history here'); assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); - assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); } finally { await sandbox.dispose(); } }); @@ -141,7 +145,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await pause(500); for (const char of typed) { frontend.pty.write(char); await pause(15); } frontend.pty.write(runs ? '\u0011' : '\r'); - await frontend.waitFor(new RegExp(`${runs ? 1 : index} (?:queued|waiting)`, 'u'), mark, 10_000); + await frontend.waitFor(runs ? /1 (?:queued|waiting)/u : /Can.t tell whether sleep is reading your typing|Typed while sleep ran[^\n]*held, not run/u, mark, 10_000); await until(async () => await ran() === before + 1, 20_000, 'the command to complete'); if (runs) { await until(async () => (await records()).some(record => record.command === typed), 15_000, 'the real command to run after it'); @@ -150,9 +154,11 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { // Anything that would run after it has had time to start. await pause(2500); assert.equal((await records()).some(record => record.command === typed), false, `${typed}: never run, not in NMSh's journal`); - assert.match(strip(frontend.output.slice(mark)), /never runs by itself/u); + assert.match(strip(frontend.output.slice(mark)), /held, not run/u); + assert.equal((await sandbox.sessions())[0]?.queued ?? 0, 0); assert.doesNotMatch(strip(frontend.output.slice(mark)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such|Unknown command)|(?:not found|No such|Unknown command)[^\\n]*${SECRET}`, 'u'), 'the shell never saw it as a command'); + frontend.pty.write('\u001b[27u'); // Explicitly discard this capture before the next independent case. if (shell !== 'fish') { const history = readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').split('\n'); assert.equal(history.some(line => line.replace(/^: \d+:\d+;/u, '') === typed), false, `${typed}: not in the shell's history`); diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index 5cbfb3d0..b581d625 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -67,11 +67,11 @@ for (const reply of REPLIES) { f.begin('sleep 1; read -t 1 QA_REPLY || true'); await f.typeLine(reply); const held = f.entry(reply); - assert.equal(held?.condition, 'approve', 'it waits for approval, whatever it looks like'); - assert.equal(held?.typedWhile, 'sleep', 'the owner knows it was typed while sleep ran'); + assert.equal(held, undefined, 'it stays in the ownership hold, not the approval queue'); + assert.equal(f.text(), '', 'cancellation cannot leave a future command draft'); assert.equal(f.written.some(data => data.includes(reply)), false, 'not written to the terminal'); assert.equal(f.recalled().includes(reply), false, 'not offered by Up as a command typed this session'); - assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); + assert.match(f.screen(), /Can.t tell whether sleep is reading/u, 'the ownership choice is visible'); // No question was ever reported: the read gave up unseen, and the command succeeded. f.tick(1500); f.complete(0); await settled(); @@ -86,7 +86,7 @@ test('quarantine: the held line is shown by where it came from, never by its tex try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1; read -t 1 QA_REPLY || true'); - await f.typeLine(SECRET); + f.owner.holdUnread(`${SECRET}\r`, 'sleep'); assert.equal(f.screen().includes(SECRET), false, 'while the command runs'); assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); f.complete(0); await settled(); @@ -100,7 +100,7 @@ test('quarantine: approving is explicit and needs no display; Tab shows the text try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1'); - await f.typeLine('echo NEXT-QZ'); + f.owner.holdUnread('echo NEXT-QZ\r', 'sleep'); f.complete(0); await settled(); assert.deepEqual(f.dispatched, []); // Enter at the empty composer arms the review; nothing runs from one key. @@ -131,12 +131,12 @@ test('Ctrl+Q is the explicit run-after: a command queued with it while one runs } finally { f.dispose(); } }); -test('the composer says where Enter sends a line while a command runs: held for approval, Ctrl+Q runs it after', async t => { +test('the composer says where Enter sends a line while a command runs: checks ownership, Ctrl+Q runs it after', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('make'); - assert.match(f.screen(), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to make/u); + assert.match(f.screen(), /Enter checks who reads it · Ctrl\+Q runs it after · Ctrl\+S sends to make/u); } finally { f.dispose(); } }); @@ -284,7 +284,7 @@ test('a held line at the head of the queue never holds the prompt: a command sub try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1'); - await f.typeLine('yes'); + f.owner.holdUnread('yes\r', 'sleep'); f.complete(0); await settled(); f.type('echo NOW'); f.enter(); diff --git a/tests/ptyResizeLive.test.ts b/tests/ptyResizeLive.test.ts index 1b5a94ab..b3b2ea5e 100644 --- a/tests/ptyResizeLive.test.ts +++ b/tests/ptyResizeLive.test.ts @@ -42,9 +42,9 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { frontend.pty.write('\r'); await frontend.waitFor(/❯ line one/u, mark); await pause(500); - // Enter holds it for approval: a note in the transcript and a queue row. + // Enter moves the draft into ownership quarantine; this chrome must not resize the PTY. frontend.pty.write('\r'); - await frontend.waitFor(/never runs by itself|waits for your approval/u, mark); + await frontend.waitFor(/Can.t tell whether sh is reading your typing/u, mark); await pause(500); assert.equal(count(), 0, 'no signal from NMSh\'s own chrome'); frontend.pty.resize(100, 40); diff --git a/tests/queueConditionOwnershipLive.test.ts b/tests/queueConditionOwnershipLive.test.ts index 9297af9f..1ef40a50 100644 --- a/tests/queueConditionOwnershipLive.test.ts +++ b/tests/queueConditionOwnershipLive.test.ts @@ -64,7 +64,7 @@ for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { first.pty.write('w'); await first.waitFor(/runs even after a failure/u, mark); first.pty.write('\u001b[27u'); - await first.waitFor(/Enter holds it for approval · Ctrl\+Q runs it after/u, mark); + await first.waitFor(/Enter checks who reads it · Ctrl\+Q runs it after/u, mark); first.pty.write('\u0003'); await first.waitFor(/Queue paused:[\s\S]*was interrupted/u, mark); await first.run('echo AFTER-INTERRUPT', /AFTER-INTERRUPT/u); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 858469de..232bda99 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -29,7 +29,8 @@ async function showAndRunHeld(frontend: Frontend, mode: string, run: number, nex frontend.pty.write('\r'); return; } - assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.ok(mode === 'ahead' && (run % 2 === 1 || /Typed while \S+ ran[^\n]*held, not run/u.test(strip(frontend.output.slice(mark)))), + `only hidden or explicitly quarantined type-ahead is held (${mode}${run})`); assert.equal(new RegExp(`(? Date: Wed, 11 Nov 2026 19:56:38 +0530 Subject: [PATCH 2/5] Queue Mode, unfinished command lines and accurate program names Queue Mode (#473, #426): an explicit per-window choice that Enter queues a line while a command runs, shown as typed and run once, in order, with no approval step. Ctrl+Q on an empty line or /queue mode [on|off] toggles it; Settings > Queue > Start in Queue Mode sets how windows start (off by default). Input ownership is unchanged: the look before typing is drawn, Enter to a program NMSh sees waiting, hidden input and the held state when a look cannot tell all behave as before. At the prompt a Queue Mode line is added after entries already waiting instead of running ahead of them; only a paused queue gives the prompt back. Unfinished command lines: zsh and Bash print PS2 as a private marker. A continuation prompt the submission's own line breaks do not explain means the shell waits for the rest of a line NMSh sent whole (an unclosed quote, or zsh history expansion turning `!"` into an open quote). The session cancels it with the adapter's sequence (zsh: Ctrl+C then Enter; Bash: Ctrl+C), the prompt carries `incomplete`, the frontend explains it and gives the line back, and the queue pauses before what follows. Previously the line waited at an invisible PS2 forever and typing for it was held as "Can't tell whether echo is reading your typing". Program names: a line of several commands is called "the command", never its first word, which may have finished long ago. --- CHANGELOG.md | 7 + docs/design/input-awareness.md | 14 ++ src/app/TerminalApp.ts | 103 ++++++++++-- src/commands/slashCommands.ts | 6 +- src/prompt/configuration.ts | 10 +- src/session/QueueDispatcher.ts | 6 +- src/session/SessionProtocol.ts | 4 +- src/session/SessionService.ts | 6 +- src/session/SocketSessionClient.ts | 3 +- src/session/StreamBacklog.ts | 2 +- src/setup/SetupCat.ts | 3 +- src/shell/ShellProtocol.ts | 13 +- src/shell/ShellSession.ts | 37 ++++- src/shell/adapters/BashAdapter.ts | 6 +- src/shell/adapters/ShellAdapter.ts | 7 + src/shell/adapters/ZshAdapter.ts | 4 +- src/ui/SettingsPanel.ts | 4 +- tests/incompleteSubmission.test.ts | 103 ++++++++++++ tests/inputOwnershipLook.test.ts | 5 +- tests/inputQuarantine.test.ts | 4 +- tests/queueMode.test.ts | 246 +++++++++++++++++++++++++++++ tests/queueModeLive.test.ts | 134 ++++++++++++++++ tests/queueSettings.test.ts | 2 +- 23 files changed, 697 insertions(+), 32 deletions(-) create mode 100644 tests/incompleteSubmission.test.ts create mode 100644 tests/queueMode.test.ts create mode 100644 tests/queueModeLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e165a6..d0ff98a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Queue Mode** ([#473](https://github.com/raiseCatError/notMyShell/issues/473), [#426](https://github.com/raiseCatError/notMyShell/issues/426)). While a command runs, Enter normally holds a line for your approval, because NMSh cannot prove it is not a reply to a program that asked nothing visible. Queue Mode is the explicit alternative: Enter queues the line to run after the current command, shown as typed, once and in order, with no approval step — what Ctrl+Q already did, without the extra key. Turn it on or off for one window with **Ctrl+Q on an empty line** or `/queue mode [on|off]`; **Settings → Queue → Start in Queue Mode** (off by default) chooses how new windows start, and a toggle never changes that setting. The composer says when it is on. At the prompt, a line entered in Queue Mode never runs ahead of entries already waiting (a held line at the head included): it is added after them, and the composer says how many wait. Only a queue paused after a failure, an interrupt or `/queue pause` gives the prompt back, so a fix runs at once. Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input gets Enter, hidden input is never drawn or queued, and when NMSh cannot tell who reads the typing it stays held as before. A program that reads with no prompt and that NMSh cannot see (`cat`, a script's `input()`) gets a queued line instead of its answer in Queue Mode: use Ctrl+S, or leave Queue Mode off. + - **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings. - **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works. - **Mermaid diagrams in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462); [design](docs/design/diagrams.md)). A ```` ```mermaid ```` fence is drawn as a real diagram — flowcharts, sequence, class, state, ER, Gantt, pie, journey, git graph, mind map, timeline, quadrant, XY, requirement, Sankey, block and C4 diagrams — in Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the viewer and Hybrid's inactive blocks, with the image backend your terminal supports (Kitty graphics, iTerm2, Sixel, or character art); `i` opens it in the image viewer to zoom and pan. NMSh uses the Mermaid you installed (`npm install -g mermaid`, or `NMSH_MERMAID`) inside the real browser it already drives, and downloads nothing. The page that draws runs no document script, loads nothing from the network or disk and returns only pixels; Mermaid's security settings are locked against directives. Settings → General → **Mermaid diagrams**: *On request* (default: press `D`, or F1 › Diagrams while editing — opening a document never starts a browser), *Automatic*, or *Off*. The fence stays the source: in Hybrid the block under the caret is its exact text, edits are drawn after a pause, and anything not drawn (not asked yet, no browser or Mermaid, a syntax error, too large, a timeout) shows the fence as written with `(diagram not drawn: reason)`. Drawings are cached by content and theme, bounded in number, bytes and time, and the renderer page closes when no document shows a diagram. @@ -25,6 +27,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Markdown editing helpers in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), first slice; Markdown files only). **Enter** continues a bullet, numbered or task list (same marker and indentation, next number, unchecked box) and splits an item at the caret; on an empty item it ends the list (a nested one steps out a level). **Tab / Shift+Tab** on list items indent or outdent the item with its children (selected items together); elsewhere Tab is a tab as before. **F1** opens the Markdown actions: toggle a task checkbox, renumber the list at the caret, insert a link or image around the selection, wrap the selection in bold, italic, code or strikethrough (again to unwrap). Every action changes only the lines it names and is one undo step; text is never reflowed or regenerated. - **Crash recovery for `/edit` and the document workspace** ([#469](https://github.com/raiseCatError/notMyShell/issues/469)). Unsaved changes are written, a moment after you stop typing, to a private draft in NMSh's data folder (`recovery/`, mode 0700; files 0600). After a crash, a kill or a closed window, opening the file offers **Restore draft**, **Compare with file**, **Discard draft** or **Keep for later**; nothing is applied until you choose, a draft made from an older version of the file says so, and the file itself is only ever written by the editor's normal checked save (which also retires the draft). Two windows on one file keep separate drafts, and a draft of a window that is still running is never offered. Drafts are bounded (2 MiB each, 50 in all, 30 days) and are never sent anywhere. +### Fixed + +- **A command line the shell could not finish no longer leaves NMSh waiting forever.** NMSh draws no continuation prompt, so a line the shell read as unfinished — an unclosed quote, bracket or heredoc, or in zsh `echo "Done!"`, where `!"` (history expansion) removes the closing quote — showed as running with nothing happening, Ctrl+C did not bring the prompt back (zsh acts on it only when its next line arrives), and typing was held as "Can't tell whether echo is reading your typing". The shell's continuation prompt is now an invisible marker for zsh and Bash: when the shell asks for more of a line NMSh submitted whole, NMSh cancels it at once (nothing of it ran), says why, and puts the line back in the composer to fix. A queued line that cannot be finished pauses the queue before anything after it, whatever the failure policy. Multi-line blocks and heredocs are unaffected; Fish keeps unfinished lines in its own reader. +- **Held typing and held queue entries name the program accurately.** For a line of several commands (`a; b`, `a && b`, a pipeline, a multi-line block) NMSh says "the command" instead of the first word, which had often finished long ago ("Can't tell whether echo is reading your typing" while `sleep` ran). + ## [0.23.0] - 2026-10-21 — Vespyr Arcade The Arcade grows from five games to 33, and it can now be played while a command keeps running. diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index eb8f0ba5..e2215489 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -171,6 +171,20 @@ typed is drawn, not even as dots. - NMSh gestures and pointer events are never sent. - Nothing typed is shown, journaled, put in history or written to the key-debug log. - **There are no answer buttons.** NMSh never submits an answer on your behalf. +- **No question in sight** (nothing waits that NMSh can see): Enter holds a line for approval and shows it by where it + came from, because a prompt-less read is indistinguishable from a command NMSh cannot see (#426). Ctrl+Q queues it + as typed. **Queue Mode** (per window: Ctrl+Q on an empty line or `/queue mode`; Settings chooses how windows start) + makes Enter do what Ctrl+Q does. It changes only this case: the look before typing is drawn, Enter to a program that + waits, hidden input and the held state when a look cannot tell are all as above. Its accepted cost is the prompt-less + read NMSh cannot see (an external program reading with no prompt): in Queue Mode the line is queued, visibly, instead. + At the prompt, Queue Mode keeps order: a line never runs ahead of entries already waiting (a held head included); + only a paused queue gives the prompt back. +- **Unfinished command lines.** NMSh draws no continuation prompt, so zsh and Bash print PS2 as an invisible marker. + The session counts the continuation prompts a submission's own line breaks explain; one more, before the command + starts, means the shell is waiting for the rest of a line NMSh sent whole (an unclosed quote, bracket or heredoc; + zsh's `!"` history expansion). The session cancels it there (zsh needs Ctrl+C then Enter: it acts on the interrupt + only when its read returns; Bash needs Ctrl+C), nothing of it has run, and the next prompt carries `incomplete` so + every window explains it and the queue pauses before what follows. Fish keeps unfinished lines in its own reader. ## Other sessions diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cbfa17a..3f331770 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -988,6 +988,8 @@ export class TerminalApp { private staleStopReport?: () => void; /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ private replayTypedWhile?: string; + /** This window's Queue Mode choice (Ctrl+Q on an empty line, /queue mode); undefined follows Settings. */ + private queueModeChoice?: boolean; /** Held lines (typedWhile) the person chose to see, by queue entry id; everything else shows where it came from. */ private readonly revealedQueue = new Set(); /** Held lines this window added and has not yet seen in the owner's state (so others are the owner's, #426). */ @@ -1120,7 +1122,9 @@ export class TerminalApp { // Host OSC 7 / OSC 133: a projection of this authoritative marker, never the other way round. this.hostSemantics.prompt(marker.cwd, marker.exitCode); this.completedInputTiming = marker.inputWaits ? {waitedMs: marker.inputWaitMs ?? 0, waits: marker.inputWaits} : undefined; + const unfinished = marker.incomplete ? this.running?.command : undefined; this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); + if (unfinished !== undefined) this.explainIncomplete(unfinished); } }); this.session.on('exec', (command, stamp) => { if (!this.stopped && this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed, stamp.queued); } }); @@ -2866,6 +2870,7 @@ export class TerminalApp { else if (key.kind === 'deleteLineAfter') this.editor.deleteLineAfter(); else if (key.kind === 'delete') this.editor.delete(); else if (key.kind === 'newline') this.editor.insert('\n'); + else if (key.kind === 'queueCommand' && !this.editor.text.trim() && !this.queueCompose && !this.queueEdit && this.queueAvailable()) this.setQueueMode('Ctrl+Q'); else if (key.kind === 'queueCommand') this.routeComposer('queue'); else if (key.kind === 'sendToProgram') this.routeComposer('send'); else if (key.kind === 'enter') this.routeComposer('enter'); @@ -3305,6 +3310,7 @@ export class TerminalApp { else if (slash.kind === 'doctor') void this.openDoctor(); else if (slash.kind === 'watch') this.handleWatch(command.trim(), slash.op, slash.arguments, slash.track === true); else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); + else if (slash.kind === 'queueMode') this.setQueueMode(command.trim(), slash.value); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); else { @@ -13025,7 +13031,7 @@ export class TerminalApp { 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. Typing held // at the prompt for a command that ended is not this command's: the composer says it. - if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(runningProgramName(this.running.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); @@ -13071,18 +13077,26 @@ export class TerminalApp { if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; } + // Queue Mode is a standing choice for this window: the empty composer says so at the prompt too. + if (!this.running && !this.passthrough && !this.queueEdit && !this.startupPending && this.queueModeOn()) { + const waiting = this.queueState.entries.length; + if (waiting && this.queueState.paused) return {text: `Queue Mode · queue paused, ${waiting} waiting · Enter runs it now · Ctrl+Q queues it after them`, waiting: false}; + if (waiting) return {text: `Queue Mode · Enter adds it after ${waiting} waiting · Ctrl+Q on an empty line turns Queue Mode off`, waiting: false}; + return {text: 'Queue Mode · while a command runs, Enter queues · Ctrl+Q on an empty line turns it off', waiting: false}; + } 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 {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}; + const program = request ? inputProgram(request, this.running.command) : runningProgramName(this.running.command); + if (this.ownershipLook?.uncertain) return {text: heldInputActions(runningProgramName(this.running.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}; if (!this.queueAvailable()) return undefined; - return {text: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; + if (this.queueModeOn()) return {text: `Queue Mode · Enter queues it to run next · Ctrl+S sends to ${program}`, waiting: false}; + return {text: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to ${program} · Ctrl+Q on an empty line: Queue Mode`, waiting: false}; } /** @@ -13171,6 +13185,31 @@ export class TerminalApp { return this.promptConfiguration.commandQueue !== false && this.session.features.has('queue'); } + /** + * Queue Mode, for this window: while a command runs, Enter queues the line as an ordinary command, as Ctrl+Q does, + * instead of holding it for approval. Input ownership still comes first: a program NMSh sees waiting for input gets + * Enter, and typing NMSh has not placed yet is looked at before it is drawn. Starts from Settings (off by default); + * Ctrl+Q on an empty line and /queue mode change it for this window only, so a stray key never persists. + */ + private queueModeOn(): boolean { + return this.queueAvailable() && (this.queueModeChoice ?? this.promptConfiguration.queue.queueMode === true); + } + + private setQueueMode(source: string, value?: boolean): void { + if (!this.queueAvailable()) { + this.output.addFrontendInteraction(source, this.session.features.has('queue') + ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); + this.render(); + return; + } + const on = value ?? !this.queueModeOn(); + this.queueModeChoice = on; + this.output.addFrontendInteraction(source, on + ? 'Queue Mode on: while a command runs, Enter queues the line to run after it. A program waiting for input still gets Enter. Ctrl+Q on an empty line turns it off.' + : 'Queue Mode off: while a command runs, Enter holds a line for your approval; Ctrl+Q queues it.', INFO); + this.render(); + } + /** The queue's line: what is queued and what runs next, or why it waits; an edit in progress says how to finish. */ private queueLine(columns: number): string | undefined { if (!this.queueAvailable()) return undefined; @@ -13326,7 +13365,7 @@ export class TerminalApp { this.session.queue({op: 'add', text, ...(conditions ? {condition: 'approve' as const, typedWhile} : {})}); if (conditions) this.ownHeldAdds += 1; // The note never repeats the text: it may be a password. - this.output.addFrontendInteraction(typedWhile, `The line entered while ${typedWhile} runs may be its reply, so it never runs by itself: it ${conditions ? 'waits for your approval' : 'pauses the queue'} (/queue). Ctrl+Q queues a command to run after.`, INFO); + this.output.addFrontendInteraction(typedWhile, `The line entered while ${typedWhile} runs may be its reply, so it never runs by itself: it ${conditions ? 'waits for your approval' : 'pauses the queue'} (/queue). Ctrl+Q queues a command to run after; Queue Mode (Ctrl+Q on an empty line) makes Enter queue.`, INFO); } this.editor.clear(); this.composerHistory.reset(); @@ -13398,12 +13437,14 @@ export class TerminalApp { } if (!text.trim()) return; if (this.reviewMixedBlock(text)) return; - this.enqueue(text, intent === 'enter' ? commandWords(this.running.command)[0] ?? 'the command' : undefined); + // Queue Mode: the person said Enter queues. Nothing waits for input that NMSh can see (that took Enter above), + // so the line is a command for after this one, shown as typed and run without an approval step. + this.enqueue(text, intent === 'enter' && !this.queueModeOn() ? runningProgramName(this.running.command) : undefined); return; } if (intent === 'send') return; // Held keys replayed after their command ended: this Enter was pressed while it ran, so it is no command (#426). - if (this.replayTypedWhile !== undefined && intent === 'enter' && text.trim() && !slash) { this.enqueue(text, this.replayTypedWhile); return; } + if (this.replayTypedWhile !== undefined && intent === 'enter' && text.trim() && !slash) { this.enqueue(text, this.queueModeOn() ? undefined : this.replayTypedWhile); return; } if (this.reviewMixedBlock(text)) return; if (this.pendingQueuePause) { this.render(); return; } // An empty Enter where the queue offers something arms its review; nothing runs yet. @@ -13414,10 +13455,12 @@ export class TerminalApp { return; } // A held line waiting for approval at the head runs nothing by itself, so it never holds the prompt: a command you - // submit there runs now (#426). Anything behind the held line waits for it either way. + // submit there runs now (#426). Anything behind the held line waits for it either way. In Queue Mode Enter queues, + // so a line never runs ahead of entries already waiting; only a paused queue gives the prompt back. const head = this.queueState.entries[0]; const held = Boolean(head && head.condition === 'approve' && !head.approved); - if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || (this.queueState.entries.length > 0 && !this.queueState.paused && !held))) { + const behind = this.queueState.entries.length > 0 && !this.queueState.paused && (!held || this.queueModeOn()); + if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || behind)) { this.enqueue(text); return; } @@ -13763,7 +13806,7 @@ export class TerminalApp { const running = this.running!; const keys = [key]; // 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 program = runningProgramName(running.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; @@ -13913,6 +13956,20 @@ export class TerminalApp { this.commandLine.reset(); } + /** + * The shell asked for more input to finish a command line NMSh submitted whole, so the session cancelled it before + * anything ran (the session owns the shell). Say why in plain words and give the line back to fix, unless something else is + * already being typed. + */ + private explainIncomplete(command: string): void { + const shell = this.shellId === 'bash' ? 'Bash' : this.shellId === 'fish' ? 'Fish' : 'zsh'; + const bang = this.shellId === 'zsh' && /"[^"]*![^"]*"?/u.test(command) + ? ' In zsh, ! inside double quotes starts history expansion and !" removes the closing quote: use single quotes or \\!.' : ''; + this.output.addFrontendInteraction('Unfinished command line', `${shell} was still waiting for the end of this command line (an unclosed quote or bracket), so NMSh cancelled it: nothing ran.${bang}`, ERROR); + if (!this.editor.text && !this.queueEdit && !this.queueCompose && !this.passthrough) this.editor.replaceText(command); + this.render(); + } + /** This command's phrase pair under Expressive wording: chosen once from the command and its start, so it never changes while it runs. */ private activityPair(): ActivityVerbPair | undefined { if (!this.running || this.promptConfiguration.liveActivity.style !== 'expressive') return undefined; @@ -14254,3 +14311,29 @@ export class TerminalApp { function* historyCommands(entries: readonly {command: string}[]): Iterable { for (const entry of entries) yield entry.command; } + +/** + * The running program as NMSh names it in a hold or a queued line's origin: the command line's first word (sudo, not + * what sudo runs). A line of several commands (`a; b`, `a && b`, a pipeline or a multi-line block) has no single + * program NMSh can name from its text, and its first word may long have finished, so it is "the command". + */ +function runningProgramName(command: string): string { + const line = command.trim(); + if (!line || severalCommands(line)) return 'the command'; + return commandWords(line)[0] ?? 'the command'; +} + +/** Whether a command line runs more than one command: an unquoted `;`, `|`, `&` (not a `>&`/`&>` redirect) or newline. */ +function severalCommands(line: string): boolean { + let quote: '\'' | '"' | undefined; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]!; + if (quote === '\'') { if (char === '\'') quote = undefined; continue; } + if (char === '\\') { index += 1; continue; } + if (quote === '"') { if (char === '"') quote = undefined; continue; } + if (char === '\'' || char === '"') { quote = char; continue; } + if (char === '\n' || char === ';' || char === '|') return true; + if (char === '&' && line[index - 1] !== '>' && line[index - 1] !== '<' && line[index + 1] !== '>') return true; + } + return false; +} diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 14d11a4c..fdd70cc1 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -137,7 +137,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/qr', insertion: '/qr', alias: '/queue', description: 'Review before resuming the queue'}, {name: '/qc', insertion: '/qc', alias: '/queue', description: 'Review before clearing queued commands'}, {name: '/pause', insertion: '/pause', alias: '/queue', description: 'Pause the command queue'}, - {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings'}, + {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings; /queue mode [on|off] switches Queue Mode, where Enter queues while a command runs'}, {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume|refresh)'}, {name: '/track', insertion: '/track ', description: 'Keep one live result of a finite command in the transcript, refreshed in place (/track df -h · /track --every 5s -- docker system df · pause|resume|refresh|stop|copy)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, @@ -251,6 +251,8 @@ export type ParsedSlashCommand = | {kind: 'doctor'} | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy'; arguments: string; /** Typed as /track: keep one live result in the transcript. */ track?: boolean} | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'} + /** `/queue mode [on|off]`: this window's Queue Mode; no argument toggles it. */ + | {kind: 'queueMode'; value?: boolean} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} | {kind: 'history', query: string} @@ -412,6 +414,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; const shortcut = ({'/qp': 'pause', '/pause': 'pause', '/qr': 'resume', '/qc': 'clear'} as const)[input.trim() as '/qp']; if (shortcut) return {kind: 'queue', op: shortcut}; + const queueMode = /^\/(?:queue|q)\s+mode(?:\s+(on|off))?\s*$/u.exec(input); + if (queueMode) return queueMode[1] ? {kind: 'queueMode', value: queueMode[1] === 'on'} : {kind: 'queueMode'}; const queue = /^\/(?:queue|q)(?:\s+(pause|resume|clear|settings|history|save-summary))?\s*$/u.exec(input); if (queue) return queue[1] === 'settings' ? {kind: 'settings', view: 'config', destination: 'queue'} : {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'}; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index fe4f2a8a..08238963 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -867,6 +867,11 @@ export interface QueuePresentationSettings { summaryExitCodes: boolean; /** Show observed input waits when one was recorded; nothing is ever guessed. */ summaryInputWaits: boolean; + /** + * New windows start in Queue Mode: while a command runs, Enter queues the line as an ordinary command (as Ctrl+Q + * does) instead of holding it for approval. A program NMSh sees waiting for input still gets Enter. Off by default. + */ + queueMode: boolean; } function boundedCount(value: unknown, min: number, max: number, fallback: number): number { @@ -887,7 +892,8 @@ export function normalizeQueuePresentation(value: unknown): QueuePresentationSet summaryDetail: pickValue(QUEUE_SUMMARY_DETAILS, raw.summaryDetail, 'detailed'), summaryPlacement: pickValue(QUEUE_SUMMARY_PLACEMENTS, raw.summaryPlacement, 'center'), summaryAlign: pickValue(QUEUE_SUMMARY_ALIGNS, raw.summaryAlign, 'left'), - summaryDurations: raw.summaryDurations !== false, summaryExitCodes: raw.summaryExitCodes !== false, summaryInputWaits: raw.summaryInputWaits === true}; + summaryDurations: raw.summaryDurations !== false, summaryExitCodes: raw.summaryExitCodes !== false, summaryInputWaits: raw.summaryInputWaits === true, + queueMode: raw.queueMode === true}; } export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { @@ -940,7 +946,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { queue: {liveTimeline: true, recentResults: 2, upcoming: 2, transient: true, transientSeconds: 8, outputDisplay: 'inline', autoSaveSummary: true, summaryDetail: 'detailed', summaryPlacement: 'center', summaryAlign: 'left', - summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false}, + summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false, queueMode: false}, workspacePanels: false, shellBackend: 'zsh', openWith: 'auto', diff --git a/src/session/QueueDispatcher.ts b/src/session/QueueDispatcher.ts index 5ea591f2..4d25af42 100644 --- a/src/session/QueueDispatcher.ts +++ b/src/session/QueueDispatcher.ts @@ -152,7 +152,10 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven if (!this.atPrompt) this.interrupted = true; } - onPrompt(exitCode: number, at = this.clock.now(), waits?: {inputWaitMs?: number}): void { + onPrompt(exitCode: number, at = this.clock.now(), waits?: {inputWaitMs?: number; incomplete?: boolean}): void { + // The entry it submitted was a line the shell could not finish; the session cancelled it before anything ran. It is + // a mistake in the queued text, not a result: what follows waits for the person, whatever the failure policy. + const unfinished = !this.ran && waits?.incomplete === true ? this.sent : undefined; if (!this.ran && (this.sent || (this.manualStarting && this.startingChain !== undefined && this.startingChain === this.timeline.state?.id))) this.timeline.unobserved(); this.manualStarting = false; this.startingChain = undefined; const belongs = this.timeline.running; @@ -168,6 +171,7 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven // An entry that is to run regardless of the result before it is not held back by a plain failure. else if (belongs && exitCode !== 0 && this.queue.next?.condition !== 'always' && this.timeline.policy !== 'continue') this.queue.hold({reason: 'failed', at, command: this.lastCommand, exitCode}); } + if (unfinished && this.queue.size && !this.queue.state.paused) this.queue.hold({reason: 'failed', at, command: unfinished.text, exitCode}); this.interrupted = false; this.settleTimeline(); this.emit('state', this.state, {}); diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index ea0132e8..d5b6be16 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -107,7 +107,7 @@ export type ServerMessage = */ | {type: 'output'; data: string; seq?: number; at?: number} | {type: 'exec'; command: string; seq: number; at: number; historyAllowed?: number; queued?: string} - | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number; inputWaitMs?: number; inputWaits?: number} + | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number; inputWaitMs?: number; inputWaits?: number; incomplete?: number} /** * The running command's input state (see InputWatch), sent on every change to frontends that support it: a * request (since/confidence/mode, with the open prompt and program when known) or none, plus waiting time so far. @@ -224,7 +224,7 @@ const SHAPES: Record = { dismissed: {sessionId: 'string'}, output: {data: 'string', seq: 'int?', at: 'int?'}, exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?', queued: 'string?'}, - prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?', inputWaitMs: 'int?', inputWaits: 'int?'}, + prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?', inputWaitMs: 'int?', inputWaits: 'int?', incomplete: 'int?'}, 'input-state': {terminalOwner: 'int?', done: 'int?', since: 'int?', confidence: 'string?', mode: 'string?', prompt: 'string?', program: 'string?', waitedMs: 'int', waits: 'int'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 4c7b459d..c9e6405f 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -100,7 +100,8 @@ function toMessage(event: BacklogEvent): ServerMessage { ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed}), ...(event.queued === undefined ? {} : {queued: event.queued})}; case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at, ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge}), - ...(event.inputWaits ? {inputWaitMs: event.inputWaitMs ?? 0, inputWaits: event.inputWaits} : {})}; + ...(event.inputWaits ? {inputWaitMs: event.inputWaitMs ?? 0, inputWaits: event.inputWaits} : {}), + ...(event.incomplete ? {incomplete: 1} : {})}; } } @@ -542,7 +543,8 @@ export class SessionService { session.screen.reset(); emit({kind: 'prompt', seq: ++session.seq, at: Date.now(), exitCode: marker.exitCode, cwd: marker.cwd, ...(marker.knowledge === undefined ? {} : {knowledge: marker.knowledge}), - ...(marker.inputWaits ? {inputWaitMs: marker.inputWaitMs ?? 0, inputWaits: marker.inputWaits} : {})}); + ...(marker.inputWaits ? {inputWaitMs: marker.inputWaitMs ?? 0, inputWaits: marker.inputWaits} : {}), + ...(marker.incomplete ? {incomplete: true} : {})}); // After the prompt is on its way: the next queued command, if any, starts from this evidence only. session.queue.onPrompt(marker.exitCode, session.idleSince, marker); }); diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 964e61e2..fdda343f 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -265,7 +265,8 @@ export class SocketSessionClient extends EventEmitter imple if (message.type === 'output') this.emit('data', message.data, {seq: message.seq, at: message.at}); else if (message.type === 'prompt') this.emit('prompt', {exitCode: message.exitCode, cwd: message.cwd, ...(message.knowledge === undefined ? {} : {knowledge: message.knowledge}), - ...(message.inputWaits ? {inputWaitMs: message.inputWaitMs ?? 0, inputWaits: message.inputWaits} : {})}, {seq: message.seq, at: message.at}); + ...(message.inputWaits ? {inputWaitMs: message.inputWaitMs ?? 0, inputWaits: message.inputWaits} : {}), + ...(message.incomplete ? {incomplete: true} : {})}, {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)?.(lookResultFrom(message.result)); this.looks.delete(message.id); } else if (message.type === 'queue-state') { diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index d8874db2..00eae18f 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -6,7 +6,7 @@ import {MAX_SHELL_KNOWLEDGE_BYTES} from '../shell/ShellKnowledge.js'; export type BacklogEvent = | {kind: 'output'; seq: number; at: number; data: string} | {kind: 'exec'; seq: number; at: number; command: string; historyAllowed?: number; queued?: string} - | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string; inputWaitMs?: number; inputWaits?: number}; + | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string; inputWaitMs?: number; inputWaits?: number; incomplete?: boolean}; /** Non-event spool records: journal acknowledgements, truncation and the shell's end. */ type SpoolRecord = BacklogEvent diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 895aefd2..6ddf9454 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -397,7 +397,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Session notices are short events about another session (a finished command, a failure, a request for attention); /sessions keeps the state.'], rows: [ {row: configRow('liveSessionStartup')}, {row: configRow('liveSessionMultiple')}, - {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Enter queues the next one; a program waiting for input still gets your answer' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, + {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Ctrl+Q queues the next command; Enter holds a line for your approval unless Queue Mode is on' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, + {row: configRow('queueMode'), note: draft => draft.queue.queueMode ? 'On: new windows start in Queue Mode; while a command runs, Enter queues the line, and a program waiting for input still gets your answer' : 'Off: Enter holds a line typed while a command runs for your approval; Ctrl+Q on an empty line switches one window to Queue Mode'}, {row: configRow('queueFailurePolicy'), note: () => 'Applies to new execution chains; existing reviewed work keeps its policy. Ctrl+C always pauses.'}, {row: configRow('queueTimeline'), note: draft => draft.queue.liveTimeline ? 'On: queued work shows as a chronological timeline — recent results, the running command and what runs next' : 'Off: the one-line queue row and pending list, as before'}, {row: configRow('queueRecentResults')}, diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index d0574df7..4cfc862c 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -5,6 +5,11 @@ export interface ShellMarker { /** Time the finished command spent waiting for input, and how many waits (see InputWatch); absent when none. */ inputWaitMs?: number; inputWaits?: number; + /** + * The shell asked for more input to finish the submitted command line (an unmatched quote, bracket or heredoc; + * zsh's `!"` history expansion), so NMSh cancelled the line before anything ran. + */ + incomplete?: boolean; } export type ProtocolEvent = @@ -22,7 +27,12 @@ export type ProtocolEvent = */ | {kind: 'unread'; text: string} /** zsh preexec: a command line is about to run in the foreground. */ - | {kind: 'exec'; command: string; historyAllowed?: number}; + | {kind: 'exec'; command: string; historyAllowed?: number} + /** + * The shell's continuation prompt (PS2): it read a line that does not finish a command and asks for the next one. + * Expected between the lines of a multi-line submission; one more than that means the submitted text was incomplete. + */ + | {kind: 'continue'}; /** * Bound on one marker's payload (a command line can be long; a cwd cannot). @@ -92,6 +102,7 @@ export class ShellProtocolDecoder { private emit(payload: string, events: ProtocolEvent[]): void { if (payload === 'reader') { events.push({kind: 'reader'}); return; } if (payload === 'done') { events.push({kind: 'done'}); return; } + if (payload === 'continue') { events.push({kind: 'continue'}); return; } if (payload.startsWith('unread;')) { const hex = payload.slice(7); // Exactly the bytes, or nothing: a malformed payload is never guessed at, and never output either. diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 5a8b989a..dd18ac51 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -68,6 +68,14 @@ export class ShellSession extends EventEmitter { private readonly order: QueryOrder; /** The last composer submission, used when a shell cannot report a command's text (Bash, unrecorded lines). */ private lastSubmitted = ''; + /** + * The submission the shell has not started yet: how many continuation prompts its own line breaks explain, and how + * many the shell asked for. One more than its line breaks means the shell is waiting for the rest of a command line + * NMSh sent whole (an unmatched quote, or zsh's `!"` history expansion removing a closing quote): it is cancelled + * at the continuation prompt, where nothing has run, and the next prompt says why. + */ + private submission?: {breaks: number; continued: number; cancelled: boolean}; + private incomplete = false; /** One authoritative input-awareness state per session, next to the PTY. */ readonly inputWatch: InputWatch; /** @@ -198,6 +206,8 @@ export class ShellSession extends EventEmitter { */ private send(data: string, submission = false): void { if (submission) this.lastSubmitted = data.replace(/\r$/u, ''); + // A command line submitted at the prompt: its line breaks are the continuation prompts it explains (onContinue). + if (submission && this.ready && this.atPrompt) this.submission = {breaks: (data.replace(/\r$/u, '').match(/\n/gu) ?? []).length, continued: 0, cancelled: false}; if (this.ready && !submission && !this.atPrompt) { // The command is done (its prompt hooks run): nothing can read this for it, and the shell's command reader is // next. It is held for the person, never written (#426). @@ -234,6 +244,24 @@ export class ShellSession extends EventEmitter { if (text && !this.exited) this.emit('unread', text, this.program); } + /** + * The shell asked for another line before starting the command NMSh submitted. Its own line breaks explain that many + * requests; one more is a command line left open, which the shell would wait on forever behind NMSh's blank + * continuation prompt. Ctrl+C there drops the unfinished line (nothing of it ran) and brings the prompt back. + */ + private onContinue(): void { + const submission = this.submission; + if (!this.ready || !submission || submission.cancelled) return; + submission.continued += 1; + if (submission.continued <= submission.breaks) return; + const cancel = this.adapter.cancelContinuation; + if (!cancel) return; + submission.cancelled = true; + this.incomplete = true; + this.order.discard(); + if (!this.exited) this.pty.write(cancel); + } + interrupt(): void { // Input still held behind a terminal answer is discarded, as a terminal's Ctrl+C discards pending input: // nothing typed before the interrupt may reach the program after it. @@ -294,11 +322,14 @@ export class ShellSession extends EventEmitter { } } else if (event.kind === 'reader') { if (this.ready && !this.atPrompt) this.inputWatch.onReader(); + } else if (event.kind === 'continue') { + this.onContinue(); } else if (event.kind === 'done') { if (this.ready && !this.atPrompt) { this.done = true; this.inputWatch.onDone(); } } else if (event.kind === 'unread') { if (this.ready && event.text) this.emit('unread', event.text, this.program); } else if (event.kind === 'exec') { + this.submission = undefined; this.atPrompt = false; this.done = false; this.delivered = false; @@ -324,10 +355,14 @@ export class ShellSession extends EventEmitter { this.atPrompt = true; this.done = false; this.delivered = false; + const incomplete = this.incomplete; + this.incomplete = false; + this.submission = undefined; // Final waiting time first, so it travels with the prompt that completes the command. this.inputWatch.onPrompt(); const {waitedMs, waits} = this.inputWatch.state.timing; - this.emit('prompt', {...this.withKnowledge(event.marker), ...(waits ? {inputWaitMs: Math.round(waitedMs), inputWaits: waits} : {})}); + this.emit('prompt', {...this.withKnowledge(event.marker), ...(waits ? {inputWaitMs: Math.round(waitedMs), inputWaits: waits} : {}), + ...(incomplete ? {incomplete: true} : {})}); } } } diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index f80e471c..63234eb9 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -126,7 +126,8 @@ __nmsh_precmd() { __nmsh_user_prompt_command=("\${PROMPT_COMMAND[@]/__nmsh_precmd/}") PROMPT_COMMAND=__nmsh_precmd fi - PS1='' PS2='' + # The continuation prompt draws nothing; its marker tells NMSh the shell is waiting for the rest of a line. + PS1='' PS2='\\e]777;nmsh;${token};continue\\a' __nmsh_tty -echo __nmsh_history_last=$(HISTTIMEFORMAT= builtin history 1) __nmsh_knowledge${context.bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} @@ -148,7 +149,7 @@ __nmsh_ps0() { } PROMPT_COMMAND=__nmsh_precmd -PS1='' PS2='' +PS1='' PS2='\\e]777;nmsh;${token};continue\\a' PS0='$(__nmsh_ps0)' `; } @@ -235,6 +236,7 @@ export const bashAdapter: ShellAdapter = { capabilities: {completion: 'basic', completionDescriptions: false, liveNames: true, historyImport: true, privateHistory: 'Bash\'s own HISTCONTROL/HISTIGNORE decide; lines Bash does not record are not recorded by NMSh either', jobCount: true}, editorChrome: 'none', + cancelContinuation: '\u0003', builtins: BASH_BUILTINS, resolveExecutable: resolveBash, unavailableReason(env) { diff --git a/src/shell/adapters/ShellAdapter.ts b/src/shell/adapters/ShellAdapter.ts index e1fc6cd9..28d12358 100644 --- a/src/shell/adapters/ShellAdapter.ts +++ b/src/shell/adapters/ShellAdapter.ts @@ -106,6 +106,13 @@ export interface ShellAdapter { readonly editorQueries?: RegExp; /** The mark the shell's editor writes once it has drawn its prompt (see QueryOrder): input waits for it after NMSh answers the editor. */ readonly editorSettled?: RegExp; + /** + * The bytes that abandon a command line the shell is still reading at its continuation prompt (PS2), so that it + * returns to its prompt at once and runs nothing of it. Bash acts on Ctrl+C there; zsh without its line editor only + * acts on the interrupt once its read returns, so Enter follows (the interrupt is applied first and the line is + * dropped). Absent: the shell reports no continuation prompt (Fish's reader keeps an unfinished line itself). + */ + readonly cancelContinuation?: string; /** * Terminal-mode bytes the shell's own editor emits as it hands the terminal * to a command (Fish turns bracketed paste off). They are editor chrome and diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index 1a5723ca..198b530b 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -26,6 +26,7 @@ export const zshAdapter: ShellAdapter = { capabilities: {completion: 'rich', completionDescriptions: true, liveNames: true, historyImport: true, privateHistory: 'a leading space, or a match of HISTORY_IGNORE, keeps a command out of history', jobCount: true}, editorChrome: 'none', + cancelContinuation: '\u0003\r', builtins: ZSH_BUILTINS, resolveExecutable(env) { try { return resolveZsh(env); } catch { return undefined; } @@ -106,7 +107,8 @@ function nmsh_precmd { # owns prompt rendering, so it always has the last word here. PROMPT='' RPROMPT='' - PS2='' + # The continuation prompt draws nothing; its marker tells NMSh the shell is waiting for the rest of a line. + PS2=$'\\e]777;nmsh;${token};continue\\a' # Themes such as Powerlevel10k move their own hook to the end of # precmd_functions every cycle; move ours back after it so the next cycle # still blanks last. Their prompt-spacing options must not return either. diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 8abe6d14..c33a19ec 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -443,11 +443,13 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'terminalTitle', label: 'Terminal title', description: 'Set the window/tab title while NMSh owns the screen; programs set their own while they run and NMSh restores its title after them. Off leaves the title to the terminal', category: 'Sessions', values: ['off', 'project', 'session'] as const, labels: ['Off', 'Project', 'Project and session'], get: config => config.terminalTitle, set: (config, terminalTitle) => ({...config, terminalTitle})}), - {id: 'commandQueue', label: 'Queue commands', description: 'While a command runs, Enter queues the next one to run after it (Ctrl+Q always queues, Ctrl+S sends to the program); a program waiting for input still gets your answer. Off: Enter sends to the program', category: 'Sessions', + {id: 'commandQueue', label: 'Queue commands', description: 'While a command runs, Ctrl+Q queues the next command to run after it and Ctrl+S sends to the program; Enter holds a line for your approval unless Queue Mode is on. Off: Enter sends to the program', category: 'Sessions', control: 'boolean', get: config => config.commandQueue, set: (config, commandQueue) => ({...config, commandQueue})}, enumRow({id: 'queueFailurePolicy', label: 'On queued command failure', description: 'For new execution chains: pause remaining work (default), or continue after a nonzero exit. Ctrl+C always pauses; existing chains keep their reviewed policy', category: 'Sessions', values: ['pause', 'continue'] as const, labels: ['Pause remaining queue', 'Continue remaining queue'], get: config => config.queueFailurePolicy, set: (config, queueFailurePolicy) => ({...config, queueFailurePolicy})}), + {id: 'queueMode', label: 'Start in Queue Mode', description: 'New windows start with Queue Mode on: while a command runs, Enter queues the line to run after it, shown as typed, with no approval step. A program NMSh sees waiting for input still gets Enter, and hidden input is never shown. Ctrl+Q on an empty line or /queue mode turns it on or off for one window', category: 'Queue', + control: 'boolean', get: config => config.queue.queueMode, set: (config, queueMode) => withQueue(config, {queueMode})}, {id: 'queueTimeline', label: 'Live timeline', description: 'Queued work as a chronological timeline above the composer: recent results, the running command and what runs next. Off keeps the one-line queue row and the pending list; the transcript is never touched', category: 'Queue', control: 'boolean', get: config => config.queue.liveTimeline, set: (config, liveTimeline) => withQueue(config, {liveTimeline})}, {id: 'queueRecentResults', parent: 'queueTimeline', when: config => config.queue.liveTimeline, label: 'Recent results', description: 'How many finished commands stay visible above the running one; older results leave the timeline, and their output stays in the transcript', category: 'Queue', diff --git a/tests/incompleteSubmission.test.ts b/tests/incompleteSubmission.test.ts new file mode 100644 index 00000000..80d660d3 --- /dev/null +++ b/tests/incompleteSubmission.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ShellSession} from '../src/shell/ShellSession.js'; +import type {ShellMarker} from '../src/shell/ShellProtocol.js'; +import {ShellProtocolDecoder} from '../src/shell/ShellProtocol.js'; +import {shellSubmission} from '../src/shell/submission.js'; + +/** + * A command line the shell cannot finish waits at its continuation prompt (PS2), which NMSh draws as nothing: the + * command looks like it runs forever and typing for it is held. zsh's history expansion turns `echo "Finished!"` into + * exactly that (`!"` removes the closing quote). The shell's PS2 is a marker: once the shell asks for one more line + * than the submission's own line breaks explain, the session cancels the line (nothing of it ran) and its next prompt + * says so. A multi-line submission's own continuation prompts are expected and never cancel it. + */ + +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 supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('decoder: a continue marker is its own event; other payloads are unchanged', () => { + const decoder = new ShellProtocolDecoder('tok'); + assert.deepEqual(decoder.push('a\u001b]777;nmsh;tok;continue\u0007b'), [{kind: 'data', data: 'a'}, {kind: 'continue'}, {kind: 'data', data: 'b'}]); +}); + +async function withShell(shell: 'zsh' | 'bash', body: (session: ShellSession, next: () => Promise, outputs: string[]) => Promise) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-incomplete-'))); + const env = {...process.env, HOME: home, ...(shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {})}; + const session = new ShellSession(home, 80, 24, home, env, shell); + const prompts: ShellMarker[] = []; + const waiters: Array<(marker: ShellMarker) => void> = []; + const outputs: string[] = []; + session.on('prompt', marker => { const waiter = waiters.shift(); if (waiter) waiter(marker); else prompts.push(marker); }); + session.on('data', data => outputs.push(data)); + const next = () => new Promise((resolve, reject) => { + const queued = prompts.shift(); + if (queued) { resolve(queued); return; } + const timer = setTimeout(() => reject(new Error('no prompt within 10 s')), 10_000); + waiters.push(marker => { clearTimeout(timer); resolve(marker); }); + }); + try { + await next(); + await body(session, next, outputs); + } finally { + session.kill(); + rmSync(home, {recursive: true, force: true}); + } +} + +test('live zsh: `echo "Finished!"` is cancelled at the continuation prompt and reported; nothing hangs, nothing ran', {skip: supported ? false : 'unsupported platform', timeout: 60_000}, async () => { + await withShell('zsh', async (session, next, outputs) => { + session.submit(shellSubmission('echo "STEP 3 — Finished!"', 'zsh')); + const marker = await next(); + assert.equal(marker.incomplete, true, 'the prompt says the line was incomplete'); + assert.equal(outputs.join('').includes('STEP 3'), false, 'nothing of it ran'); + // The shell is an ordinary prompt again: the next command runs and is not incomplete. + session.submit(shellSubmission('echo AFTER', 'zsh')); + const after = await next(); + assert.equal(after.incomplete, undefined); + assert.equal(after.exitCode, 0); + assert.match(outputs.join(''), /AFTER/u); + }); +}); + +test('live zsh and bash: a multi-line submission\'s own continuation prompts never cancel it; an unclosed quote does', {skip: supported ? false : 'unsupported platform', timeout: 90_000}, async () => { + for (const shell of ['zsh', ...(bash ? ['bash' as const] : [])] as const) { + await withShell(shell, async (session, next, outputs) => { + session.submit(shellSubmission("printf 'ONE\\n'\nprintf 'TWO\\n'\ncat < { + const {CommandQueue} = await import('../src/session/CommandQueue.js'); + const {QueueDispatcher} = await import('../src/session/QueueDispatcher.js'); + for (const policy of ['pause', 'continue'] as const) { + const submitted: string[] = []; + const queue = new CommandQueue(() => 1); + const dispatcher = new QueueDispatcher({isReady: true, adapter: {id: 'zsh'}, submit: (text: string) => { submitted.push(text); }}, queue); + dispatcher.request({op: 'failure-policy', policy} as never); + dispatcher.onExec('sleep 2'); + for (const text of ['echo ONE', 'echo "bang!"', 'echo AFTER']) dispatcher.request({op: 'add', text}); + dispatcher.onPrompt(0); + dispatcher.onExec('echo ONE'); + dispatcher.onPrompt(0); + assert.deepEqual(submitted, ['echo ONE', 'echo "bang!"'], `${policy}: the unfinished entry was sent`); + dispatcher.onPrompt(130, 5, {incomplete: true}); + assert.deepEqual(submitted, ['echo ONE', 'echo "bang!"'], `${policy}: nothing after it was sent`); + assert.deepEqual(queue.state.paused, {reason: 'failed', at: 5, command: 'echo "bang!"', exitCode: 130}, `${policy}: the queue says why it waits`); + assert.deepEqual(queue.state.entries.map(entry => entry.text), ['echo AFTER']); + } +}); diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts index 80c9adcf..7b6445da 100644 --- a/tests/inputOwnershipLook.test.ts +++ b/tests/inputOwnershipLook.test.ts @@ -74,8 +74,9 @@ function fixture() { } 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; +// A line of several commands (READ) is named "the command": its first word may have finished long ago. +const KEPT = /Checking whether (?:the command|\S+) is waiting for input · your typing is kept/u; +const HELD = /Can't tell whether (?:the command|\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(); diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index 5cbfb3d0..5f62aaf5 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -68,7 +68,7 @@ for (const reply of REPLIES) { await f.typeLine(reply); const held = f.entry(reply); assert.equal(held?.condition, 'approve', 'it waits for approval, whatever it looks like'); - assert.equal(held?.typedWhile, 'sleep', 'the owner knows it was typed while sleep ran'); + assert.equal(held?.typedWhile, 'the command', 'the owner knows it was typed while that command line ran (several commands: no single program to name)'); assert.equal(f.written.some(data => data.includes(reply)), false, 'not written to the terminal'); assert.equal(f.recalled().includes(reply), false, 'not offered by Up as a command typed this session'); assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); @@ -91,7 +91,7 @@ test('quarantine: the held line is shown by where it came from, never by its tex assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); f.complete(0); await settled(); assert.equal(f.screen().includes(SECRET), false, 'at the prompt'); - assert.match(f.screen(), /typed while sleep ran/u, 'it says where it came from'); + assert.match(f.screen(), /typed while the command ran/u, 'it says where it came from'); } finally { f.dispose(); } }); diff --git a/tests/queueMode.test.ts b/tests/queueMode.test.ts new file mode 100644 index 00000000..3f439563 --- /dev/null +++ b/tests/queueMode.test.ts @@ -0,0 +1,246 @@ +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 {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * Queue Mode: an explicit, per-window choice that Enter queues while a command runs. A line entered then is the + * person's next command, shown as typed and run after the current one without an approval step, as Ctrl+Q does. + * Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input + * gets Enter, hidden input is never drawn or queued, and a look that cannot tell keeps the typing held. + * + * Deterministic: every key goes through the real TerminalApp key handler; looks answer as the test says. + */ + +const SECRET = 'QSECRETQ'; +const CTRL_Q = '\u0011'; + +function fixture(queueMode = false) { + const frame = executionFrameFixture(110, 30); + frame.app['promptConfiguration'].queue.queueMode = queueMode; + const written: string[] = []; + const queueOps: QueueOp[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, 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}); + const submitted: string[] = []; + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + const looksAnswer = (result: LookResult | undefined) => + Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return result; }, configurable: true}); + looksAnswer('visible'); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const enter = () => frame.app['onInput']('\r'); + const adds = () => queueOps.filter((op): op is Extract => op.op === 'add'); + 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'); + const report = (request: NonNullable) => + frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); + return {...frame, written, queueOps, submitted, looksAnswer, type, enter, adds, screen, transcript, report, looks: () => looks}; +} + +const settled = async () => { for (let i = 0; i < 4; i += 1) await new Promise(done => setImmediate(done)); }; + +test('/queue mode parses on, off and toggle; other /queue words are unchanged', () => { + assert.deepEqual(parseSlashCommand('/queue mode'), {kind: 'queueMode'}); + assert.deepEqual(parseSlashCommand('/queue mode on'), {kind: 'queueMode', value: true}); + assert.deepEqual(parseSlashCommand('/q mode off'), {kind: 'queueMode', value: false}); + assert.deepEqual(parseSlashCommand('/queue pause'), {kind: 'queue', op: 'pause'}); + assert.equal(parseSlashCommand('/queue mode maybe')?.kind, 'unknown'); +}); + +test('off (the default): a line entered while sleep runs is still held for approval, by where it came from', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + f.type('echo one'); + await settled(); + f.enter(); + await settled(); + assert.deepEqual(f.adds().map(op => [op.text, op.condition, op.typedWhile]), [['echo one', 'approve', 'sleep']]); + assert.match(f.screen(), /Enter holds it for approval · Ctrl\+Q runs it after/u); + } finally { f.dispose(); } +}); + +test('on: lines entered while sleep runs are queued as typed, in order, with no approval step; the composer says so', async () => { + const f = fixture(true); + try { + f.begin('sleep 30'); + assert.match(f.screen(), /Queue Mode · Enter queues it to run next · Ctrl\+S sends to sleep/u); + const lines = ['echo "STEP 1 — Starting"', 'sleep 2', 'echo "STEP 2 — Halfway"', 'sleep 2', 'echo "STEP 3 — Finished!"']; + for (const line of lines) { + f.type(line); + await settled(); + f.enter(); + await settled(); + } + assert.deepEqual(f.adds().map(op => op.text), lines, 'each line once, in the order entered'); + assert.equal(f.adds().some(op => op.condition !== undefined || op.typedWhile !== undefined), false, 'none waits for approval'); + assert.deepEqual(f.written, [], 'nothing reached sleep'); + assert.deepEqual(f.submitted, [], 'nothing ran early'); + assert.doesNotMatch(f.screen(), /typed while|Awaiting approval/u); + } finally { f.dispose(); } +}); + +test('on: a program NMSh sees waiting for a line still gets Enter; nothing is queued', async () => { + const f = fixture(true); + try { + f.begin('read -r reply'); + f.report({since: Date.now(), confidence: 'likely', mode: 'line'}); + f.type('hello'); + f.enter(); + await settled(); + assert.equal(f.written.join(''), 'hello\r', 'the answer went to the program'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('on: hidden input is never drawn, queued or run, whether it was reported or only seen by the look', async () => { + for (const how of ['reported', 'looked'] as const) { + const f = fixture(true); + try { + f.begin('sudo -k true'); + if (how === 'reported') f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + else f.looksAnswer('hidden'); + f.type(SECRET); + await settled(); + if (how === 'looked') f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + await settled(); + f.enter(); + await settled(); + assert.equal(f.screen().includes(SECRET), false, `${how}: not on screen`); + assert.equal(f.transcript().includes(SECRET), false, `${how}: not in the transcript`); + assert.equal(f.adds().some(op => op.text.includes(SECRET)), false, `${how}: not queued`); + assert.equal(f.submitted.some(text => text.includes(SECRET)), false, `${how}: not run`); + } finally { f.dispose(); } + } +}); + +test('on: a look that cannot tell keeps the typing held and hidden; Queue Mode never routes on no evidence', async t => { + const f = fixture(true); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.looksAnswer('unknown'); + f.begin('sleep 30'); + f.type(SECRET); + await settled(); + f.enter(); + await settled(); + assert.match(f.screen(), /Can't tell whether sleep is reading your typing/u); + assert.equal(f.screen().includes(SECRET), false); + assert.deepEqual(f.adds(), [], 'nothing queued'); + } finally { f.dispose(); } +}); + +test('on: a line whose command ended while its keys were held is queued as typed, not held for approval', async () => { + const f = fixture(true); + try { + let answer: (result: LookResult) => void = () => {}; + Object.defineProperty(f.session, 'lookInput', {value: () => new Promise(resolve => { answer = resolve; }), configurable: true}); + f.begin('ls'); + f.type('echo after'); + f.enter(); + f.complete(0); + answer('idle'); + await settled(); + assert.deepEqual(f.adds().map(op => [op.text, op.condition, op.typedWhile]), [['echo after', undefined, undefined]]); + } finally { f.dispose(); } +}); + +test('Ctrl+Q on an empty line toggles Queue Mode for this window only; /queue mode sets it; Settings are not written', async () => { + const f = fixture(); + try { + f.app['onInput'](CTRL_Q); + assert.equal(f.app['queueModeOn'](), true); + assert.match(f.transcript(), /Queue Mode on/u); + assert.match(f.screen(), /Queue Mode · while a command runs, Enter queues/u, 'the empty composer says so at the prompt'); + assert.equal(f.app['promptConfiguration'].queue.queueMode, false, 'the default in Settings is unchanged'); + f.app['onInput'](CTRL_Q); + assert.equal(f.app['queueModeOn'](), false); + await f.app['runSlash']('/queue mode on', parseSlashCommand('/queue mode on')!); + assert.equal(f.app['queueModeOn'](), true); + await f.app['runSlash']('/queue mode on', parseSlashCommand('/queue mode on')!); + assert.equal(f.app['queueModeOn'](), true, 'on stays on'); + await f.app['runSlash']('/queue mode off', parseSlashCommand('/queue mode off')!); + assert.equal(f.app['queueModeOn'](), false); + assert.deepEqual(f.adds(), [], 'toggling queues nothing'); + } finally { f.dispose(); } +}); + +test('Ctrl+Q with text still queues that line; Ctrl+Q while a program reads hidden input composes a queue entry, never a toggle', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + f.type('echo later'); + await settled(); + f.app['onInput'](CTRL_Q); + await settled(); + assert.deepEqual(f.adds().map(op => [op.text, op.typedWhile]), [['echo later', undefined]]); + assert.equal(f.app['queueModeOn'](), false, 'Ctrl+Q with a line queues it, it toggles nothing'); + f.complete(0); + f.begin('sudo -k true'); + f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.app['onInput'](CTRL_Q); + assert.equal(f.app['queueCompose'], true, 'composes a queue entry'); + assert.equal(f.app['queueModeOn'](), false); + } finally { f.dispose(); } +}); + +test('a line of several commands is named "the command", never its first word, which may have finished long ago', async () => { + for (const command of ['echo "STEP 1"; sleep 2; echo "STEP 2"', "printf 'ONE\\n'\nsleep 1", 'make && make test']) { + const f = fixture(); + try { + f.begin(command); + f.type('echo next'); + await settled(); + f.enter(); + await settled(); + assert.equal(f.adds()[0]?.typedWhile, 'the command', command); + assert.doesNotMatch(f.transcript(), /entered while (echo|printf|make) runs/u, command); + } finally { f.dispose(); } + } +}); + +test('on: at the prompt, a line never runs ahead of entries already waiting; a paused queue gives the prompt back', async () => { + const held = {id: 1, text: 'echo held', addedAt: 1, condition: 'approve' as const, typedWhile: 'sleep'}; + const queued = {id: 2, text: 'sleep 2', addedAt: 2}; + for (const [state, expectRun, hint] of [ + [{entries: [held, queued]}, false, /Queue Mode · Enter adds it after 2 waiting/u], + [{entries: [queued]}, false, /Queue Mode · Enter adds it after 1 waiting/u], + [{entries: [queued], paused: {reason: 'failed' as const, at: 3, command: 'false', exitCode: 1}}, true, /queue paused, 1 waiting · Enter runs it now/u], + ] as const) { + const f = fixture(true); + try { + f.session.emit('queueState', state, {}); + await settled(); + assert.match(f.screen(), hint); + f.type('echo "STEP 3"'); + f.enter(); + await settled(); + if (expectRun) { + assert.deepEqual(f.submitted, ['echo "STEP 3"'], 'a paused queue: the line runs now'); + assert.deepEqual(f.adds(), []); + } else { + assert.deepEqual(f.submitted, [], 'nothing runs ahead of the waiting entries'); + assert.deepEqual(f.adds().map(op => [op.text, op.condition, op.typedWhile]), [['echo "STEP 3"', undefined, undefined]], 'added after them, as typed'); + } + } finally { f.dispose(); } + } +}); + +test('off: a held line at the head still never holds the prompt (#426); the line runs now', async () => { + const f = fixture(false); + try { + f.session.emit('queueState', {entries: [{id: 1, text: 'echo held', addedAt: 1, condition: 'approve', typedWhile: 'sleep'}]}, {}); + await settled(); + f.type('echo now'); + f.enter(); + await settled(); + assert.deepEqual(f.submitted, ['echo now']); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); diff --git a/tests/queueModeLive.test.ts b/tests/queueModeLive.test.ts new file mode 100644 index 00000000..edf6b5ce --- /dev/null +++ b/tests/queueModeLive.test.ts @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; + +/** + * Queue Mode through the real composer, PTY and session service: lines entered with Enter while `sleep` runs are + * queued as typed and run after it, once each and in order, with no approval step. A read the shell waits on still + * gets the answer, and a hidden one is never drawn: Queue Mode changes what Enter does for a command nobody reads, + * never who owns typing. + */ +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, queue: {queueMode: true}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + 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], 'queue mode fixture startup and cleanup failed'); } + throw error; + } +} + +async function type(frontend: Frontend, text: string, gapMs = 25): 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 idle = async (sandbox: LiveSandbox) => { const session = (await sandbox.sessions())[0]; return session?.running === undefined && !session?.queued; }; + +const READS: Record = { + zsh: {line: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read -s 'reply?Demo password: '; print; print len=${#reply}"}, + bash: {line: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read -rs -p 'Demo password: ' reply; echo; echo len=${#reply}"}, + fish: {line: `read QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read --silent --prompt-str 'Demo password: ' reply; echo len=(string length -- $reply)"}, +}; + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: Queue Mode runs lines entered during sleep after it, once each, in order, with no approval`, {skip: available ? false : `${shell} not installed`, timeout: 180_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + const lines = ['echo "STEP 1 — Starting"', 'sleep 1', 'echo "STEP 2 — Halfway"', 'sleep 1', 'echo "STEP 3 — Finished."']; + const mark = frontend.mark; + frontend.pty.write('sleep 4\r'); + await pause(700); + for (const line of lines) { await type(frontend, line); frontend.pty.write('\r'); await pause(150); } + await until(async () => (await sandbox.sessions())[0]?.queued === lines.length, 10_000, 'all five lines queued while sleep runs'); + await until(() => idle(sandbox), 20_000, 'the queue drained and the prompt is back'); + const shown = strip(frontend.output.slice(mark)); + assert.doesNotMatch(shown, /Awaiting approval|typed while|Can't tell whether/u, 'no approval or held state'); + // Journal order is not run order; each record says when it started. A record is journaled after its output. + const after = async () => { + const ran = (await records(sandbox)).map((record, index) => ({command: record.command.trim(), at: record.startedAt ?? index})) + .sort((a, b) => a.at - b.at).map(record => record.command); + return ran.slice(ran.lastIndexOf('sleep 4') + 1); + }; + await until(async () => (await after()).length >= lines.length, 15_000, 'every queued command journaled'); + assert.deepEqual(await after(), lines, 'each queued line ran once, in the order entered, after sleep'); + const printed = (await records(sandbox)).map(record => strip(record.output ?? '')).join('\n'); + for (const step of ['STEP 1 — Starting', 'STEP 2 — Halfway', 'STEP 3 — Finished.']) assert.equal(printed.split(step).length - 1, 1, `${step} printed once`); + } finally { await sandbox.dispose(); } + }); + + test(`live ${shell}: Queue Mode still gives a waiting read its answer and never draws a hidden one`, {skip: available ? false : `${shell} not installed`, timeout: 180_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + for (const reactionMs of [300, 900]) { + const mark = frontend.mark; + await type(frontend, READS[shell].line, 5); + frontend.pty.write('\r'); + await pause(reactionMs); + await type(frontend, 'hello', 60); + frontend.pty.write('\r'); + await frontend.waitFor(/ANSWER=hello/u, mark, 10_000); + await until(() => idle(sandbox), 15_000, `line read after ${reactionMs} ms: back at the prompt`); + assert.equal((await records(sandbox)).some(record => record.command.trim() === 'hello'), false, `after ${reactionMs} ms: the answer never ran as a command`); + } + const mark = frontend.mark; + await type(frontend, READS[shell].hidden, 5); + frontend.pty.write('\r'); + await pause(600); + await type(frontend, SECRET, 60); + frontend.pty.write('\r'); + await frontend.waitFor(/len=8/u, mark, 10_000); + await until(() => idle(sandbox), 15_000, 'hidden read: back at the prompt'); + assert.equal(strip(frontend.output.slice(mark)).includes(SECRET), false, 'the hidden answer is never drawn'); + assert.equal((await records(sandbox)).some(record => record.command.includes(SECRET)), false, 'never run as a command'); + } finally { await sandbox.dispose(); } + }); +} + +test('live zsh: a line zsh cannot finish (`!"` removes its closing quote) is cancelled, explained and given back; never left waiting', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const bang = 'echo "STEP 3 — Finished!"'; + let mark = frontend.mark; + await type(frontend, bang, 5); + frontend.pty.write('\r'); + await frontend.waitFor(/was still waiting for the end of this command line/u, mark, 10_000); + await until(() => idle(sandbox), 10_000, 'the prompt is back'); + assert.match(strip(frontend.output.slice(mark)), /history expansion/u, 'the zsh-specific cause is named'); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /Can't tell whether/u, 'never left as held typing'); + // The line is back in the composer to fix; an ordinary command afterwards runs at once. + await type(frontend, '\u0015', 5); + await frontend.run('echo AFTER-BANG', /AFTER-BANG/u); + // Queued: the earlier entry runs, the unfinished one is cancelled, and the queue pauses before what follows it. + mark = frontend.mark; + frontend.pty.write('sleep 6\r'); + await pause(600); + for (const line of ['echo QUEUED-ONE', bang, 'echo QUEUED-AFTER']) { await type(frontend, line); frontend.pty.write('\r'); await pause(150); } + await until(async () => (await sandbox.sessions())[0]?.queued === 3, 10_000, 'all three queued while sleep runs'); + await until(async () => (await records(sandbox)).some(record => record.command.trim() === 'echo QUEUED-ONE'), 20_000, 'the first queued entry ran'); + await until(async () => (await sandbox.sessions())[0]?.queued === 1 && (await sandbox.sessions())[0]?.running === undefined, 10_000, 'the cancelled entry left one waiting'); + await pause(1500); + assert.equal((await records(sandbox)).some(record => record.command.trim() === 'echo QUEUED-AFTER'), false, 'the queue paused after the cancelled line'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/queueSettings.test.ts b/tests/queueSettings.test.ts index 67054976..2656e2ec 100644 --- a/tests/queueSettings.test.ts +++ b/tests/queueSettings.test.ts @@ -30,7 +30,7 @@ test('queue presentation defaults match the approved spec: timeline on, two resu assert.deepEqual(DEFAULT_PROMPT_CONFIGURATION.queue, {liveTimeline: true, recentResults: 2, upcoming: 2, transient: true, transientSeconds: 8, outputDisplay: 'inline', autoSaveSummary: true, summaryDetail: 'detailed', summaryPlacement: 'center', summaryAlign: 'left', - summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false}); + summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false, queueMode: false}); assert.deepEqual(normalizePromptConfiguration({}).queue, {...DEFAULT_PROMPT_CONFIGURATION.queue, autoSaveSummary: false}, 'an existing configuration keeps the defaults'); }); From 095671f761cd54017b9ba6a25105f95fe8839e22 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:11:15 +0530 Subject: [PATCH 3/5] Queue: hold lines queued during a command once it is seen asking for input An Enter pressed before NMSh can see that the running command reads (an echoing terminal is no evidence, see #514) reaches the queue as a command, and in Queue Mode without approval. The session service now holds every entry queued since the command was submitted as soon as the command is first reported asking for input, before any frontend hears of it, so the next dispatch can never overtake the hold. Hidden input also hides the held text. Adds a deterministic dispatcher test for a reader that ends right after it was seen, and a live zsh test of #514's controlled race with Queue Mode on (the fixture files are byte-identical copies of #514's). Refs #426 Refs #473 --- CHANGELOG.md | 2 +- docs/design/input-awareness.md | 6 +++ src/session/CommandQueue.ts | 19 ++++++++ src/session/InProcessSessionClient.ts | 6 ++- src/session/QueueDispatcher.ts | 24 ++++++++++ src/session/SessionService.ts | 2 + tests/fixtures/ownershipRaceEntry.mjs | 5 ++ tests/fixtures/ownershipRaceProbe.mjs | 28 +++++++++++ tests/queueModeOwnershipRaceLive.test.ts | 55 ++++++++++++++++++++++ tests/queueReplyHold.test.ts | 59 ++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/ownershipRaceEntry.mjs create mode 100644 tests/fixtures/ownershipRaceProbe.mjs create mode 100644 tests/queueModeOwnershipRaceLive.test.ts create mode 100644 tests/queueReplyHold.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ff98a5..1fdc3588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Queue Mode** ([#473](https://github.com/raiseCatError/notMyShell/issues/473), [#426](https://github.com/raiseCatError/notMyShell/issues/426)). While a command runs, Enter normally holds a line for your approval, because NMSh cannot prove it is not a reply to a program that asked nothing visible. Queue Mode is the explicit alternative: Enter queues the line to run after the current command, shown as typed, once and in order, with no approval step — what Ctrl+Q already did, without the extra key. Turn it on or off for one window with **Ctrl+Q on an empty line** or `/queue mode [on|off]`; **Settings → Queue → Start in Queue Mode** (off by default) chooses how new windows start, and a toggle never changes that setting. The composer says when it is on. At the prompt, a line entered in Queue Mode never runs ahead of entries already waiting (a held line at the head included): it is added after them, and the composer says how many wait. Only a queue paused after a failure, an interrupt or `/queue pause` gives the prompt back, so a fix runs at once. Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input gets Enter, hidden input is never drawn or queued, and when NMSh cannot tell who reads the typing it stays held as before. A program that reads with no prompt and that NMSh cannot see (`cat`, a script's `input()`) gets a queued line instead of its answer in Queue Mode: use Ctrl+S, or leave Queue Mode off. +- **Queue Mode** ([#473](https://github.com/raiseCatError/notMyShell/issues/473), [#426](https://github.com/raiseCatError/notMyShell/issues/426)). While a command runs, Enter normally holds a line for your approval, because NMSh cannot prove it is not a reply to a program that asked nothing visible. Queue Mode is the explicit alternative: Enter queues the line to run after the current command, shown as typed, once and in order, with no approval step — what Ctrl+Q already did, without the extra key. Turn it on or off for one window with **Ctrl+Q on an empty line** or `/queue mode [on|off]`; **Settings → Queue → Start in Queue Mode** (off by default) chooses how new windows start, and a toggle never changes that setting. The composer says when it is on. At the prompt, a line entered in Queue Mode never runs ahead of entries already waiting (a held line at the head included): it is added after them, and the composer says how many wait. Only a queue paused after a failure, an interrupt or `/queue pause` gives the prompt back, so a fix runs at once. Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input gets Enter, hidden input is never drawn or queued, and when NMSh cannot tell who reads the typing it stays held as before. A program that reads with no prompt and that NMSh cannot see (`cat`, a script's `input()`) gets a queued line instead of its answer in Queue Mode: use Ctrl+S, or leave Queue Mode off. Once a running command is seen asking for input, every line queued while it ran waits for your approval (its text hidden if the input is hidden). The session service decides this before the next queued command could start, so a reply typed before NMSh could see the reader never runs as a command by itself. - **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings. - **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works. diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index e2215489..44676a79 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -177,6 +177,12 @@ typed is drawn, not even as dots. makes Enter do what Ctrl+Q does. It changes only this case: the look before typing is drawn, Enter to a program that waits, hidden input and the held state when a look cannot tell are all as above. Its accepted cost is the prompt-less read NMSh cannot see (an external program reading with no prompt): in Queue Mode the line is queued, visibly, instead. + A reader seen late (#514: an echoing terminal is no evidence, and a loaded machine can delay the follow-up sample) is + caught by the owner: when the running command is first reported asking for input, every entry queued since it was + submitted waits for approval (hidden input also hides their text). The session service decides this beside the + prompt, so the next dispatch cannot overtake it. What remains is a reader NMSh never sees before it ends (on macOS + there is no kernel read report: a prompt-less external reader, or a timed read that gives up within a second): its + queued line runs after it, as a Ctrl+Q line always has. At the prompt, Queue Mode keeps order: a line never runs ahead of entries already waiting (a held head included); only a paused queue gives the prompt back. - **Unfinished command lines.** NMSh draws no continuation prompt, so zsh and Bash print PS2 as an invisible marker. diff --git a/src/session/CommandQueue.ts b/src/session/CommandQueue.ts index e64e533a..e03c1a7a 100644 --- a/src/session/CommandQueue.ts +++ b/src/session/CommandQueue.ts @@ -194,6 +194,25 @@ export class CommandQueue extends EventEmitter<{change: [QueueState]}> { return undefined; } + /** + * Entries added at or after `since` stop running by themselves (#426): the command that ran then asked for input, + * so each may be its reply, typed before anything showed that it reads. They wait for approval; `typedWhile` (hidden + * input) also keeps their text out of view. Entries the person already approved are left. Returns how many changed. + */ + holdAddedSince(since: number, typedWhile?: string): number { + let held = 0; + for (const entry of this.entries) { + if (entry.addedAt < since || (entry.condition === 'approve' && entry.approved)) continue; + const hide = typedWhile !== undefined && entry.typedWhile === undefined; + if (entry.condition === 'approve' && !hide) continue; + entry.condition = 'approve'; + if (hide) entry.typedWhile = typedWhile; + held += 1; + } + if (held) this.changed(); + return held; + } + /** Stops automatic running; entries stay. */ hold(pause: QueuePause): void { if (!this.entries.length) return; diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index 20373afd..e2b45fec 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -60,7 +60,11 @@ export class InProcessSessionClient extends EventEmitter im }); shell.on('startup', tail => this.emit('startup', tail)); shell.on('inputRejected', (data, submission) => this.emit('inputRejected', data, submission)); - shell.on('inputState', state => this.emit('inputState', state)); + shell.on('inputState', state => { + // As in the session service: before the frontend hears of it, lines queued while the command ran stop running by themselves. + if (state.request) this.dispatcher.onInputRequest(state.request, state.request.program ?? 'the command'); + this.emit('inputState', state); + }); shell.on('unread', (text, program) => { this.dispatcher.holdUnread(text, program); }); shell.on('exit', event => { this.dispatcher.onShellExit(); void this.tasks.shutdown(); this.emit('exit', event); }); } diff --git a/src/session/QueueDispatcher.ts b/src/session/QueueDispatcher.ts index 4d25af42..1c6e28d0 100644 --- a/src/session/QueueDispatcher.ts +++ b/src/session/QueueDispatcher.ts @@ -82,6 +82,10 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven private autoSummaries = false; private summaries: QueueSummary[] = []; private detailChain?: number; + /** When the current command was submitted (or, if NMSh did not submit it, started); cleared at its prompt. */ + private commandSince?: number; + /** The input request of the current command already checked (its `since`); each is checked once. */ + private askedSince?: number; constructor(shell: QueueShell, queue = new CommandQueue(), private readonly clock: QueueClock = systemQueueClock) { super(); @@ -125,6 +129,7 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven /** Claim a manual submission synchronously, before an asynchronous shell exec marker can arrive. */ onSubmission(): void { if (this.atPrompt && !this.ran && !this.sent) this.manualStarting = true; + if (this.atPrompt) this.commandSince = this.clock.now(); this.atPrompt = false; this.queue.invalidateReviews(); } @@ -137,6 +142,8 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven this.atPrompt = false; this.ran = true; this.lastCommand = command; + this.commandSince = Math.min(this.commandSince ?? at, at); + this.askedSince = undefined; const entry = this.sent; this.sent = undefined; const summaryEligible = historyAllowed === 1 && !isPrivateCommand(entry?.text ?? command, ignorePatternFromEnv()); @@ -152,6 +159,20 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven if (!this.atPrompt) this.interrupted = true; } + /** + * The running command asked for input (InputWatch reported a request; `program` names it). A line queued while it + * ran, before this was known, may be its reply: in Queue Mode Enter queues without approval, and an Enter pressed + * before the reader could be seen (an echoing terminal is no evidence, #514) queues it as a command. Those entries now + * wait for approval, decided here beside the prompt so the next dispatch can never overtake a frontend's request; + * hidden input also hides their text. Each request is checked once, when first reported: what the person queues + * while it is shown stays as queued. + */ + onInputRequest(request: {since: number; mode: string}, program: string): number { + if (this.atPrompt || this.commandSince === undefined || this.askedSince === request.since) return 0; + this.askedSince = request.since; + return this.queue.holdAddedSince(this.commandSince, request.mode === 'hidden' ? program.slice(0, 64) || 'the command' : undefined); + } + onPrompt(exitCode: number, at = this.clock.now(), waits?: {inputWaitMs?: number; incomplete?: boolean}): void { // The entry it submitted was a line the shell could not finish; the session cancelled it before anything ran. It is // a mistake in the queued text, not a result: what follows waits for the person, whatever the failure policy. @@ -161,6 +182,8 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven const belongs = this.timeline.running; if (this.ran && belongs) this.timeline.complete(exitCode, this.interrupted || exitCode === 130, at, waits?.inputWaitMs); this.foreground = undefined; + this.commandSince = undefined; + this.askedSince = undefined; // A submission that started no command (a comment, an empty block) is not owed to the next command. this.sent = undefined; const finished = this.ran; @@ -286,6 +309,7 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven // Leaves the prompt now: a second dispatch cannot happen before this entry's own prompt. this.atPrompt = false; this.sent = entry; + this.commandSince = this.clock.now(); this.emit('state', this.state, {dispatched: entry}); // Exactly as the composer submits it: a multi-line entry runs as one block. this.shell.submit(shellSubmission(entry.text, this.shell.adapter.id)); diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index c9e6405f..58c24670 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -429,6 +429,8 @@ export class SessionService { // Preserve explicit reader ownership for detach/reattach and transcript cuts, // even on Fish versions that send no terminal input-mode escape. if (state.terminalOwner) session.screen.interactive = true; + // Before any window hears of the request: lines queued while it ran may be its reply and stop running by themselves. + if (state.request) session.queue.onInputRequest(state.request, state.request.program ?? 'the command'); if (session.controllerFeatures?.has('input-state')) session.controller?.(inputStateMessage(state)); const request = state.request; if (!request) { diff --git a/tests/fixtures/ownershipRaceEntry.mjs b/tests/fixtures/ownershipRaceEntry.mjs new file mode 100644 index 00000000..3a211be5 --- /dev/null +++ b/tests/fixtures/ownershipRaceEntry.mjs @@ -0,0 +1,5 @@ +const probe = new URL('./ownershipRaceProbe.mjs', import.meta.url); +probe.searchParams.set('gate', process.env.NMSH_TEST_OWNERSHIP_GATE); +await import(probe.href); +process.execArgv.push('--import=' + probe.href); +await import('../../src/index.ts'); diff --git a/tests/fixtures/ownershipRaceProbe.mjs b/tests/fixtures/ownershipRaceProbe.mjs new file mode 100644 index 00000000..26a73172 --- /dev/null +++ b/tests/fixtures/ownershipRaceProbe.mjs @@ -0,0 +1,28 @@ +/** Controlled observation seam only. Real shell reads, terminal modes and all routing remain production code. */ +import {appendFileSync, existsSync} from 'node:fs'; +import {InputWatch} from '../../src/session/InputWatch.ts'; +import {QueryOrder} from '../../src/shell/QueryOrder.ts'; +// The service deliberately strips frontend environment; the fixture passes only its private gate in this module URL. +const root = new URL(import.meta.url).searchParams.get('gate'); +if (!root) throw new Error('ownership race fixture needs its private gate'); +const trace = event => appendFileSync(root + '.jsonl', JSON.stringify({pid: process.pid, ...event}) + '\n'); +process.on('uncaughtExceptionMonitor', error => trace({event: 'fixture-error', message: error.message})); +trace({event: 'loaded'}); +const apply = InputWatch.prototype.apply; +InputWatch.prototype.apply = function(sample, now) { + const controlled = this.running?.command.includes('QA_RACE') && !existsSync(root + '.release'); + if (controlled) { + trace({event: 'sample', modes: sample.modes, states: sample.foreground.map(p => p.state), readingTerminal: sample.readingTerminal}); + sample = {...sample, readingTerminal: undefined, foreground: sample.foreground.map(p => ({...p, state: 'R' + p.state.replaceAll('R', '')}))}; + } + return apply.call(this, sample, now); +}; +const input = QueryOrder.prototype.input; +QueryOrder.prototype.input = function(...args) { + if (!this.ownershipRaceTrace) { + this.ownershipRaceTrace = true; + const write = this.write; + this.write = bytes => { trace({event: 'pty-write', bytes: Buffer.byteLength(bytes)}); return write.call(this, bytes); }; + } + return input.apply(this, args); +}; diff --git a/tests/queueModeOwnershipRaceLive.test.ts b/tests/queueModeOwnershipRaceLive.test.ts new file mode 100644 index 00000000..f1e6584c --- /dev/null +++ b/tests/queueModeOwnershipRaceLive.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * Queue Mode under #514's ownership race, on a real PTY: a prompt-less `read` that NMSh cannot recognize yet (the + * fixture holds back only the evidence, never the shell) gets an immediate reply and Enter. An echoing terminal is no + * evidence of a reader, and Queue Mode queues lines without approval, so the reply reaches the queue as a command. + * Once the read is seen, that entry waits for approval: when the read gives up and its command succeeds, the reply has + * not run, and never runs by itself. + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const entry = fileURLToPath(new URL('./fixtures/ownershipRaceEntry.mjs', import.meta.url)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +for (const reactionMs of [0, 300]) { + test(`live zsh, Queue Mode: a reply entered ${reactionMs}ms into a read NMSh could not see yet never runs as a command`, {skip: !supported, timeout: 90_000}, async t => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', queue: {queueMode: true}}); + const gate = join(sandbox.home, 'ownership-gate'); + const frontend = sandbox.launch([], {cols: 120, rows: 36}, {NMSH_TEST_OWNERSHIP_GATE: gate}, entry); + const trace = () => existsSync(gate + '.jsonl') ? readFileSync(gate + '.jsonl', 'utf8').trim().split('\n').filter(Boolean) : []; + try { + await frontend.waitFor(/zsh/u); + await until(async () => (await sandbox.sessions()).some(s => s.lastExit !== undefined), 30_000, () => 'authenticated prompt: ' + strip(frontend.output).slice(-1000)); + await frontend.run('echo READY', /READY/u); + const command = `read -t 8 -r QA_RACE; printf 'TIMED=%s\\n' "$QA_RACE"`; + const mark = frontend.mark; + frontend.pty.write(command + '\r'); + await pause(reactionMs); + frontend.pty.write('hello\r'); + await until(async () => (await sandbox.sessions())[0]?.queued === 1, 5000, 'Queue Mode queued the unrecognized reply'); + assert.ok(trace().some(line => line.includes('"sample"')), 'the control exercised real probe observations'); + writeFileSync(gate + '.release', ''); + await until(async () => (await sandbox.sessions())[0]?.inputMode === 'line', 10_000, 'the read is seen once the evidence is released'); + // The read gives up and the command succeeds: nothing stops the queue except the hold on the possible reply. + await frontend.waitFor(/TIMED=/u, mark, 15_000); + await until(async () => await frontend.completions(command) === 1, 15_000, 'the read command completed'); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /TIMED=hello/u, 'the reply never reached the read either'); + await pause(1500); + const records = (await sandbox.transcripts().list()).flatMap(s => s.transcript.records); + assert.equal(records.some(r => r.command.trim() === 'hello'), false, 'the reply never ran as a command'); + assert.equal((await sandbox.sessions())[0]?.queued, 1, 'it waits in the queue'); + assert.match(strip(frontend.output.slice(mark)), /waits? for your approval/u, 'and says why'); + // In Queue Mode the next line goes behind it; nothing releases the held reply but the person. + frontend.pty.write('echo AFTER\r'); + await until(async () => (await sandbox.sessions())[0]?.queued === 2, 5000, 'the next line queued behind the held reply'); + await pause(1500); + const after = (await sandbox.transcripts().list()).flatMap(s => s.transcript.records).map(r => r.command.trim()); + assert.equal(after.includes('hello') || after.includes('echo AFTER'), false, 'neither ran by itself'); + } finally { t.diagnostic(JSON.stringify({reactionMs, trace: trace().length})); await sandbox.dispose(); } + }); +} diff --git a/tests/queueReplyHold.test.ts b/tests/queueReplyHold.test.ts new file mode 100644 index 00000000..a0c82912 --- /dev/null +++ b/tests/queueReplyHold.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandQueue} from '../src/session/CommandQueue.js'; +import {QueueDispatcher} from '../src/session/QueueDispatcher.js'; + +/** + * #514 race, owner side: an Enter pressed before NMSh could see that the command reads (an echoing terminal is no + * evidence) can reach the queue as a command, with no approval in Queue Mode. Once the command is reported asking for + * input, everything queued while it ran waits for approval. The owner decides it beside the prompt, so a reader that + * ends right after it was seen can never let the next dispatch run its reply. + */ + +function fixture() { + let now = 0; + const submitted: string[] = []; + const queue = new CommandQueue(() => now); + const dispatcher = new QueueDispatcher({isReady: true, adapter: {id: 'zsh'}, submit: (text: string) => { submitted.push(text); }} as never, queue, + {now: () => now, monotonic: () => now}); + return {queue, dispatcher, submitted, at: (ms: number) => { now = ms; }}; +} + +test('a line queued before the reader was seen never runs after it, even when the prompt follows at once', () => { + const {queue, dispatcher, submitted, at} = fixture(); + at(50); dispatcher.request({op: 'add', text: 'echo EARLY'}); + at(100); dispatcher.onExec('read -r reply; printf %s "$reply"'); + at(150); dispatcher.request({op: 'add', text: 'hello'}); + at(200); assert.equal(dispatcher.onInputRequest({since: 100, mode: 'line'}, 'read'), 1); + // Ctrl+Q while the question is shown is a command for after it: left as queued. + at(250); dispatcher.request({op: 'add', text: 'echo QUEUED-WHILE-SHOWN'}); + assert.equal(dispatcher.onInputRequest({since: 100, mode: 'line'}, 'read'), 0, 'each request is checked once'); + // The reader gives up right after it was seen (a timed read): the prompt arrives before any frontend could act. + at(260); dispatcher.onPrompt(0); + assert.deepEqual(submitted, ['echo EARLY'], 'an entry queued before the command is no reply and runs'); + at(270); dispatcher.onExec('echo EARLY'); + at(280); dispatcher.onPrompt(0); + assert.deepEqual(submitted, ['echo EARLY'], 'the possible reply waits; nothing behind it overtakes it'); + assert.deepEqual(queue.state.entries.map(entry => [entry.text, entry.condition]), [['hello', 'approve'], ['echo QUEUED-WHILE-SHOWN', undefined]]); + assert.equal(queue.state.entries[0]!.typedWhile, undefined, 'a visible line stays shown as typed'); +}); + +test('hidden input also hides what was queued; approved entries and the time before submission are respected', () => { + const {queue, dispatcher, submitted, at} = fixture(); + dispatcher.onPrompt(0); + at(90); dispatcher.onSubmission(); + // Typed between Enter and the shell's exec marker: still this command's. + at(95); dispatcher.request({op: 'add', text: 'hunter2'}); + at(100); dispatcher.onExec('sudo true'); + at(120); dispatcher.request({op: 'add', text: 'make', condition: 'approve'}); + dispatcher.request({op: 'approve', id: queue.state.entries[1]!.id}); + at(200); assert.equal(dispatcher.onInputRequest({since: 150, mode: 'hidden'}, 'sudo'), 1); + const [secret, approved] = queue.state.entries; + assert.equal(secret!.condition, 'approve'); + assert.equal(secret!.typedWhile, 'sudo', 'shown only as where it came from'); + assert.equal(approved!.approved, true, 'an approval the person gave stays'); + at(300); dispatcher.onPrompt(0); + assert.deepEqual(submitted, []); + // A new command starts fresh: its own request checks only what was queued since it was submitted. + assert.equal(dispatcher.onInputRequest({since: 150, mode: 'line'}, 'sudo'), 0, 'nothing runs: no request applies at the prompt'); +}); From 80463a54622a9592f049e7c48d67b49adce42a54 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:15:22 +0530 Subject: [PATCH 4/5] Test: unfinished-line cancellation against heredocs, quotes, escapes, functions and substitutions Complete multi-line constructs (quoted and unquoted heredocs, a quote inside a heredoc, multi-line strings, escaped newlines, a function, a multi-line command substitution, blank lines in an if block, ! in single quotes) run untouched on zsh and Bash; five unfinished lines are cancelled with nothing run, and the next command after each runs normally. Refs #473 --- tests/incompleteSubmission.test.ts | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/incompleteSubmission.test.ts b/tests/incompleteSubmission.test.ts index 80d660d3..557e599a 100644 --- a/tests/incompleteSubmission.test.ts +++ b/tests/incompleteSubmission.test.ts @@ -81,6 +81,47 @@ test('live zsh and bash: a multi-line submission\'s own continuation prompts nev } }); +/** Complete constructs whose own line breaks ask for continuation lines: each must run, never be cancelled. */ +const COMPLETE: Array<{text: string; expect: RegExp}> = [ + {text: "cat <<'EOF'\nit's \"half-quoted\nEOF", expect: /it's "half-quoted/u}, + {text: 'cat < { + for (const shell of ['zsh', ...(bash ? ['bash' as const] : [])] as const) { + await withShell(shell, async (session, next, outputs) => { + for (const {text, expect} of COMPLETE) { + const from = outputs.length; + session.submit(shellSubmission(text, shell)); + const marker = await next(); + assert.equal(marker.incomplete, undefined, `${shell}: ${JSON.stringify(text)} is complete and is not cancelled`); + assert.equal(marker.exitCode, 0, `${shell}: ${JSON.stringify(text)} succeeds`); + assert.match(outputs.slice(from).join(''), expect, `${shell}: ${JSON.stringify(text)} ran`); + } + for (const text of UNFINISHED) { + const from = outputs.length; + session.submit(shellSubmission(text, shell)); + const marker = await next(); + assert.equal(marker.incomplete, true, `${shell}: ${JSON.stringify(text)} is reported unfinished`); + assert.doesNotMatch(outputs.slice(from).join(''), /NEVER-/u, `${shell}: nothing of ${JSON.stringify(text)} ran`); + // The very next command is an ordinary one: no leftover continuation state swallows or cancels it. + session.submit(shellSubmission('echo AFTER-UNFINISHED', shell)); + const after = await next(); + assert.equal(after.incomplete, undefined, `${shell}: the command after ${JSON.stringify(text)} is not cancelled`); + assert.match(outputs.slice(from).join(''), /AFTER-UNFINISHED/u); + } + }); + } +}); + test('queue: an entry the shell could not finish pauses what follows, whatever the failure policy; nothing else is sent', async () => { const {CommandQueue} = await import('../src/session/CommandQueue.js'); const {QueueDispatcher} = await import('../src/session/QueueDispatcher.js'); From a053031e3d745f82b47e28ca4b8384f06982752d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:00:10 +0530 Subject: [PATCH 5/5] Queue: replace standing Queue Mode with a per-command queue composer A standing "Enter queues" mode cannot be made safe: on macOS nothing reports that a program reads with no prompt, so no setting can tell a reply from a command, and it would weaken #514's boundary for every command. Ctrl+Q on an empty line while a command runs now opens the queue composer for that command only: each line entered in it is queued as typed, without approval, until Esc, a second Ctrl+Q on the empty line, or the end of that command. Plain Enter keeps #514's rules. Hidden input closes the composer before anything is drawn: its first key is looked at like the composer's, and a request reported while it is empty closes it. A line already begun in it stays a queued command. Removes the Start in Queue Mode setting and /queue mode. The owner-side hold on late-detected readers stays. Documents the remaining limit: a reader NMSh never sees before it ends. Refs #426 Refs #473 --- CHANGELOG.md | 2 +- docs/design/input-awareness.md | 29 +- src/app/TerminalApp.ts | 96 +++---- src/commands/slashCommands.ts | 6 +- src/prompt/configuration.ts | 10 +- src/session/QueueDispatcher.ts | 4 +- src/setup/SetupCat.ts | 3 +- src/ui/SettingsPanel.ts | 4 +- tests/queueComposer.test.ts | 226 ++++++++++++++++ ...Live.test.ts => queueComposerLive.test.ts} | 48 +--- ....test.ts => queueComposerRaceLive.test.ts} | 27 +- tests/queueMode.test.ts | 248 ------------------ tests/queueReplyHold.test.ts | 2 +- tests/queueSettings.test.ts | 2 +- 14 files changed, 326 insertions(+), 381 deletions(-) create mode 100644 tests/queueComposer.test.ts rename tests/{queueModeLive.test.ts => queueComposerLive.test.ts} (69%) rename tests/{queueModeOwnershipRaceLive.test.ts => queueComposerRaceLive.test.ts} (68%) delete mode 100644 tests/queueMode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fdc3588..cca0ffa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Queue Mode** ([#473](https://github.com/raiseCatError/notMyShell/issues/473), [#426](https://github.com/raiseCatError/notMyShell/issues/426)). While a command runs, Enter normally holds a line for your approval, because NMSh cannot prove it is not a reply to a program that asked nothing visible. Queue Mode is the explicit alternative: Enter queues the line to run after the current command, shown as typed, once and in order, with no approval step — what Ctrl+Q already did, without the extra key. Turn it on or off for one window with **Ctrl+Q on an empty line** or `/queue mode [on|off]`; **Settings → Queue → Start in Queue Mode** (off by default) chooses how new windows start, and a toggle never changes that setting. The composer says when it is on. At the prompt, a line entered in Queue Mode never runs ahead of entries already waiting (a held line at the head included): it is added after them, and the composer says how many wait. Only a queue paused after a failure, an interrupt or `/queue pause` gives the prompt back, so a fix runs at once. Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input gets Enter, hidden input is never drawn or queued, and when NMSh cannot tell who reads the typing it stays held as before. A program that reads with no prompt and that NMSh cannot see (`cat`, a script's `input()`) gets a queued line instead of its answer in Queue Mode: use Ctrl+S, or leave Queue Mode off. Once a running command is seen asking for input, every line queued while it ran waits for your approval (its text hidden if the input is hidden). The session service decides this before the next queued command could start, so a reply typed before NMSh could see the reader never runs as a command by itself. +- **Queue several commands while one runs** ([#473](https://github.com/raiseCatError/notMyShell/issues/473), [#426](https://github.com/raiseCatError/notMyShell/issues/426)). **Ctrl+Q on an empty line** while a command runs opens the queue composer for that command: every line you enter in it is queued to run after it, shown as typed, once and in order, with no approval step — what Ctrl+Q on a line already did, without pressing it for each line. The composer says so while it is open; Esc, a second Ctrl+Q on the empty line, or the end of that command closes it, so it never carries over to the next command. Plain Enter keeps the input-ownership rules: a program NMSh sees waiting gets it, and a line NMSh cannot place waits for your choice. Hidden input closes the composer before anything typed is drawn: the first key is looked at as usual, and a password prompt reported while it is open and empty sends your typing to the program. Once a running command is seen asking for input, every line queued while it ran waits for your approval (its text hidden if the input is hidden); the session service decides this before the next queued command could start. **Limit:** NMSh cannot see every reader (on macOS nothing reports that a program reads with no prompt, and a timed read can give up before it is seen), so a line you type into the queue composer for such a program runs as a command after it; the composer exists for commands, and Ctrl+S sends to the program. - **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings. - **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works. diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 8e325c13..93870acc 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -171,18 +171,23 @@ typed is drawn, not even as dots. - **There are no answer buttons.** NMSh never submits an answer on your behalf. - **No question in sight** (nothing waits that NMSh can see): Enter holds a line for approval and shows it by where it came from, because a prompt-less read is indistinguishable from a command NMSh cannot see (#426). Ctrl+Q queues it - as typed. **Queue Mode** (per window: Ctrl+Q on an empty line or `/queue mode`; Settings chooses how windows start) - makes Enter do what Ctrl+Q does. It changes only this case: the look before typing is drawn, Enter to a program that - waits, hidden input and the held state when a look cannot tell are all as above. Its accepted cost is the prompt-less - read NMSh cannot see (an external program reading with no prompt): in Queue Mode the line is queued, visibly, instead. - A reader seen late (#514: an echoing terminal is no evidence, and a loaded machine can delay the follow-up sample) is - caught by the owner: when the running command is first reported asking for input, every entry queued since it was - submitted waits for approval (hidden input also hides their text). The session service decides this beside the - prompt, so the next dispatch cannot overtake it. What remains is a reader NMSh never sees before it ends (on macOS - there is no kernel read report: a prompt-less external reader, or a timed read that gives up within a second): its - queued line runs after it, as a Ctrl+Q line always has. - At the prompt, Queue Mode keeps order: a line never runs ahead of entries already waiting (a held head included); - only a paused queue gives the prompt back. + as typed. **The queue composer** (#473): Ctrl+Q on an empty line while a command runs opens it for that command + only; each line entered in it is queued as typed, without approval, until Esc, a second Ctrl+Q on the empty line, + or the end of that command. It is the person's explicit choice, made after seeing what runs, and it never carries + over to the next command, so a reflexive Enter there gets the rules above. Plain Enter is never made to queue by a + standing mode: on macOS nothing proves that a program does not read, so no setting can make Enter safe for every + command (#514's boundary stays). + - Hidden input closes it before anything is drawn: its first key is looked at like the composer's, and a hidden + look closes it and holds the typing unseen; a request reported while it is open and empty closes it, so the + keys go where ownership sends them. A line already begun in it stays a queued command, then it closes. + - A reader seen late (an echoing terminal is no evidence, and a loaded machine delays the follow-up sample) is + caught by the owner: when the running command is first reported asking for input, every entry queued since it + was submitted waits for approval (hidden input also hides their text), decided by the session service beside the + prompt, so the next dispatch cannot overtake it. + - Unavoidable limit: a reader NMSh never sees before it ends (a prompt-less external reader on macOS, or a timed + read that gives up within about a second) — a line typed for it into the queue composer runs after it as a + command, exactly as a Ctrl+Q line always has. A secret typed with echo on into the composer is shown and queued + like any queued text. Tests: `queueComposer.test.ts`, `queueComposerRaceLive.test.ts`, `queueReplyHold.test.ts`. - **Unfinished command lines.** NMSh draws no continuation prompt, so zsh and Bash print PS2 as an invisible marker. The session counts the continuation prompts a submission's own line breaks explain; one more, before the command starts, means the shell is waiting for the rest of a line NMSh sent whole (an unclosed quote, bracket or heredoc; diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 45326f26..2467bd9e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -990,8 +990,11 @@ export class TerminalApp { private staleStopReport?: () => void; /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ private replayTypedWhile?: string; - /** This window's Queue Mode choice (Ctrl+Q on an empty line, /queue mode); undefined follows Settings. */ - private queueModeChoice?: boolean; + /** + * The command (startId) for which the person opened the queue composer with Ctrl+Q on an empty line: until Esc, or + * until that command ends, each Enter adds the line to the queue (see openQueueComposer). + */ + private queueComposeFor?: number; /** Held lines (typedWhile) the person chose to see, by queue entry id; everything else shows where it came from. */ private readonly revealedQueue = new Set(); /** Held lines this window added and has not yet seen in the owner's state (so others are the owner's, #426). */ @@ -2566,6 +2569,7 @@ export class TerminalApp { if (key.kind === 'escape' && this.stopAhead && !this.editor.text) { this.settleStopAhead('cancel'); return; } if (key.kind === 'escape' && this.queueCompose) { this.queueCompose = false; + this.queueComposeFor = undefined; this.editor.clear(); this.render(); return; @@ -2876,7 +2880,7 @@ export class TerminalApp { else if (key.kind === 'deleteLineAfter') this.editor.deleteLineAfter(); else if (key.kind === 'delete') this.editor.delete(); else if (key.kind === 'newline') this.editor.insert('\n'); - else if (key.kind === 'queueCommand' && !this.editor.text.trim() && !this.queueCompose && !this.queueEdit && this.queueAvailable()) this.setQueueMode('Ctrl+Q'); + else if (key.kind === 'queueCommand' && !this.editor.text.trim() && !this.queueEdit && this.running && !this.passthrough && this.queueAvailable()) this.openQueueComposer(); else if (key.kind === 'queueCommand') this.routeComposer('queue'); else if (key.kind === 'sendToProgram') this.routeComposer('send'); else if (key.kind === 'enter') this.routeComposer('enter'); @@ -3316,7 +3320,6 @@ export class TerminalApp { else if (slash.kind === 'doctor') void this.openDoctor(); else if (slash.kind === 'watch') this.handleWatch(command.trim(), slash.op, slash.arguments, slash.track === true); else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); - else if (slash.kind === 'queueMode') this.setQueueMode(command.trim(), slash.value); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); else { @@ -5621,6 +5624,7 @@ export class TerminalApp { this.running = undefined; this.inputState = undefined; this.queueCompose = false; + this.queueComposeFor = undefined; this.completedInputTiming = undefined; // An answer typed for this command but never sent must not become the next shell command. const draft = this.runningDraft; @@ -13083,15 +13087,11 @@ export class TerminalApp { if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; } - // Queue Mode is a standing choice for this window: the empty composer says so at the prompt too. - if (!this.running && !this.passthrough && !this.queueEdit && !this.startupPending && this.queueModeOn()) { - const waiting = this.queueState.entries.length; - if (waiting && this.queueState.paused) return {text: `Queue Mode · queue paused, ${waiting} waiting · Enter runs it now · Ctrl+Q queues it after them`, waiting: false}; - if (waiting) return {text: `Queue Mode · Enter adds it after ${waiting} waiting · Ctrl+Q on an empty line turns Queue Mode off`, waiting: false}; - return {text: 'Queue Mode · while a command runs, Enter queues · Ctrl+Q on an empty line turns it off', waiting: false}; - } 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 && this.queueComposeFor === this.running.startId) { + return {text: `Queueing · each Enter adds a command to run after ${runningProgramName(this.running.command)} · Esc stops`, waiting: false}; + } 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) : runningProgramName(this.running.command); @@ -13101,9 +13101,8 @@ export class TerminalApp { // 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; - if (this.queueModeOn()) return {text: `Queue Mode · Enter queues it to run next · Ctrl+S sends to ${program}`, waiting: false}; const enter = this.session.lookInput && this.session.features.has('input-look') ? 'Enter checks who reads it' : 'Enter holds it for approval'; - return {text: `${enter} · Ctrl+Q runs it after · Ctrl+S sends to ${program} · Ctrl+Q on an empty line: Queue Mode`, waiting: false}; + return {text: `${enter} · Ctrl+Q runs it after · Ctrl+S sends to ${program} · Ctrl+Q alone queues several`, waiting: false}; } /** @@ -13193,27 +13192,22 @@ export class TerminalApp { } /** - * Queue Mode, for this window: while a command runs, Enter queues the line as an ordinary command, as Ctrl+Q does, - * instead of holding it for approval. Input ownership still comes first: a program NMSh sees waiting for input gets - * Enter, and typing NMSh has not placed yet is looked at before it is drawn. Starts from Settings (off by default); - * Ctrl+Q on an empty line and /queue mode change it for this window only, so a stray key never persists. + * The queue composer, opened with Ctrl+Q on an empty line while a command runs (#473): every line entered in it is + * queued to run after that command, as Ctrl+Q on a line does, with no approval step and without pressing Ctrl+Q for + * each line. It is the person's explicit choice for this one command: it never outlives it, Esc closes it, and plain + * Enter elsewhere keeps the input-ownership rules (#426, #514). A program seen asking for input closes it while it is + * empty, so the next keys (a password included) go where input ownership sends them, never into a visible queue line. + * Ctrl+Q on the empty composer again closes it. What NMSh cannot see (a reader with no prompt on macOS) is not made + * safe by this: the composer says, every time it is open, that what is typed in it runs as a command. */ - private queueModeOn(): boolean { - return this.queueAvailable() && (this.queueModeChoice ?? this.promptConfiguration.queue.queueMode === true); - } - - private setQueueMode(source: string, value?: boolean): void { - if (!this.queueAvailable()) { - this.output.addFrontendInteraction(source, this.session.features.has('queue') - ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); - this.render(); - return; + private openQueueComposer(): void { + if (this.queueCompose && this.queueComposeFor === this.running?.startId) { + this.queueCompose = false; + this.queueComposeFor = undefined; + } else { + this.queueCompose = true; + this.queueComposeFor = this.running!.startId; } - const on = value ?? !this.queueModeOn(); - this.queueModeChoice = on; - this.output.addFrontendInteraction(source, on - ? 'Queue Mode on: while a command runs, Enter queues the line to run after it. A program waiting for input still gets Enter. Ctrl+Q on an empty line turns it off.' - : 'Queue Mode off: while a command runs, Enter holds a line for your approval; Ctrl+Q queues it.', INFO); this.render(); } @@ -13372,12 +13366,14 @@ export class TerminalApp { this.session.queue({op: 'add', text, ...(conditions ? {condition: 'approve' as const, typedWhile} : {})}); if (conditions) this.ownHeldAdds += 1; // The note never repeats the text: it may be a password. - this.output.addFrontendInteraction(typedWhile, `The line entered while ${typedWhile} runs may be its reply, so it never runs by itself: it ${conditions ? 'waits for your approval' : 'pauses the queue'} (/queue). Ctrl+Q queues a command to run after; Queue Mode (Ctrl+Q on an empty line) makes Enter queue.`, INFO); + this.output.addFrontendInteraction(typedWhile, `The line entered while ${typedWhile} runs may be its reply, so it never runs by itself: it ${conditions ? 'waits for your approval' : 'pauses the queue'} (/queue). Ctrl+Q queues a command to run after; Ctrl+Q on an empty line queues several.`, INFO); } this.editor.clear(); this.composerHistory.reset(); this.runningDraft = undefined; - this.queueCompose = false; + // The queue composer the person opened for this command stays open for the next line; a one-off one closes. + this.queueCompose = this.queueComposeFor !== undefined && this.queueComposeFor === this.running?.startId; + if (!this.queueCompose) this.queueComposeFor = undefined; this.render(); } @@ -13436,7 +13432,7 @@ export class TerminalApp { if (intent === 'enter' && text.trim() && (this.justStopped() || afterDone) && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text, afterDone && !this.justStopped()); return; } // A first-key hold can contain edits and Enter together. Parse NMSh actions above first; a replayed // program submission still needs reliable ownership, even when a later look only inferred a line reader. - if (intent === 'enter' && this.replayingHeld && text.trim() && !this.justStopped() && !this.running.done && !this.queueModeOn() + if (intent === 'enter' && this.replayingHeld && text.trim() && !this.justStopped() && !this.running.done && this.inputState?.request?.confidence !== 'confirmed' && this.session.lookInput && this.session.features.has('input-look')) { this.holdForOwnershipLook({kind: 'enter'}, true); return; @@ -13451,14 +13447,12 @@ export class TerminalApp { } if (!text.trim()) return; if (this.reviewMixedBlock(text)) return; - // Queue Mode: the person said Enter queues. Nothing waits for input that NMSh can see (that took Enter above), - // so the line is a command for after this one, shown as typed and run without an approval step. - this.enqueue(text, intent === 'enter' && !this.queueModeOn() ? runningProgramName(this.running.command) : undefined); + this.enqueue(text, intent === 'enter' ? runningProgramName(this.running.command) : undefined); return; } if (intent === 'send') return; // Held keys replayed after their command ended: this Enter was pressed while it ran, so it is no command (#426). - if (this.replayTypedWhile !== undefined && intent === 'enter' && text.trim() && !slash) { this.enqueue(text, this.queueModeOn() ? undefined : this.replayTypedWhile); return; } + if (this.replayTypedWhile !== undefined && intent === 'enter' && text.trim() && !slash) { this.enqueue(text, this.replayTypedWhile); return; } if (this.reviewMixedBlock(text)) return; if (this.pendingQueuePause) { this.render(); return; } // An empty Enter where the queue offers something arms its review; nothing runs yet. @@ -13469,11 +13463,10 @@ export class TerminalApp { return; } // A held line waiting for approval at the head runs nothing by itself, so it never holds the prompt: a command you - // submit there runs now (#426). Anything behind the held line waits for it either way. In Queue Mode Enter queues, - // so a line never runs ahead of entries already waiting; only a paused queue gives the prompt back. + // submit there runs now (#426). Anything behind the held line waits for it either way. const head = this.queueState.entries[0]; const held = Boolean(head && head.condition === 'approve' && !head.approved); - const behind = this.queueState.entries.length > 0 && !this.queueState.paused && (!held || this.queueModeOn()); + const behind = this.queueState.entries.length > 0 && !this.queueState.paused && !held; if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || behind)) { this.enqueue(text); return; @@ -13680,6 +13673,13 @@ export class TerminalApp { } this.inputState = state; if (this.arcade) this.arcadeInputState(state); + // A program asks for input while the queue composer is open and empty: it closes, so the next keys (a password + // included) go where input ownership sends them, never into a visible queue line. A line already begun in it was + // begun as a queued command; it stays one, and the composer closes once it is added (or Esc). + if (state.request && this.queueComposeFor !== undefined) { + if (!this.editor.text) { this.queueCompose = false; this.queueComposeFor = undefined; } + else this.queueComposeFor = undefined; + } // The shell said the command is done: typing begun from now on is for the prompt, never a reply (#426, #436). if (state.done) { this.running.done = true; @@ -13804,9 +13804,13 @@ export class TerminalApp { */ 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.replayingHeld || !this.running || this.passthrough || this.startupPending || this.queueEdit || this.justStopped()) return false; + // A one-off queue entry (Ctrl+Q during direct input) is composed for the queue. The queue composer is looked at + // before its first key is drawn, like the composer: hidden input closes it and the typing stays held, unseen. + if (this.queueCompose && this.queueComposeFor === undefined) 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; + if (this.queueCompose) return false; const text = this.editor.text; return key.kind === 'enter' && Boolean(text.trim()) && !isSlashInput(text) && this.queueAvailable() && !this.answersByDefault(); } @@ -13952,6 +13956,8 @@ export class TerminalApp { // its read returns, and the typing type-ahead; but it can be a second question with no prompt. Either way the // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). if (result === 'hidden') { if (afterHiddenAnswer()) hold.sawAnsweredHidden = true; else hold.sawHidden = true; } + // Hidden input: the queue composer closes, so nothing typed now is drawn as a queue line. + if (result === 'hidden' && this.queueComposeFor !== undefined) { this.queueCompose = false; this.queueComposeFor = undefined; } // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. Typing is type-ahead for the prompt // only when the shell's done marker reached this window before it began (InputState.done): a look's idle says @@ -13961,9 +13967,7 @@ export class TerminalApp { const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || result === undefined; // Echo permits presentation, not submission to a reader. Combined first-key holds are checked // by routeComposer after their editor keys and NMSh actions have been parsed. - // In Queue Mode Enter is the person's queue key, as Ctrl+Q is: the line is queued, and the owner holds it for - // approval if the command is later seen asking for input (QueueDispatcher.onInputRequest). - if (result === 'visible' && !this.inputState?.request && key.kind === 'enter' && !this.queueModeOn() + if (result === 'visible' && !this.inputState?.request && key.kind === 'enter' && this.running?.startId === running.startId && !this.running.done) { confirm(); return; } if (!blind) settle('route'); else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index fdd70cc1..14d11a4c 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -137,7 +137,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/qr', insertion: '/qr', alias: '/queue', description: 'Review before resuming the queue'}, {name: '/qc', insertion: '/qc', alias: '/queue', description: 'Review before clearing queued commands'}, {name: '/pause', insertion: '/pause', alias: '/queue', description: 'Pause the command queue'}, - {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings; /queue mode [on|off] switches Queue Mode, where Enter queues while a command runs'}, + {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings'}, {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume|refresh)'}, {name: '/track', insertion: '/track ', description: 'Keep one live result of a finite command in the transcript, refreshed in place (/track df -h · /track --every 5s -- docker system df · pause|resume|refresh|stop|copy)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, @@ -251,8 +251,6 @@ export type ParsedSlashCommand = | {kind: 'doctor'} | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy'; arguments: string; /** Typed as /track: keep one live result in the transcript. */ track?: boolean} | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'} - /** `/queue mode [on|off]`: this window's Queue Mode; no argument toggles it. */ - | {kind: 'queueMode'; value?: boolean} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} | {kind: 'history', query: string} @@ -414,8 +412,6 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; const shortcut = ({'/qp': 'pause', '/pause': 'pause', '/qr': 'resume', '/qc': 'clear'} as const)[input.trim() as '/qp']; if (shortcut) return {kind: 'queue', op: shortcut}; - const queueMode = /^\/(?:queue|q)\s+mode(?:\s+(on|off))?\s*$/u.exec(input); - if (queueMode) return queueMode[1] ? {kind: 'queueMode', value: queueMode[1] === 'on'} : {kind: 'queueMode'}; const queue = /^\/(?:queue|q)(?:\s+(pause|resume|clear|settings|history|save-summary))?\s*$/u.exec(input); if (queue) return queue[1] === 'settings' ? {kind: 'settings', view: 'config', destination: 'queue'} : {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'}; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 08238963..fe4f2a8a 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -867,11 +867,6 @@ export interface QueuePresentationSettings { summaryExitCodes: boolean; /** Show observed input waits when one was recorded; nothing is ever guessed. */ summaryInputWaits: boolean; - /** - * New windows start in Queue Mode: while a command runs, Enter queues the line as an ordinary command (as Ctrl+Q - * does) instead of holding it for approval. A program NMSh sees waiting for input still gets Enter. Off by default. - */ - queueMode: boolean; } function boundedCount(value: unknown, min: number, max: number, fallback: number): number { @@ -892,8 +887,7 @@ export function normalizeQueuePresentation(value: unknown): QueuePresentationSet summaryDetail: pickValue(QUEUE_SUMMARY_DETAILS, raw.summaryDetail, 'detailed'), summaryPlacement: pickValue(QUEUE_SUMMARY_PLACEMENTS, raw.summaryPlacement, 'center'), summaryAlign: pickValue(QUEUE_SUMMARY_ALIGNS, raw.summaryAlign, 'left'), - summaryDurations: raw.summaryDurations !== false, summaryExitCodes: raw.summaryExitCodes !== false, summaryInputWaits: raw.summaryInputWaits === true, - queueMode: raw.queueMode === true}; + summaryDurations: raw.summaryDurations !== false, summaryExitCodes: raw.summaryExitCodes !== false, summaryInputWaits: raw.summaryInputWaits === true}; } export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { @@ -946,7 +940,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { queue: {liveTimeline: true, recentResults: 2, upcoming: 2, transient: true, transientSeconds: 8, outputDisplay: 'inline', autoSaveSummary: true, summaryDetail: 'detailed', summaryPlacement: 'center', summaryAlign: 'left', - summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false, queueMode: false}, + summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false}, workspacePanels: false, shellBackend: 'zsh', openWith: 'auto', diff --git a/src/session/QueueDispatcher.ts b/src/session/QueueDispatcher.ts index 1c6e28d0..6db25a07 100644 --- a/src/session/QueueDispatcher.ts +++ b/src/session/QueueDispatcher.ts @@ -161,8 +161,8 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven /** * The running command asked for input (InputWatch reported a request; `program` names it). A line queued while it - * ran, before this was known, may be its reply: in Queue Mode Enter queues without approval, and an Enter pressed - * before the reader could be seen (an echoing terminal is no evidence, #514) queues it as a command. Those entries now + * ran, before this was known, may be its reply: Ctrl+Q and the queue composer add lines without approval, and NMSh + * may not have been able to see the reader when they were typed (an echoing terminal is no evidence, #514). Those entries now * wait for approval, decided here beside the prompt so the next dispatch can never overtake a frontend's request; * hidden input also hides their text. Each request is checked once, when first reported: what the person queues * while it is shown stays as queued. diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 6ddf9454..17eb944a 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -397,8 +397,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Session notices are short events about another session (a finished command, a failure, a request for attention); /sessions keeps the state.'], rows: [ {row: configRow('liveSessionStartup')}, {row: configRow('liveSessionMultiple')}, - {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Ctrl+Q queues the next command; Enter holds a line for your approval unless Queue Mode is on' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, - {row: configRow('queueMode'), note: draft => draft.queue.queueMode ? 'On: new windows start in Queue Mode; while a command runs, Enter queues the line, and a program waiting for input still gets your answer' : 'Off: Enter holds a line typed while a command runs for your approval; Ctrl+Q on an empty line switches one window to Queue Mode'}, + {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Ctrl+Q queues the next command (on an empty line, several); Enter never runs a line NMSh cannot tell is a command' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, {row: configRow('queueFailurePolicy'), note: () => 'Applies to new execution chains; existing reviewed work keeps its policy. Ctrl+C always pauses.'}, {row: configRow('queueTimeline'), note: draft => draft.queue.liveTimeline ? 'On: queued work shows as a chronological timeline — recent results, the running command and what runs next' : 'Off: the one-line queue row and pending list, as before'}, {row: configRow('queueRecentResults')}, diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index c33a19ec..b5da32db 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -443,13 +443,11 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'terminalTitle', label: 'Terminal title', description: 'Set the window/tab title while NMSh owns the screen; programs set their own while they run and NMSh restores its title after them. Off leaves the title to the terminal', category: 'Sessions', values: ['off', 'project', 'session'] as const, labels: ['Off', 'Project', 'Project and session'], get: config => config.terminalTitle, set: (config, terminalTitle) => ({...config, terminalTitle})}), - {id: 'commandQueue', label: 'Queue commands', description: 'While a command runs, Ctrl+Q queues the next command to run after it and Ctrl+S sends to the program; Enter holds a line for your approval unless Queue Mode is on. Off: Enter sends to the program', category: 'Sessions', + {id: 'commandQueue', label: 'Queue commands', description: 'While a command runs, Ctrl+Q queues the next command to run after it (Ctrl+Q on an empty line queues several) and Ctrl+S sends to the program; Enter never runs a line NMSh cannot tell is a command. Off: Enter sends to the program', category: 'Sessions', control: 'boolean', get: config => config.commandQueue, set: (config, commandQueue) => ({...config, commandQueue})}, enumRow({id: 'queueFailurePolicy', label: 'On queued command failure', description: 'For new execution chains: pause remaining work (default), or continue after a nonzero exit. Ctrl+C always pauses; existing chains keep their reviewed policy', category: 'Sessions', values: ['pause', 'continue'] as const, labels: ['Pause remaining queue', 'Continue remaining queue'], get: config => config.queueFailurePolicy, set: (config, queueFailurePolicy) => ({...config, queueFailurePolicy})}), - {id: 'queueMode', label: 'Start in Queue Mode', description: 'New windows start with Queue Mode on: while a command runs, Enter queues the line to run after it, shown as typed, with no approval step. A program NMSh sees waiting for input still gets Enter, and hidden input is never shown. Ctrl+Q on an empty line or /queue mode turns it on or off for one window', category: 'Queue', - control: 'boolean', get: config => config.queue.queueMode, set: (config, queueMode) => withQueue(config, {queueMode})}, {id: 'queueTimeline', label: 'Live timeline', description: 'Queued work as a chronological timeline above the composer: recent results, the running command and what runs next. Off keeps the one-line queue row and the pending list; the transcript is never touched', category: 'Queue', control: 'boolean', get: config => config.queue.liveTimeline, set: (config, liveTimeline) => withQueue(config, {liveTimeline})}, {id: 'queueRecentResults', parent: 'queueTimeline', when: config => config.queue.liveTimeline, label: 'Recent results', description: 'How many finished commands stay visible above the running one; older results leave the timeline, and their output stays in the transcript', category: 'Queue', diff --git a/tests/queueComposer.test.ts b/tests/queueComposer.test.ts new file mode 100644 index 00000000..9bcd6e5c --- /dev/null +++ b/tests/queueComposer.test.ts @@ -0,0 +1,226 @@ +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 {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * The queue composer (#473): Ctrl+Q on an empty line while a command runs opens it for that command, and every line + * entered in it is queued as typed, in order, with no approval step, as Ctrl+Q on a line does. It is an explicit + * choice that never outlives its command, so plain Enter keeps #514's rule: an echoing terminal is no evidence, and a + * line NMSh cannot place waits for the person. Hidden input closes it before anything typed is drawn. + * + * Deterministic: every key goes through the real TerminalApp key handler; looks answer as the test says. + */ + +const SECRET = 'QSECRETQ'; +const CTRL_Q = '\u0011'; + +function fixture() { + const frame = executionFrameFixture(110, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, 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}); + const submitted: string[] = []; + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + const looksAnswer = (result: LookResult | undefined) => + Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return result; }, configurable: true}); + looksAnswer('visible'); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const enter = () => frame.app['onInput']('\r'); + const adds = () => queueOps.filter((op): op is Extract => op.op === 'add'); + 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'); + const report = (request: NonNullable) => + frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); + return {...frame, written, queueOps, submitted, looksAnswer, type, enter, adds, screen, transcript, report, looks: () => looks}; +} + +const settled = async () => { for (let i = 0; i < 4; i += 1) await new Promise(done => setImmediate(done)); }; + +const open = (f: ReturnType) => f.app['onInput'](CTRL_Q); + +test('/queue mode no longer exists; other /queue words are unchanged', () => { + assert.equal(parseSlashCommand('/queue mode')?.kind === 'queue', false); + assert.deepEqual(parseSlashCommand('/queue pause'), {kind: 'queue', op: 'pause'}); +}); + +test('plain Enter while sleep runs is never queued: an echoing terminal is no evidence, the line waits for a choice (#514)', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + assert.match(f.screen(), /Enter checks who reads it · Ctrl\+Q runs it after · Ctrl\+S sends to sleep · Ctrl\+Q alone queues several/u); + f.type('echo one'); + await settled(); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), [], 'nothing is queued or sent'); + assert.match(f.screen(), /Can.t tell whether sleep is reading your typing/u); + } finally { f.dispose(); } +}); + +test('Ctrl+Q on an empty line opens the queue composer: each line is queued as typed, in order, with no approval; it stays open', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + open(f); + assert.match(f.screen(), /Queueing · each Enter adds a command to run after sleep · Esc stops/u); + const lines = ['echo "STEP 1 — Starting"', 'sleep 2', 'echo "STEP 2 — Halfway"', 'sleep 2', "echo 'STEP 3 — Finished!'"]; + for (const line of lines) { f.type(line); await settled(); f.enter(); await settled(); } + assert.deepEqual(f.adds().map(op => op.text), lines, 'each line once, in the order entered'); + assert.equal(f.adds().some(op => op.condition !== undefined || op.typedWhile !== undefined), false, 'none waits for approval'); + assert.deepEqual(f.written, [], 'nothing reached sleep'); + assert.deepEqual(f.submitted, [], 'nothing ran early'); + assert.match(f.screen(), /Queueing · each Enter adds/u, 'still open for the next line'); + } finally { f.dispose(); } +}); + +test('adversarial: the composer never outlives its command; a reflex Enter during the next command is #514 again', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + open(f); + f.type('echo queued'); await settled(); f.enter(); await settled(); + f.complete(0); + assert.equal(f.app['queueCompose'], false, 'closed when its command ended'); + f.begin('python3 ask.py'); + f.type('yes'); await settled(); f.enter(); await settled(); + assert.deepEqual(f.adds().map(op => op.text), ['echo queued'], 'the reply typed for the next command is not queued'); + assert.match(f.screen(), /Can.t tell whether python3 is reading your typing/u); + // At the prompt, Ctrl+Q on an empty line opens nothing. + f.complete(0); + open(f); + assert.equal(f.app['queueCompose'], false); + } finally { f.dispose(); } +}); + +test('Esc closes the composer and drops its unfinished line; Ctrl+Q on the empty composer closes it too', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + open(f); + f.type('echo half'); await settled(); + f.app['handleKey']({kind: 'escape'}); + assert.equal(f.app['queueCompose'], false); + assert.equal(f.app['editor'].text, ''); + open(f); + assert.equal(f.app['queueCompose'], true); + open(f); + assert.equal(f.app['queueCompose'], false, 'a second Ctrl+Q on the empty composer closes it'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('adversarial: a password prompt NMSh has not reported yet: the first key is looked at, the composer closes, nothing is drawn or queued', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.looksAnswer('hidden'); + f.begin('sudo -k true'); + open(f); + f.type(SECRET); await settled(); + f.enter(); await settled(); + assert.equal(f.app['queueCompose'], false, 'hidden input closed the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not on screen'); + assert.equal(f.transcript().includes(SECRET), false, 'not in the transcript'); + assert.equal(f.adds().some(op => op.text.includes(SECRET)), false, 'not queued'); + assert.equal(f.submitted.some(text => text.includes(SECRET)), false, 'not run'); + } finally { f.dispose(); } +}); + +test('adversarial: a hidden question reported while the composer is open and empty closes it; the keys go to the program, unseen', async () => { + const f = fixture(); + try { + f.begin('sudo -k true'); + open(f); + f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + assert.equal(f.app['queueCompose'], false); + f.type(SECRET); f.enter(); await settled(); + assert.equal(f.written.join(''), `${SECRET}\r`, 'the password reached the program'); + assert.equal(f.screen().includes(SECRET), false); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('adversarial: a question that appears while a line is half-typed in the composer: that line stays a queued command, then the program gets the keys', async () => { + const f = fixture(); + try { + f.begin('sleep 2; read -r reply'); + open(f); + f.type('echo be'); await settled(); + f.report({since: Date.now(), confidence: 'likely', mode: 'line'}); + f.type('gun'); f.enter(); await settled(); + assert.deepEqual(f.adds().map(op => [op.text, op.condition]), [['echo begun', undefined]], 'begun as a queued command, queued as one'); + assert.equal(f.app['queueCompose'], false, 'then the composer closes'); + f.type('hello'); f.enter(); await settled(); + assert.equal(f.written.join(''), 'hello\r', 'the answer goes to the program'); + assert.equal(f.adds().length, 1); + } finally { f.dispose(); } +}); + +test('a look that cannot tell keeps typing held and hidden, composer or not; nothing routes on no evidence', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.looksAnswer('unknown'); + f.begin('sleep 30'); + open(f); + f.type(SECRET); await settled(); + t.mock.timers.tick(5000); await settled(); + assert.equal(f.screen().includes(SECRET), false); + assert.deepEqual(f.adds(), [], 'nothing queued'); + } finally { f.dispose(); } +}); + +test('Ctrl+Q with text queues that line once; Ctrl+Q while a program reads hidden input composes one entry, then closes', async () => { + const f = fixture(); + try { + f.begin('sleep 30'); + f.type('echo later'); await settled(); + f.app['onInput'](CTRL_Q); await settled(); + assert.deepEqual(f.adds().map(op => [op.text, op.typedWhile]), [['echo later', undefined]]); + assert.equal(f.app['queueCompose'], false, 'Ctrl+Q with a line queues it and opens nothing'); + f.complete(0); + f.begin('sudo -k true'); + f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.app['onInput'](CTRL_Q); + assert.equal(f.app['queueCompose'], true, 'composes a queue entry'); + f.type('echo after-sudo'); f.enter(); await settled(); + assert.equal(f.app['queueCompose'], false, 'one entry, then typing returns to the program'); + } finally { f.dispose(); } +}); + +test('a line of several commands is named "the command", never its first word, which may have finished long ago', async () => { + for (const command of ['echo "STEP 1"; sleep 2; echo "STEP 2"', "printf 'ONE\\n'\nsleep 1", 'make && make test']) { + const f = fixture(); + try { + f.begin(command); + f.type('echo next'); + await settled(); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), [], command); + assert.match(f.screen(), /Can.t tell whether the command is reading your typing/u, command); + assert.doesNotMatch(f.screen(), /whether (echo|printf|make) is reading/u, command); + } finally { f.dispose(); } + } +}); + +test('a held line at the head still never holds the prompt (#426); the line runs now', async () => { + const f = fixture(); + try { + f.session.emit('queueState', {entries: [{id: 1, text: 'echo held', addedAt: 1, condition: 'approve', typedWhile: 'sleep'}]}, {}); + await settled(); + f.type('echo now'); + f.enter(); + await settled(); + assert.deepEqual(f.submitted, ['echo now']); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); diff --git a/tests/queueModeLive.test.ts b/tests/queueComposerLive.test.ts similarity index 69% rename from tests/queueModeLive.test.ts rename to tests/queueComposerLive.test.ts index edf6b5ce..3f5eee08 100644 --- a/tests/queueModeLive.test.ts +++ b/tests/queueComposerLive.test.ts @@ -5,22 +5,20 @@ import {existsSync} from 'node:fs'; import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; /** - * Queue Mode through the real composer, PTY and session service: lines entered with Enter while `sleep` runs are - * queued as typed and run after it, once each and in order, with no approval step. A read the shell waits on still - * gets the answer, and a hidden one is never drawn: Queue Mode changes what Enter does for a command nobody reads, - * never who owns typing. + * The queue composer through the real composer, PTY and session service: Ctrl+Q on an empty line while `sleep` runs + * opens it, and the lines entered in it are queued as typed and run after sleep, once each and in order, with no + * approval step. A line zsh cannot finish is cancelled and given back, never left waiting. */ 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, queue: {queueMode: true}}, + 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}); frontend.pty.onData(data => { @@ -45,21 +43,18 @@ async function type(frontend: Frontend, text: string, gapMs = 25): Promise const records = async (sandbox: LiveSandbox) => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); const idle = async (sandbox: LiveSandbox) => { const session = (await sandbox.sessions())[0]; return session?.running === undefined && !session?.queued; }; -const READS: Record = { - zsh: {line: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read -s 'reply?Demo password: '; print; print len=${#reply}"}, - bash: {line: `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read -rs -p 'Demo password: ' reply; echo; echo len=${#reply}"}, - fish: {line: `read QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`, hidden: "read --silent --prompt-str 'Demo password: ' reply; echo len=(string length -- $reply)"}, -}; for (const shell of ['zsh', 'bash', 'fish'] as const) { const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); - test(`live ${shell}: Queue Mode runs lines entered during sleep after it, once each, in order, with no approval`, {skip: available ? false : `${shell} not installed`, timeout: 180_000}, async () => { + test(`live ${shell}: lines entered in the queue composer during sleep run after it, once each, in order, with no approval`, {skip: available ? false : `${shell} not installed`, timeout: 180_000}, async () => { const {sandbox, frontend} = await start(shell); try { const lines = ['echo "STEP 1 — Starting"', 'sleep 1', 'echo "STEP 2 — Halfway"', 'sleep 1', 'echo "STEP 3 — Finished."']; const mark = frontend.mark; frontend.pty.write('sleep 4\r'); await pause(700); + frontend.pty.write('\u0011'); + await frontend.waitFor(/Queueing · each Enter adds/u, mark, 5000); for (const line of lines) { await type(frontend, line); frontend.pty.write('\r'); await pause(150); } await until(async () => (await sandbox.sessions())[0]?.queued === lines.length, 10_000, 'all five lines queued while sleep runs'); await until(() => idle(sandbox), 20_000, 'the queue drained and the prompt is back'); @@ -77,33 +72,6 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { for (const step of ['STEP 1 — Starting', 'STEP 2 — Halfway', 'STEP 3 — Finished.']) assert.equal(printed.split(step).length - 1, 1, `${step} printed once`); } finally { await sandbox.dispose(); } }); - - test(`live ${shell}: Queue Mode still gives a waiting read its answer and never draws a hidden one`, {skip: available ? false : `${shell} not installed`, timeout: 180_000}, async () => { - const {sandbox, frontend} = await start(shell); - try { - for (const reactionMs of [300, 900]) { - const mark = frontend.mark; - await type(frontend, READS[shell].line, 5); - frontend.pty.write('\r'); - await pause(reactionMs); - await type(frontend, 'hello', 60); - frontend.pty.write('\r'); - await frontend.waitFor(/ANSWER=hello/u, mark, 10_000); - await until(() => idle(sandbox), 15_000, `line read after ${reactionMs} ms: back at the prompt`); - assert.equal((await records(sandbox)).some(record => record.command.trim() === 'hello'), false, `after ${reactionMs} ms: the answer never ran as a command`); - } - const mark = frontend.mark; - await type(frontend, READS[shell].hidden, 5); - frontend.pty.write('\r'); - await pause(600); - await type(frontend, SECRET, 60); - frontend.pty.write('\r'); - await frontend.waitFor(/len=8/u, mark, 10_000); - await until(() => idle(sandbox), 15_000, 'hidden read: back at the prompt'); - assert.equal(strip(frontend.output.slice(mark)).includes(SECRET), false, 'the hidden answer is never drawn'); - assert.equal((await records(sandbox)).some(record => record.command.includes(SECRET)), false, 'never run as a command'); - } finally { await sandbox.dispose(); } - }); } test('live zsh: a line zsh cannot finish (`!"` removes its closing quote) is cancelled, explained and given back; never left waiting', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { @@ -124,6 +92,8 @@ test('live zsh: a line zsh cannot finish (`!"` removes its closing quote) is can mark = frontend.mark; frontend.pty.write('sleep 6\r'); await pause(600); + frontend.pty.write('\u0011'); + await frontend.waitFor(/Queueing · each Enter adds/u, mark, 5000); for (const line of ['echo QUEUED-ONE', bang, 'echo QUEUED-AFTER']) { await type(frontend, line); frontend.pty.write('\r'); await pause(150); } await until(async () => (await sandbox.sessions())[0]?.queued === 3, 10_000, 'all three queued while sleep runs'); await until(async () => (await records(sandbox)).some(record => record.command.trim() === 'echo QUEUED-ONE'), 20_000, 'the first queued entry ran'); diff --git a/tests/queueModeOwnershipRaceLive.test.ts b/tests/queueComposerRaceLive.test.ts similarity index 68% rename from tests/queueModeOwnershipRaceLive.test.ts rename to tests/queueComposerRaceLive.test.ts index f1e6584c..499397af 100644 --- a/tests/queueModeOwnershipRaceLive.test.ts +++ b/tests/queueComposerRaceLive.test.ts @@ -6,19 +6,19 @@ import {fileURLToPath} from 'node:url'; import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; /** - * Queue Mode under #514's ownership race, on a real PTY: a prompt-less `read` that NMSh cannot recognize yet (the - * fixture holds back only the evidence, never the shell) gets an immediate reply and Enter. An echoing terminal is no - * evidence of a reader, and Queue Mode queues lines without approval, so the reply reaches the queue as a command. - * Once the read is seen, that entry waits for approval: when the read gives up and its command succeeds, the reply has - * not run, and never runs by itself. + * The queue composer under #514's ownership race, on a real PTY: a prompt-less `read` that NMSh cannot recognize yet + * (the fixture holds back only the evidence, never the shell). The person opens the queue composer and types a reply + * into it anyway: the composer queues what is typed in it without approval, so the reply reaches the queue. Once the + * read is seen, the owner holds that entry for approval: when the read gives up and its command succeeds, the reply + * has not run, and never runs by itself. (A reader NMSh never sees before it ends is the documented limit.) */ const supported = process.platform === 'darwin' || process.platform === 'linux'; const entry = fileURLToPath(new URL('./fixtures/ownershipRaceEntry.mjs', import.meta.url)); const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); for (const reactionMs of [0, 300]) { - test(`live zsh, Queue Mode: a reply entered ${reactionMs}ms into a read NMSh could not see yet never runs as a command`, {skip: !supported, timeout: 90_000}, async t => { - const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', queue: {queueMode: true}}); + test(`live zsh, queue composer: a reply entered ${reactionMs}ms into a read NMSh could not see yet never runs as a command`, {skip: !supported, timeout: 90_000}, async t => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); const gate = join(sandbox.home, 'ownership-gate'); const frontend = sandbox.launch([], {cols: 120, rows: 36}, {NMSH_TEST_OWNERSHIP_GATE: gate}, entry); const trace = () => existsSync(gate + '.jsonl') ? readFileSync(gate + '.jsonl', 'utf8').trim().split('\n').filter(Boolean) : []; @@ -30,8 +30,10 @@ for (const reactionMs of [0, 300]) { const mark = frontend.mark; frontend.pty.write(command + '\r'); await pause(reactionMs); + frontend.pty.write('\u0011'); + await frontend.waitFor(/Queueing · each Enter adds/u, mark, 5000); frontend.pty.write('hello\r'); - await until(async () => (await sandbox.sessions())[0]?.queued === 1, 5000, 'Queue Mode queued the unrecognized reply'); + await until(async () => (await sandbox.sessions())[0]?.queued === 1, 5000, 'the composer queued the unrecognized reply'); assert.ok(trace().some(line => line.includes('"sample"')), 'the control exercised real probe observations'); writeFileSync(gate + '.release', ''); await until(async () => (await sandbox.sessions())[0]?.inputMode === 'line', 10_000, 'the read is seen once the evidence is released'); @@ -44,12 +46,11 @@ for (const reactionMs of [0, 300]) { assert.equal(records.some(r => r.command.trim() === 'hello'), false, 'the reply never ran as a command'); assert.equal((await sandbox.sessions())[0]?.queued, 1, 'it waits in the queue'); assert.match(strip(frontend.output.slice(mark)), /waits? for your approval/u, 'and says why'); - // In Queue Mode the next line goes behind it; nothing releases the held reply but the person. - frontend.pty.write('echo AFTER\r'); - await until(async () => (await sandbox.sessions())[0]?.queued === 2, 5000, 'the next line queued behind the held reply'); - await pause(1500); + // The composer closed with its command; a command at the prompt runs now, and the held reply still waits. + await frontend.run('echo AFTER', /AFTER/u); const after = (await sandbox.transcripts().list()).flatMap(s => s.transcript.records).map(r => r.command.trim()); - assert.equal(after.includes('hello') || after.includes('echo AFTER'), false, 'neither ran by itself'); + assert.equal(after.includes('hello'), false, 'the reply never ran by itself'); + assert.equal((await sandbox.sessions())[0]?.queued, 1, 'it still waits for approval'); } finally { t.diagnostic(JSON.stringify({reactionMs, trace: trace().length})); await sandbox.dispose(); } }); } diff --git a/tests/queueMode.test.ts b/tests/queueMode.test.ts deleted file mode 100644 index b7807c15..00000000 --- a/tests/queueMode.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -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 {parseSlashCommand} from '../src/commands/slashCommands.js'; -import {stripAnsi} from '../src/util/text.js'; -import {executionFrameFixture} from './support/executionTimelineFrames.js'; - -/** - * Queue Mode: an explicit, per-window choice that Enter queues while a command runs. A line entered then is the - * person's next command, shown as typed and run after the current one without an approval step, as Ctrl+Q does. - * Input ownership is unchanged: typing is still looked at before it is drawn, a program NMSh sees waiting for input - * gets Enter, hidden input is never drawn or queued, and a look that cannot tell keeps the typing held. - * - * Deterministic: every key goes through the real TerminalApp key handler; looks answer as the test says. - */ - -const SECRET = 'QSECRETQ'; -const CTRL_Q = '\u0011'; - -function fixture(queueMode = false) { - const frame = executionFrameFixture(110, 30); - frame.app['promptConfiguration'].queue.queueMode = queueMode; - const written: string[] = []; - const queueOps: QueueOp[] = []; - Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, 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}); - const submitted: string[] = []; - Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); - let looks = 0; - const looksAnswer = (result: LookResult | undefined) => - Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return result; }, configurable: true}); - looksAnswer('visible'); - const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; - const enter = () => frame.app['onInput']('\r'); - const adds = () => queueOps.filter((op): op is Extract => op.op === 'add'); - 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'); - const report = (request: NonNullable) => - frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); - return {...frame, written, queueOps, submitted, looksAnswer, type, enter, adds, screen, transcript, report, looks: () => looks}; -} - -const settled = async () => { for (let i = 0; i < 4; i += 1) await new Promise(done => setImmediate(done)); }; - -test('/queue mode parses on, off and toggle; other /queue words are unchanged', () => { - assert.deepEqual(parseSlashCommand('/queue mode'), {kind: 'queueMode'}); - assert.deepEqual(parseSlashCommand('/queue mode on'), {kind: 'queueMode', value: true}); - assert.deepEqual(parseSlashCommand('/q mode off'), {kind: 'queueMode', value: false}); - assert.deepEqual(parseSlashCommand('/queue pause'), {kind: 'queue', op: 'pause'}); - assert.equal(parseSlashCommand('/queue mode maybe')?.kind, 'unknown'); -}); - -test('off (the default): a line entered while sleep runs is never queued by Enter; it waits for an explicit choice (#514)', async () => { - const f = fixture(); - try { - f.begin('sleep 30'); - assert.match(f.screen(), /Enter checks who reads it · Ctrl\+Q runs it after/u); - f.type('echo one'); - await settled(); - f.enter(); - await settled(); - assert.deepEqual(f.adds(), [], 'an echoing terminal is no evidence: nothing is queued or sent'); - assert.match(f.screen(), /Can.t tell whether sleep is reading your typing/u); - } finally { f.dispose(); } -}); - -test('on: lines entered while sleep runs are queued as typed, in order, with no approval step; the composer says so', async () => { - const f = fixture(true); - try { - f.begin('sleep 30'); - assert.match(f.screen(), /Queue Mode · Enter queues it to run next · Ctrl\+S sends to sleep/u); - const lines = ['echo "STEP 1 — Starting"', 'sleep 2', 'echo "STEP 2 — Halfway"', 'sleep 2', 'echo "STEP 3 — Finished!"']; - for (const line of lines) { - f.type(line); - await settled(); - f.enter(); - await settled(); - } - assert.deepEqual(f.adds().map(op => op.text), lines, 'each line once, in the order entered'); - assert.equal(f.adds().some(op => op.condition !== undefined || op.typedWhile !== undefined), false, 'none waits for approval'); - assert.deepEqual(f.written, [], 'nothing reached sleep'); - assert.deepEqual(f.submitted, [], 'nothing ran early'); - assert.doesNotMatch(f.screen(), /typed while|Awaiting approval/u); - } finally { f.dispose(); } -}); - -test('on: a program NMSh sees waiting for a line still gets Enter; nothing is queued', async () => { - const f = fixture(true); - try { - f.begin('read -r reply'); - f.report({since: Date.now(), confidence: 'likely', mode: 'line'}); - f.type('hello'); - f.enter(); - await settled(); - assert.equal(f.written.join(''), 'hello\r', 'the answer went to the program'); - assert.deepEqual(f.adds(), []); - } finally { f.dispose(); } -}); - -test('on: hidden input is never drawn, queued or run, whether it was reported or only seen by the look', async () => { - for (const how of ['reported', 'looked'] as const) { - const f = fixture(true); - try { - f.begin('sudo -k true'); - if (how === 'reported') f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); - else f.looksAnswer('hidden'); - f.type(SECRET); - await settled(); - if (how === 'looked') f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); - await settled(); - f.enter(); - await settled(); - assert.equal(f.screen().includes(SECRET), false, `${how}: not on screen`); - assert.equal(f.transcript().includes(SECRET), false, `${how}: not in the transcript`); - assert.equal(f.adds().some(op => op.text.includes(SECRET)), false, `${how}: not queued`); - assert.equal(f.submitted.some(text => text.includes(SECRET)), false, `${how}: not run`); - } finally { f.dispose(); } - } -}); - -test('on: a look that cannot tell keeps the typing held and hidden; Queue Mode never routes on no evidence', async t => { - const f = fixture(true); - try { - t.mock.timers.enable({apis: ['setTimeout']}); - f.looksAnswer('unknown'); - f.begin('sleep 30'); - f.type(SECRET); - await settled(); - f.enter(); - await settled(); - assert.match(f.screen(), /Can't tell whether sleep is reading your typing/u); - assert.equal(f.screen().includes(SECRET), false); - assert.deepEqual(f.adds(), [], 'nothing queued'); - } finally { f.dispose(); } -}); - -test('on: a line whose command ended while its keys were held is queued as typed, not held for approval', async () => { - const f = fixture(true); - try { - let answer: (result: LookResult) => void = () => {}; - Object.defineProperty(f.session, 'lookInput', {value: () => new Promise(resolve => { answer = resolve; }), configurable: true}); - f.begin('ls'); - f.type('echo after'); - f.enter(); - f.complete(0); - answer('idle'); - await settled(); - assert.deepEqual(f.adds().map(op => [op.text, op.condition, op.typedWhile]), [['echo after', undefined, undefined]]); - } finally { f.dispose(); } -}); - -test('Ctrl+Q on an empty line toggles Queue Mode for this window only; /queue mode sets it; Settings are not written', async () => { - const f = fixture(); - try { - f.app['onInput'](CTRL_Q); - assert.equal(f.app['queueModeOn'](), true); - assert.match(f.transcript(), /Queue Mode on/u); - assert.match(f.screen(), /Queue Mode · while a command runs, Enter queues/u, 'the empty composer says so at the prompt'); - assert.equal(f.app['promptConfiguration'].queue.queueMode, false, 'the default in Settings is unchanged'); - f.app['onInput'](CTRL_Q); - assert.equal(f.app['queueModeOn'](), false); - await f.app['runSlash']('/queue mode on', parseSlashCommand('/queue mode on')!); - assert.equal(f.app['queueModeOn'](), true); - await f.app['runSlash']('/queue mode on', parseSlashCommand('/queue mode on')!); - assert.equal(f.app['queueModeOn'](), true, 'on stays on'); - await f.app['runSlash']('/queue mode off', parseSlashCommand('/queue mode off')!); - assert.equal(f.app['queueModeOn'](), false); - assert.deepEqual(f.adds(), [], 'toggling queues nothing'); - } finally { f.dispose(); } -}); - -test('Ctrl+Q with text still queues that line; Ctrl+Q while a program reads hidden input composes a queue entry, never a toggle', async () => { - const f = fixture(); - try { - f.begin('sleep 30'); - f.type('echo later'); - await settled(); - f.app['onInput'](CTRL_Q); - await settled(); - assert.deepEqual(f.adds().map(op => [op.text, op.typedWhile]), [['echo later', undefined]]); - assert.equal(f.app['queueModeOn'](), false, 'Ctrl+Q with a line queues it, it toggles nothing'); - f.complete(0); - f.begin('sudo -k true'); - f.report({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); - f.app['onInput'](CTRL_Q); - assert.equal(f.app['queueCompose'], true, 'composes a queue entry'); - assert.equal(f.app['queueModeOn'](), false); - } finally { f.dispose(); } -}); - -test('a line of several commands is named "the command", never its first word, which may have finished long ago', async () => { - for (const command of ['echo "STEP 1"; sleep 2; echo "STEP 2"', "printf 'ONE\\n'\nsleep 1", 'make && make test']) { - const f = fixture(); - try { - f.begin(command); - f.type('echo next'); - await settled(); - f.enter(); - await settled(); - assert.deepEqual(f.adds(), [], command); - assert.match(f.screen(), /Can.t tell whether the command is reading your typing/u, command); - assert.doesNotMatch(f.screen(), /whether (echo|printf|make) is reading/u, command); - } finally { f.dispose(); } - } -}); - -test('on: at the prompt, a line never runs ahead of entries already waiting; a paused queue gives the prompt back', async () => { - const held = {id: 1, text: 'echo held', addedAt: 1, condition: 'approve' as const, typedWhile: 'sleep'}; - const queued = {id: 2, text: 'sleep 2', addedAt: 2}; - for (const [state, expectRun, hint] of [ - [{entries: [held, queued]}, false, /Queue Mode · Enter adds it after 2 waiting/u], - [{entries: [queued]}, false, /Queue Mode · Enter adds it after 1 waiting/u], - [{entries: [queued], paused: {reason: 'failed' as const, at: 3, command: 'false', exitCode: 1}}, true, /queue paused, 1 waiting · Enter runs it now/u], - ] as const) { - const f = fixture(true); - try { - f.session.emit('queueState', state, {}); - await settled(); - assert.match(f.screen(), hint); - f.type('echo "STEP 3"'); - f.enter(); - await settled(); - if (expectRun) { - assert.deepEqual(f.submitted, ['echo "STEP 3"'], 'a paused queue: the line runs now'); - assert.deepEqual(f.adds(), []); - } else { - assert.deepEqual(f.submitted, [], 'nothing runs ahead of the waiting entries'); - assert.deepEqual(f.adds().map(op => [op.text, op.condition, op.typedWhile]), [['echo "STEP 3"', undefined, undefined]], 'added after them, as typed'); - } - } finally { f.dispose(); } - } -}); - -test('off: a held line at the head still never holds the prompt (#426); the line runs now', async () => { - const f = fixture(false); - try { - f.session.emit('queueState', {entries: [{id: 1, text: 'echo held', addedAt: 1, condition: 'approve', typedWhile: 'sleep'}]}, {}); - await settled(); - f.type('echo now'); - f.enter(); - await settled(); - assert.deepEqual(f.submitted, ['echo now']); - assert.deepEqual(f.adds(), []); - } finally { f.dispose(); } -}); diff --git a/tests/queueReplyHold.test.ts b/tests/queueReplyHold.test.ts index a0c82912..93263e2f 100644 --- a/tests/queueReplyHold.test.ts +++ b/tests/queueReplyHold.test.ts @@ -5,7 +5,7 @@ import {QueueDispatcher} from '../src/session/QueueDispatcher.js'; /** * #514 race, owner side: an Enter pressed before NMSh could see that the command reads (an echoing terminal is no - * evidence) can reach the queue as a command, with no approval in Queue Mode. Once the command is reported asking for + * evidence) can reach the queue as a command: Ctrl+Q and the queue composer add lines without approval. Once the command is reported asking for * input, everything queued while it ran waits for approval. The owner decides it beside the prompt, so a reader that * ends right after it was seen can never let the next dispatch run its reply. */ diff --git a/tests/queueSettings.test.ts b/tests/queueSettings.test.ts index 2656e2ec..67054976 100644 --- a/tests/queueSettings.test.ts +++ b/tests/queueSettings.test.ts @@ -30,7 +30,7 @@ test('queue presentation defaults match the approved spec: timeline on, two resu assert.deepEqual(DEFAULT_PROMPT_CONFIGURATION.queue, {liveTimeline: true, recentResults: 2, upcoming: 2, transient: true, transientSeconds: 8, outputDisplay: 'inline', autoSaveSummary: true, summaryDetail: 'detailed', summaryPlacement: 'center', summaryAlign: 'left', - summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false, queueMode: false}); + summaryDurations: true, summaryExitCodes: true, summaryInputWaits: false}); assert.deepEqual(normalizePromptConfiguration({}).queue, {...DEFAULT_PROMPT_CONFIGURATION.queue, autoSaveSummary: false}, 'an existing configuration keeps the defaults'); });