diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d436fb..e0ad5543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Input awareness:** a command that waits for an answer is no longer just "Running". NMSh reads the terminal's own state, without touching the program. A password or other no-echo read, or a single-key question such as `e2fsck`'s `Fix?`, is confirmed; on Linux, any blocking read of the terminal is confirmed too. An ordinary line question left open by an idle process is shown as "Probably waiting for input". The question stays visible beside the composer however far the transcript has scrolled, and the window title, `/sessions`, `/resume` and a cross-session notice show the wait. Hidden and single-key input goes straight to the program, keystroke by keystroke, and is never shown or kept. A reply left unsent when the command ends is discarded, never run as a shell command. Waiting time is measured separately from wall-clock time and reported on completion. See `docs/design/input-awareness.md`. + +### Fixed +- **Single-key prompts:** answering a single-key question through the composer no longer sends a stray Enter that answered the next question with its default. +- **Passwords:** text typed at a password prompt is no longer drawn in the composer. + + ## [0.18.0] - 2026-10-13 **Context Engine, Managed Agents & Workspaces.** Context modules become a platform: facts are collected on demand by bounded, privacy-labelled capabilities, the first-party catalog ships as declarative Context Packs, and the Status Strip becomes a full second surface for modules. Claude Code can run as a managed target inside NMSh, with its permission requests and questions answered natively. `/worktrees` and a read-only `/github` workspace are the first delivered slices of the workspace work. The release also carries the fixes from the v0.17 Ghostty physical-QA pass and a set of passthrough, tmux, transcript and Fish correctness fixes. diff --git a/docs/architecture/mise-project-awareness.md b/docs/architecture/mise-project-awareness.md index c0d55139..083e1f00 100644 --- a/docs/architecture/mise-project-awareness.md +++ b/docs/architecture/mise-project-awareness.md @@ -19,8 +19,9 @@ command and 128 KiB combined stdout/stderr limit. stderr is discarded; failures are generic. JSON parsing retains tool names/versions and task names only. Unknown fields, including `env`, `run`, descriptions and source paths, never enter UI/cache/persistence. Metadata remains in memory, bounded to 32 identities -keyed by canonical cwd, binary and ancestor marker/project stat identity. Config -changes invalidate an identity; explicit refresh reruns inspection. No typing or +keyed by canonical cwd, binary and ancestor marker/project stat identity. Changes to marker filesystem metadata invalidate an identity. A same-size edit +with unchanged timestamps can retain it; NMSh does not read config content to +detect this. Explicit refresh requires renewed consent and reruns inspection. No typing or rendering invocation exists. A failed inspection is cached factually. Task Enter inserts a quoted visible `mise run ''` into the composer. A @@ -39,7 +40,8 @@ Task listing may fetch remote task files and evaluates resolved task directories The consent screen therefore does not describe metadata as side-effect-free. Standard marker coverage is conservative, not a full reimplementation of mise -config selection: `mise.toml`, `.mise.toml`, `.mise/config.toml`, +config selection: `mise.toml`, `.mise.toml`, `mise.local.toml`, `.mise.local.toml`, +`.mise/config.toml`, `.config/mise/config.toml`, `.tool-versions`. Environment-specific/custom config may exist without a detected marker. Metadata inspection remains explicitly available when installed. There is no automatic task argument editor, active diff --git a/docs/architecture/shell-adapter.md b/docs/architecture/shell-adapter.md index c6e5bf2c..9cc79f64 100644 --- a/docs/architecture/shell-adapter.md +++ b/docs/architecture/shell-adapter.md @@ -21,7 +21,7 @@ ZshAdapter FishAdapter BashAdapter | Member | Purpose | |---|---| | `launch(context)` | Write private bootstrap files into a per-session directory and return argv + env. The user's own startup files load first. | -| marker grammar | Every adapter emits the same authenticated OSC 777 markers: `;` when a command finished (or the shell became ready) and `exec2;<0\|1>;` before a command runs. `ShellProtocolDecoder` is shared. | +| marker grammar | Every adapter emits the same authenticated OSC 777 markers: `;` when a command finished (or the shell became ready) and `exec2;<0\|1>;` before a command runs. Fish also emits `reader` from `fish_read` before builtin input starts. `ShellProtocolDecoder` is shared. | | `editorChrome` | `none` (zsh with ZLE off, Bash `--noediting`): everything after readiness is output. `prompt-to-exec` (Fish): bytes between readiness and exec are Fish's own line editor redrawing, and are dropped. | | `scrubCommandStart`, `answerQueries` | Fish turns bracketed paste off as it hands the terminal to a command; that byte sequence is removed from the start of command output so it can never reach the host in passthrough. Fish 4 waits for a primary-device-attributes reply while idle; NMSh, being its terminal, answers minimally. | | `capabilities` | What the backend can honestly provide: completion richness and descriptions, live names, history import, the shell's private-history rule, job counting. The frontend degrades on absent capabilities instead of faking them. | diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md new file mode 100644 index 00000000..19771bb6 --- /dev/null +++ b/docs/design/input-awareness.md @@ -0,0 +1,150 @@ +# Input awareness: when a command needs you + +A running command that waits for an answer (`e2fsck`'s `Fix?`, a password, `rm -i`, a `read` prompt) used to look +like any other running command: `Running · 30s`. NMSh now tells the two apart, shows the question where it stays in +view, routes typing the way the program reads it, and keeps waiting time separate from execution time. + +Verified on macOS 27 (zsh 5.9, Bash 5.3, Fish 4.9) on 2026-10-10. The Linux kernel layer is implemented and unit-tested +against the kernel's documented `/proc` formats, but it has not been run on a Linux host in this change. + +## Principles + +- **NMSh stays a frontend over the real shell.** It reads the terminal's own state and the process table. It never + pauses, signals or writes to a program to find out, and it never answers for you. +- **One authoritative state.** `InputWatch` runs beside the PTY: in the session service, or in-process when there is + no service. It publishes one `InputState` that every surface renders: the live activity line, the composer, the + window title, `/sessions`, `/resume` and cross-session notices. No surface infers anything on its own. +- **Silence is not evidence.** A quiet process might be sleeping, computing or waiting on the network. A request needs + positive evidence, and its confidence is shown in words. + +## What NMSh can know, and how + +| Layer | Signal | Platforms | Confidence | +|---|---|---|---| +| Kernel | A foreground thread is blocked in `read()` on this terminal (`/proc/PID/task/TID/syscall` plus the fd's link). If every thread is blocked elsewhere, that vetoes the other layers. | Linux | confirmed | +| Terminal line settings | `stty -a` on the PTY device: echo off with line input on means a hidden line (password). Line input off with a prompt left open on the cursor's line means single keys (`e2fsck`, `read -k`, `read -n1`). | macOS, Linux | confirmed | +| Prompt evidence | A question-shaped line left open (ends in `?`, `:`, `]`, `)`, `>`), written once and never rewritten or cursor-drawn, output quiet for 0.7 s or more, and the foreground group idle across two samples (nothing running on a CPU, no CPU time gained). | macOS, Linux | likely | + +Raw mode alone is never a request: spinners and terminal proxies (`docker run -it`) set it without asking anything. A +program that owns the terminal is never reported, because it draws and reads its own interface. That covers the +alternate screen, bracketed paste, mouse or focus reporting, the kitty keyboard protocol, and NMSh's known full-screen +commands. + +**Reliable:** password and other no-echo reads (`sudo`, `ssh`, `getpass`, `read -s`); single-key questions (`e2fsck`, +`read -k1`, `read -n1`, menu libraries that read keys); on Linux, any blocking read of the terminal, including prompts +that end with a newline. + +**Inferred (shown as "Probably waiting for input"):** ordinary line reads on macOS, such as `read -p`, `rm -i`, Python +`input()` and `apt`'s `[Y/n]`. macOS exposes no wait channel without privileges (`ps -o wchan` is empty for every +process), so the open question plus an idle process is the best available evidence. + +**Not detected generically:** +- Line reads on macOS whose prompt ends with a newline, or that print no prompt at all. +- Programs reading through `poll`/`select` on macOS. +- Programs that own the terminal. Fish 4's own `read` is one: it is fish's line editor, with bracketed paste and OSC 133 + marks, so NMSh hands it the terminal and fish answers natively. It is not reported as waiting. + +In all of these cases NMSh shows "Running", as before. + +**False-positive guards:** +- **Builds:** busy CPU or a process in state `R`. +- **`sleep`, network waits, background tasks:** no question left open. +- **Progress bars:** a carriage-return rewrite marks the line unreliable. +- **Status lines:** `Downloading...` is not question-shaped, and cursor-drawn lines are ignored. +- **Log streams:** no open line, and probes are rate-limited. +- **Questions printed as ordinary output:** later output ends the wait at once. +- **Interactive full-screen or inline programs:** they own the terminal. + +### Cost + +Probes start only after a running command's output goes quiet: +- **First look:** 150 ms after quiet with a line left open, 400 ms otherwise. +- **Then:** at 1 s and 2.5 s, every 3 s, and every 10 s after a minute of quiet. +- **Closed line:** at most one look every 2 s. + +Each look runs `/bin/stty -a` and `/bin/ps` (plus `/proc` reads on Linux) with fixed arguments and a 1.5 s timeout. A +probe that started before newer output is discarded. + +## States and time + +- **Running:** no credible request. The line keeps its shimmer and adds `· waited 18s` once the command has waited. +- **Waiting for input** (◆, accent and weight) or **Probably waiting for input** (◇): the attention line plus the open + question. +- **Running again:** after an answer (a key in key mode, Enter otherwise, or Ctrl+C or Ctrl+D), or when the program + writes again. +- **Completed, failed or interrupted:** unchanged. + +`durationMs` stays the whole wall-clock time. Waiting is measured separately: +- **Start:** when the program wrote the prompt it waits on. +- **End:** at the answer, at new output, or at completion. +- **Evidence that simply disappears:** if a read times out silently, the wait ends at the look that saw it gone, so + that one boundary is accurate to within a probe interval. + +Intervals are summed per command and carried with the completing prompt (`inputWaitMs`, `inputWaits`), so they survive +detach and replay. They are recorded on the transcript record, and the completion row reads +`✔ Completed · 1m 25s · 18s waiting for input (2 prompts) · 15:42`. Active time is not shown: waiting is the only +boundary NMSh observes. + +## Presentation + +The activity region already reserves two rows while a command runs (the working line and a spacer). A wait uses both, +so nothing new is reserved and the PTY never resizes: + +``` +◆ Waiting for input · e2fsck · 18.0s · 1m 40s total + Padding at end of inode bitmap is not set. Fix? · each key goes straight to e2fsck +❯ Keys go straight to e2fsck +``` + +- **The question stays in view** however far the transcript has scrolled or folded. +- **Narrow widths:** the line drops detail first (total time, then program name); the question outranks the hint. +- **No animation:** no shimmer and no flashing. The timer only counts. +- **NO_COLOR:** keeps the weight and the glyph. +- **Safe glyphs:** `!` for confirmed and `?` for likely. +- **Window title:** reads `◆ e2fsck needs input · project`, so a background tab shows it. + +## Input ownership + +- **Line input** (confirmed or likely): the composer is the line editor, as before. Enter sends the line to the running + program, never to the shell. A reply that is still unsent when the command ends is discarded with a note. It never + runs as the next command. +- **Hidden lines and single keys** (confirmed): each key goes straight to the program as the bytes a plain terminal + sends, so line-discipline editing, Enter-as-default and no-echo all behave natively. + - The composer shows only where typing goes. A draft typed before the question appeared stays hidden and untouched. + - Keys typed after the question appeared, in the moment before NMSh knew how the program reads, are handed over as a + terminal would have delivered them. + - Ctrl+C, Ctrl+D and Ctrl+Z keep their meaning, and PageUp/PageDown and the wheel still scroll the transcript. + - 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. + +## Other sessions + +The service is the one owner of every session's state: +- **`/sessions` and `/resume`** lead the row with `◆ Waiting for input` and the question. +- **Cross-session notice:** a wait that lasts 3 s becomes one sticky notice in other windows, for example + `Mango · e2fsck is waiting for input · 2m · /resume`. It is withdrawn when the wait ends or the session is focused. +- **Returning:** go back through `/resume`, which attaches. Attaching shows the waiting question; nothing is answered + automatically. +- **Older frontends:** the frontend lists `input-state` in its hello. An older frontend gets neither the new message + nor the new notice kind, so it never rejects a frame. + +## Managed agents + +Managed Claude sessions (`/claude`) do not run in a PTY. Their permission requests and questions arrive as structured +events, and the agent view already marks them with the same ◆ attention. Input awareness never parses an agent's +screen. A coding agent run as a shell command (`claude`, `codex`) owns the terminal, so NMSh hands it over and reports +it through its own attention signals (bell, OSC 9/777), not as "waiting for input". + +## Files + +- `src/session/InputWatch.ts`: the decision and timing (one per session, beside the PTY). +- `src/session/terminalProbe.ts`: read-only `stty`, `ps` and `/proc` facts. +- `src/session/openLine.ts`: the open line and the prompt shape. +- `src/session/inputState.ts`: the shared state. +- Protocol, service and clients: `SessionProtocol.ts` (`input-state`, `CLIENT_FEATURES`), `SessionService.ts`, + `SocketSessionClient.ts`, `InProcessSessionClient.ts`. +- `src/status/inputStatus.ts`: wording and painting. +- `src/terminal/keyBytes.ts`: direct key bytes. +- Tests: `tests/inputAwareness.test.ts` (unit) and `tests/inputAwarenessLive.test.ts` (real PTY: zsh, Bash, Fish, detach + and reattach, other windows, narrow, NO_COLOR and Safe). The harmless e2fsck-style fixture is `tests/fixtures/askyn.py`. diff --git a/docs/development-verification.md b/docs/development-verification.md index 003b34d3..bc31082b 100644 --- a/docs/development-verification.md +++ b/docs/development-verification.md @@ -100,3 +100,17 @@ Final shard runs passed in 52.2s and 79.9s with four workers. Every shard and re all assertions and budgets. Higher local fan-out exposed intermittent existing completion, resume-browser latency and terminal-mode timing failures; focused reruns and final bounded-worker verification passed without assertion changes. + +### Explicit temporary storage + +For verification on another disk, set `NMSH_TEST_TMPDIR` to an existing short +absolute directory on a filesystem supporting Unix sockets and permissions. +The runner creates and cleans its private `nt-*` directories there. Test children +receive a private home inside that root so shell completion caches and other +home-relative test artifacts cannot write to the host home. An invalid +directory fails instead of falling back to the system disk. Keep paths short: +nested PTY/session socket names still have the operating system's length limit. + +Set `TMPDIR`, `TMP` and `TEMP` to that directory too for launchers and benchmark +helpers. Point npm and tool caches at the same disk when needed. Default CI +storage and all verification gates remain unchanged. diff --git a/docs/development/fish-typeahead.md b/docs/development/fish-typeahead.md index 2f34e9d9..d087b674 100644 --- a/docs/development/fish-typeahead.md +++ b/docs/development/fish-typeahead.md @@ -1,5 +1,36 @@ # Fish keystroke loss after `read` +## Fish 3.7 input ownership (2026-10-11) + +The published queue head `7fb6b5a` fails both Fish read tests on Ubuntu 24.04 / Fish 3.7.0: +`tests/inputAwarenessLive.test.ts` (read handover) and `tests/typingAfterRead.test.ts`. Reproduced in a disposable +Linux arm64 VM with Node 26.10.0, independently of the Ubuntu amd64 Actions run `38071796100`. The answer remains +in NMSh's composer or queue while Fish's reader waits. This is a routing bug, not the Fish 4 type-ahead limit below. + +Fish 3.7's `__fish_config_interactive.fish` explicitly does not enable bracketed paste in `read`; its line editor +therefore does not supply the terminal-mode signal the frontend previously used for handover. Process probes and +an open-line heuristic cannot reliably recognize a reader that redraws its prompt. `answersByDefault` was not the +cause: it correctly keeps ordinary commands in the queue when no input request is known. + +The adapter now uses Fish's own `fish_read` event (also emitted for a read inside a function) to send an authenticated +private `reader` marker. The session broadcasts terminal ownership before forwarding the prompt; the frontend +hands over the terminal until Fish returns to its command prompt. This also covers hidden reads and `read; cat` +without enabling extra terminal modes. Detached sessions retain this ownership for reattach. The marker never +enters raw program output, transcript content or clipboard text. + +Regressions cover authentication and split markers, ownership lifecycle and serialization, real hidden-reader +detach/reattach, and input after completion. The typing test builds a unique prompt for each iteration: a historical +redraw of an earlier prompt must not authorize sending an answer to the current command. + +Separate CI findings: multiline submission must use Fish's `begin`/`end`, rather than the brace group accepted by +Fish 4 and POSIX shells. The Ctrl+C survivor fixture must wait until its trap is installed, use a shell supporting +`read -t` (Ubuntu `/bin/sh` is dash), and remain alive until the assertion releases it. These corrections preserve +the same-shell queue and test actual routing rather than timing a fixed sleep. + +Physical Ghostty/Terminal.app interaction remains unvalidated. The following investigation concerns Fish 4 only. + +## Historical Fish 4 investigation + Investigated on 2026-10-10 with fish 4.9.3, zsh 5.9 and Bash 5.3 on macOS 27. ## Symptom diff --git a/docs/development/workflow-fullscreen-investigation.md b/docs/development/workflow-fullscreen-investigation.md new file mode 100644 index 00000000..9d8eac2a --- /dev/null +++ b/docs/development/workflow-fullscreen-investigation.md @@ -0,0 +1,102 @@ +# Fullscreen reattach investigation + +Coordination: [#359](https://github.com/raiseCatError/notMyShell/issues/359). +The frontend freeze remains pending. This diagnostic branch is additive above +Report `648002fcfb26b64b84718d0d386ae86dbe271dd1`; it is not a production fix or +an accepted feature head. + +[Actual Report CI 38405554008](https://github.com/raiseCatError/notMyShell/actions/runs/38405554008) +failed Ubuntu shard 1/2. The real fullscreen fixture timed out waiting for +`REDRAW 33 90` at its unchanged deadline; trap output reported 29×90 and 28×90. +The frontend remained alive. Other applicable jobs and Vim/less repaint passed. +Local canonical verification of the exact Report head passed. These results do +not establish the failed-run cause. + +## Failure-only observations + +`tests/helpers/fullscreenFailure.ts` runs only after the original fullscreen +assertion has already failed, before sandbox cleanup. It appends its observations +and rethrows the original failure. No assertion, deadline or production source +changes. Diagnostic controls call the helper directly to validate it; their +successful observations do not attribute the historical failed run. + +The helper validates the recorded session ID and backend PID against the private +live registry. It observes the backend and outer frontend PTY sizes with bounded, +read-only `stty size` calls using validated slave paths. It retains session state, +fullscreen/running-presence flags, bounded descendant process state and, on Linux, +foreground process group, wait channel and signal masks. Every subprocess shares +a five-second diagnostic budget with a one-second individual maximum. Missing, +failed, timed-out or unsupported observations remain explicit. + +It sends no input, resize or signal. It records no environment, command arguments +or program output. PID discovery is limited to descendants of the validated owned +backend. The existing sandbox lifecycle still owns teardown. The focused source review found no material issue. On macOS arm64 / official +Node 26.11.1, one worker and private T7 roots, probe identity/geometry controls +and unchanged real lifecycle tests passed. The full `npm run verify` gate +passed afterward, including build, canonical runtime/ranking, cleanup and diff +checks. These passing observations do not attribute the historical CI failure. +Linux arm64 probe/lifecycle validation also passed. The isolated diagnostic +head `50ba4dc853cdbe2ca1b29575b22f1f22f3ef7b15` completed +[comprehensive CI 38421963998](https://github.com/raiseCatError/notMyShell/actions/runs/38421963998): +all required platform, Node 22, Fedora, timing and quality checks passed. Attempt +1 lacked a macOS hosted runner and produced no steps/log; GitHub explicitly +reported runner acquisition failure and arm64 capacity constraints. Only that +unexecuted job was retried once. This is diagnostic evidence, not acceptance of +the failed original Report head. + +## Interpretation + +- A final backend size of 29 rows points toward a missing or overwritten full-size + resize. A subsequent bounded phase trace should locate that transition before + proposing a production fix. +- A final backend size of 33 rows points toward WINCH delivery or trap sampling. + The repaint assertion still fails; geometry alone does not prove repaint. +- A passing observed run does not clear the failed original head or prove a flake. + No unobserved rerun is used to accept the known failing gate. + +The WINCH fixture invokes an external `stty` from a zsh trap; its output samples +geometry rather than recording each applied resize. Signal/trap timing is a +hypothesis, not an established cause. Earlier startup/current-owner corrections +have independent failing-before/passing-after controls and are retained. They +are not claimed as retrospective attribution for this failure. + +Full latest Linux/Fish, remaining published-head CI, security acceptance and +physical terminal/clipboard QA remain separate requirements. No integration or +release/master merge is authorized by this investigation. + +## Confirmed fixture correction + +A standalone Linux control showed the original zsh WINCH trap printing only an +older size while the final PTY was independently verified at 33×90. Signal state +showed WINCH blocked and pending while the trap's external `stty` sample was held. +The synchronized real-NMSh control confirmed the same defect after reattach: +hold a 32-row sample, resize to 33 rows, verify both PTYs, mark the new output +window, then release. The original trap missed the fresh 33-row repaint at its +unchanged 20-second deadline. A trap-set flag consumed in the main loop repainted +32 rows and then the current 33 rows under the same conditions. + +The durable regression is `tests/fullscreenResizeSampling.test.ts`, sharing the +actual acceptance fixture with `tests/sessionLifecycle.test.ts`. It delegates +non-size termios operations to real `stty`, holds only an explicitly armed older +sample, verifies the current owned PTYs independently, and marks output after the +held window so historical redraws cannot satisfy the assertion. It also verifies +return to the composer and subsequent command execution. Sandbox teardown stays +unchanged. An initial reattach failure remains separate evidence; only the fresh +post-release failure proves this sampling regression. + +The repository regression failed against the original shared fixture on macOS +arm64 at the expected 20-second repaint wait, despite both PTYs being 33×90. The +minimal fixture correction sets a dirty flag in the signal handler and clears it +before querying geometry in the main loop. A WINCH during that external query +therefore survives for the next repaint. The unchanged regression, geometry +controls, full lifecycle cases and real Vim/less repaint passed afterward. +Direct strict TypeScript checking of the new test helpers also passes; explicit +success/unavailable unions change no runtime observations. + +Production geometry and startup/input behavior are unchanged by this fixture +correction. The original repaint assertion/deadline and real-program tests remain +intact. The historical CI run did not capture actual PTY size, so its exact final +geometry is still unknown. Earlier standalone synchronization and stale-output +control attempts were inconclusive and remain recorded in the handoff; no retry +of the genuine failed feature CI is used for acceptance. Fresh canonical and +actual published-head CI gates are required after this correction. diff --git a/scripts/platform-benchmarks.ts b/scripts/platform-benchmarks.ts index fc904452..1eab8e81 100644 --- a/scripts/platform-benchmarks.ts +++ b/scripts/platform-benchmarks.ts @@ -1,13 +1,14 @@ import {once} from 'node:events'; import {existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; +import {tmpdir} from 'node:os'; import {performance} from 'node:perf_hooks'; import {ShellSession} from '../src/shell/ShellSession.js'; import {connectSession} from '../src/session/connectSession.js'; import {socketPathFor} from '../src/session/runtimeDir.js'; // A short, owned POSIX root avoids Unix-socket limits. Never touch existing roots. -const root = realpathSync(mkdtempSync('/tmp/npb-')); +const root = realpathSync(mkdtempSync(join(process.env.NMSH_TEST_TMPDIR ?? tmpdir(), 'npb-'))); writeFileSync(join(root, '.zshrc'), ''); const env = {...process.env, HOME: root}; const samples: Record = {'pty-ready': [], 'service-ready': [], 'service-cleanup': []}; diff --git a/scripts/test-selection.mjs b/scripts/test-selection.mjs index 69d91898..cc995fd8 100644 --- a/scripts/test-selection.mjs +++ b/scripts/test-selection.mjs @@ -14,7 +14,7 @@ const suites = { fast: ['testSharding', 'input', 'keys', 'highlighter', 'shellProtocol', 'sessionProtocol', 'completionModel', 'pathDisplay', 'linuxPlatform'], // Minimum-runtime gate: actual launcher, persistent shell backends, protocol, // platform discovery and build/version behavior without long detach scenarios. - node22: ['testSharding', 'buildInfo', 'startupLaunch', 'shellAdapters', 'sessionProtocol', 'shellProtocol', 'linuxPlatform', 'hostProfiles', 'serviceCompat'], + node22: ['testSharding', 'buildInfo', 'startupLaunch', 'shellAdapters', 'sessionProtocol', 'shellProtocol', 'linuxPlatform', 'hostProfiles', 'serviceCompat', 'socketClientLifecycle', 'sessionViewRefresh', 'stoppedSessionPrompt', 'sessionReattachGeometry', 'startupSignalLifecycle'], fedora: ['linuxPlatform', 'tools', 'update', 'hostProfiles', 'portabilityUninstall'], }; diff --git a/scripts/test.mjs b/scripts/test.mjs index 840569b4..9b51f773 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -1,5 +1,5 @@ import {spawn} from 'node:child_process'; -import {mkdtempSync, readdirSync, realpathSync, rmSync} from 'node:fs'; +import {mkdtempSync, mkdirSync, readdirSync, realpathSync, rmSync} from 'node:fs'; import {availableParallelism, tmpdir} from 'node:os'; import {join} from 'node:path'; import {pathToFileURL} from 'node:url'; @@ -7,12 +7,14 @@ import {discoverTestFiles, parseTestOptions, selectTestGroups, testConcurrency} /** Short private temp roots keep Unix sockets below their path length limit. */ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio = 'inherit', report = message => console.error(message)} = {}) { - const root = realpathSync(mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'nt-'))); + const root = realpathSync(mkdtempSync(join(process.env.NMSH_TEST_TMPDIR ?? (process.platform === 'win32' ? tmpdir() : '/tmp'), 'nt-'))); let leftovers = []; try { + const testHome = join(root, 'home'); + mkdirSync(testHome, {mode: 0o700}); // Existing presentation snapshots pin truecolor; baseline fixtures override this explicitly. // Tests never see the user's real NMSh settings: configuration lives under this run's private root. - const env = {...process.env, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config'), NMSH_DISABLE_UPDATES: '1'}; + const env = {...process.env, HOME: testHome, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config'), NMSH_DISABLE_UPDATES: '1'}; // A nested runner must not impersonate its parent's test worker. delete env.NODE_TEST_CONTEXT; // PTY workers also own shell/helper processes. Bound fan-out on larger diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2218896d..332678ea 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -17,7 +17,7 @@ import {misePanelKey, renderMisePanel, type MisePanel} from '../tools/MisePanel. import {detectBackend, keepAwakePath, KeepAwakeController, type KeepAwakeRecord} from '../keepAwake/keepAwake.js'; import {awakeDuration, awakeLabel, awakeStyle, awakeView, edgeAccessoryColumns, edgeFits, placeOnSaver, renderComposerEdge, resolveAccessorySlot, sliceAnsiCells} from '../keepAwake/presentation.js'; import {createKeepAwakePanel, describeStart, keepAwakeKey, renderKeepAwakePanel, requestStart, statusLines, type KeepAwakePanel} from '../keepAwake/KeepAwakePanel.js'; -import {homedir} from 'node:os'; +import {homedir, tmpdir} from 'node:os'; import {createNotificationService, formatCommandNotification, shouldNotify, type TerminalFocus} from '../notifications/commandNotifications.js'; import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId} from '../ui/BlockActions.js'; import {paletteItems} from '../ui/CommandPalette.js'; @@ -178,6 +178,9 @@ import {TaskProgress} from '../status/TaskProgress.js'; import {renderStatusStrip, stripStatsFromFacts, stripVisible, type StripModuleItem} from '../status/StatusStrip.js'; import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; +import {completionWaitFact, directInputPlaceholder, inputActivityRows, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {directInput, type InputState, type InputTiming} from '../session/inputState.js'; +import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {AgentActivityStore} from '../agents/AgentActivityStore.js'; @@ -719,6 +722,16 @@ export class TerminalApp { private readonly effects = new EffectState(); private presentationFrame?: {frame: TerminalFrame; plan: ScreenPlan; agentCaretRow?: number}; private activityAnimationNow = Date.now(); + /** The running command's input state, as the session's InputWatch reports it; never guessed here. */ + private inputState?: InputState; + /** Waiting time the shell's completing prompt carried for the command that just finished. */ + private completedInputTiming?: InputTiming; + /** + * A composer draft started while a command runs: when, and whether a request for input was already shown (an + * answer, discarded rather than run if the command ends first). Drafts started before the command's latest output + * are type-ahead for the shell and stay in the composer. + */ + private runningDraft?: {startId: number; at: number; answering: boolean}; /** One pending timeout at a time drives the welcome cat's occasional blink. */ private welcomeBlinkTimer?: () => void; private welcomeBlinkCount = 0; @@ -740,6 +753,7 @@ export class TerminalApp { private lastPtyColumns = 0; private stopped = false; private originalRawMode = false; + private rawInputPrepared = false; private shellHandoffCwd?: string; private shellHandoffRequested = false; private presentationStartCwd = process.cwd(); @@ -775,9 +789,10 @@ export class TerminalApp { this.done = new Promise(resolve => { this.finish = resolve; }); - this.session.on('data', (data, stamp) => { if (this.inStream(stamp)) this.onShellData(data); }); + this.session.on('data', (data, stamp) => { if (!this.stopped && this.inStream(stamp)) this.onShellData(data); }); this.session.on('prompt', (marker, stamp) => { - if (this.inStream(stamp)) { + // Buffered prompts after shutdown cannot restart collection or queue work. + if (!this.stopped && this.inStream(stamp)) { if (marker.knowledge !== undefined) { this.shellJobs = knowledgeJobCount(marker.knowledge) ?? 0; // An older bootstrap reports no snapshot: keep the documented launch-environment fallback. @@ -790,11 +805,13 @@ 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; this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); } }); - this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed); } }); + this.session.on('exec', (command, stamp) => { if (!this.stopped && this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed); } }); this.session.on('replayed', summary => this.finishReplay(summary)); + this.session.on('inputState', state => this.onInputState(state)); this.session.on('inputRejected', (data, submission) => this.onInputRejected(data, submission)); this.session.on('startup', tail => { this.startupTail = tail; @@ -877,6 +894,8 @@ export class TerminalApp { private attachedSession?: AttachedSession; private continuedJournal?: TranscriptSession; private rendererEntered = false; + /** Current fullscreen ownership can release startup while its journal awaits IO. */ + private startupPassthroughReady?: () => void; /** Drop stream events the transcript already has (exactly-once replay). */ private inStream(stamp: StreamStamp): boolean { @@ -930,7 +949,7 @@ export class TerminalApp { private finishReplay(summary: {truncatedBytes: number}): void { const attached = this.attachedSession; - if (!this.replaying || !attached) return; + if (this.stopped || !this.replaying || !attached) return; this.replaying = false; const parts = [`Reattached live session ${attached.sessionId.slice(0, 8)} (zsh pid ${attached.pid})`]; const completed = this.replayedCompletions; @@ -946,21 +965,19 @@ export class TerminalApp { this.cancelPresentation(); this.passthrough = true; this.noteForeignScreen(); - this.attachedModes = attached.modes ?? ''; + this.commandModes.reset('alternate'); + this.commandModes.observeModes(attached.modes ?? ''); if (this.rendererEntered) this.enterAttachedPassthrough(); + else this.startupPassthroughReady?.(); } this.scheduleJournal(); this.render(); } - /** The reattached fullscreen app's own terminal modes, which this terminal never received. */ - private attachedModes = ''; - private enterAttachedPassthrough(): void { // Reattached into a fullscreen app: hand it the whole terminal again, // including the mouse/paste/cursor-key modes it set before the detach. - this.handOverTerminal(this.attachedModes); - this.attachedModes = ''; + this.handOverTerminal(this.commandModes.restoreSequence()); } /** @@ -978,9 +995,16 @@ export class TerminalApp { if (this.escapeFlushTimer) { clearTimeout(this.escapeFlushTimer); this.escapeFlushTimer = undefined; } this.keyDecoder.reset(); this.terminalFocus = 'unknown'; + if (!this.rendererEntered) { + // Entry clears the screen. Wake startup first, then resize exactly once + // after entry so the program's resulting repaint cannot be erased. + this.startupPassthroughReady?.(); + return; + } this.renderer.suspendForPassthrough(restore); const dimensions = this.dimensions(); this.session.resize(dimensions.columns, dimensions.rows); + this.startupPassthroughReady?.(); } private onActiveModeChange(mode: PresentationMode): void { @@ -1005,7 +1029,7 @@ export class TerminalApp { if (this.running) { this.running.awaitingExec = false; this.running.historyAllowed = historyAllowed; - if (!this.startupPending && !this.passthrough && shouldPassthrough(command)) { + if (!this.replaying && !this.startupPending && !this.passthrough && shouldPassthrough(command)) { this.cancelPresentation(); this.passthrough = true; this.noteForeignScreen(); @@ -1014,10 +1038,17 @@ export class TerminalApp { return; } this.commandModes.reset(); + this.inputState = undefined; const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), mode => this.onActiveModeChange(mode), this.historicalContext(this.shellCwd, this.context, command)); this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd, historyAllowed}; + if (!this.replaying && !this.startupPending && !this.passthrough && shouldPassthrough(command)) { + this.cancelPresentation(); + this.passthrough = true; + this.noteForeignScreen(); + this.handOverTerminal(); + } this.scheduleJournal(); if (!this.replaying) this.render(); } @@ -1037,12 +1068,24 @@ export class TerminalApp { cwd: running.cwd, startId: this.output.activeStartId ?? running.startId, outputStartId, historyAllowed: running.historyAllowed}} : {})}; } + /** Protect input before any program prompt is visible, including synchronous attach replay. */ + private prepareRawInput(): void { + if (this.stopped || !process.stdin.isTTY || this.rawInputPrepared) return; + this.originalRawMode = process.stdin.isRaw; + process.stdin.setRawMode(true); + this.rawInputPrepared = true; + } + async run(): Promise { + if (this.stopped) return this.done; + // Catchable shutdown must own cleanup even while probes or persistence await. + process.once('SIGTERM', this.onTerminate); + process.once('SIGHUP', this.onTerminate); + this.prepareRawInput(); + process.once('exit', this.onProcessExit); let earlyInput = ''; // A reattached interactive program owns terminal queries and replies. if (!this.passthrough && process.stdin.isTTY && process.stdout.isTTY) { - this.originalRawMode = process.stdin.isRaw; - process.stdin.setRawMode(true); process.stdin.setEncoding('utf8'); const resolved = await probeHost(this.host.capabilities, { write: data => process.stdout.write(data), @@ -1052,7 +1095,7 @@ export class TerminalApp { return () => { process.stdin.off('data', receive); process.stdin.pause(); }; }, }, undefined, {graphics: shouldProbeGraphics(process.env)}); - process.stdin.setRawMode(this.originalRawMode); + if (this.stopped) return this.done; this.host.capabilities = resolved.capabilities; this.renderer.setCapabilities(resolved.capabilities); earlyInput = resolved.input; @@ -1062,53 +1105,63 @@ export class TerminalApp { () => this.output.addFrontendInteraction('/resume', 'Could not persist the current session; check local storage.', ERROR), // Durable now: the service may forget these stream events. saved => { if (saved.live) this.session.ack(saved.live.seq, saved.id); }); - try { - if (this.continuedJournal) await this.journal.continue(this.continuedJournal); - else await this.journal.start(); + // Initialize the journal before input can schedule snapshots, but do not wait + // for disk IO before restoring a fullscreen program's terminal and size. + // The journal serializes saves and acknowledges only durable checkpoints. + let journalSettled = false; + const journalStarted = (this.continuedJournal + ? this.journal.continue(this.continuedJournal) + : this.journal.start()).then(() => { this.journalActive = true; - } catch { + }, () => { this.output.addFrontendInteraction('/resume', 'Continuous session journaling could not start; check local storage.', ERROR); + this.render(); + }).finally(() => { journalSettled = true; }); + // A fresh ordinary frontend must first publish its journal identity so an + // immediate detach can continue that journal. An already-owned terminal + // must instead restore its raw input and full size without waiting on IO. + while (!this.stopped && !this.passthrough && !journalSettled) { + // Replay or the next foreground command can establish ownership while + // persistence waits. A completion in the same socket batch can withdraw + // it again, so each wakeup must check the current owner before proceeding. + try { + await Promise.race([journalStarted, new Promise(resolve => { this.startupPassthroughReady = resolve; })]); + } finally { + this.startupPassthroughReady = undefined; + } } - this.startOnboarding(); - // Restored terminal modes are a visible handoff: keys can arrive at once. - // Install raw input first so the host cannot echo or translate those keys. - if (process.stdin.isTTY) { - this.originalRawMode = process.stdin.isRaw; - process.stdin.setRawMode(true); + // A shell exit or shutdown during startup cannot reinstall frontend input. + if (!this.stopped) { + this.startOnboarding(); + // Restored terminal modes are a visible handoff: keys can arrive at once. + // Install raw input first so the host cannot echo or translate those keys. + this.prepareRawInput(); + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + process.stdin.on('data', this.onInput); + this.renderer.enter(); + this.rendererEntered = true; + if (this.passthrough) this.enterAttachedPassthrough(); + if (earlyInput) this.onInput(earlyInput); + process.stdout.on('resize', this.onResize); + process.on('SIGTSTP', this.onSuspend); + process.on('SIGCONT', this.onContinue); + this.presentationStarted = true; + void this.loadSessionIdentity(); + this.refreshAwake(); + // Re-apply (or clean up) Theme Bridge state once per launch; nothing happens when it was never used. + this.scheduleThemeBridge(500); + this.scheduleWelcomeBlink(); + void this.loadHistory(); + this.render(); + void this.quietUpdateCheck(); + void this.quietToolUpdateCheck(); + this.armIdle(); + this.presetFrontendReady = true; + if (this.presetShellReady) this.advancePresetStartup(0, this.shellCwd); } - process.stdin.setEncoding('utf8'); - process.stdin.resume(); - process.stdin.on('data', this.onInput); - this.renderer.enter(); - this.rendererEntered = true; - if (this.passthrough) this.enterAttachedPassthrough(); - if (earlyInput) this.onInput(earlyInput); - process.stdout.on('resize', this.onResize); - process.on('SIGTSTP', this.onSuspend); - process.on('SIGCONT', this.onContinue); - process.once('SIGTERM', this.onTerminate); - process.once('SIGHUP', this.onTerminate); - process.on('exit', () => { - if (!this.stopped) { - if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode); - this.terminalFocus = 'unknown'; - this.renderer.leave(); - } - }); - this.presentationStarted = true; - void this.loadSessionIdentity(); - this.refreshAwake(); - // Re-apply (or clean up) Theme Bridge state once per launch; nothing happens when it was never used. - this.scheduleThemeBridge(500); - this.scheduleWelcomeBlink(); - void this.loadHistory(); - this.render(); - void this.quietUpdateCheck(); - void this.quietToolUpdateCheck(); - this.armIdle(); - this.presetFrontendReady = true; - if (this.presetShellReady) this.advancePresetStartup(0, this.shellCwd); const exitCode = await this.done; + await journalStarted; try { await this.journal.close(!this.detaching || this.shellEnded); } catch { process.stderr.write('NMSh could not finish persisting the current presentation session.\n'); } @@ -1119,11 +1172,16 @@ export class TerminalApp { private readonly queryExtractor = new QueryExtractor(); private readonly replyRouter = new ReplyRouter(); + private logEditorKeyInput(data: string): void { + const hex = Array.from(Buffer.from(data)).map(b => b.toString(16).padStart(2, '0')).join(' '); + appendFileSync(join(tmpdir(), 'nmsh-key-debug.log'), `RAW hex=${hex} escaped=${JSON.stringify(data)}\n`); + } + private readonly onInput = (data: string): void => { - if (process.env.NMSH_DEBUG_KEYS === '1') { - const hex = Array.from(Buffer.from(data)).map(b => b.toString(16).padStart(2, '0')).join(' '); - const escaped = JSON.stringify(data); - appendFileSync('/tmp/nmsh-key-debug.log', `RAW hex=${hex} escaped=${escaped}\n`); + // Before probes classify a read, or inside a native reader, input can already be sensitive. + // Key diagnostics therefore observe only the idle composer, never startup or running-program input. + if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.startupPending && !this.passthrough && !this.externalPassthrough) { + this.logEditorKeyInput(data); } if (this.passthrough && !this.startupPending) { this.session.write(data); @@ -1211,6 +1269,14 @@ export class TerminalApp { this.render(); }; + private readonly onProcessExit = (): void => { + if (!this.stopped) { + if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode); + this.terminalFocus = 'unknown'; + this.renderer.leave(); + } + }; + // The frontend is going away (window closed, SIGHUP/SIGTERM); the shell was // not asked to end, so a service-backed session stays alive, detached. private readonly onTerminate = (): void => { @@ -1816,6 +1882,17 @@ export class TerminalApp { this.historyViewport.latest(); return; } + // A program reading a hidden line or single keys gets each key as it is typed, as in any terminal: the + // composer neither shows nor keeps it, and Enter is a key for the program, never a shell submission. + if (this.directInputRequest()) { + this.sendDirectKey(key); + return; + } + // Typing that starts while a command waits for a line is an answer: if the command ends before it is sent, it + // is discarded rather than left to run as the next shell command. + if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: Boolean(this.inputState?.request)}; + } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. if (this.promptConfiguration.composerPosition === 'flow' && this.historyViewport.detached && FLOW_EDIT_KEYS.has(key.kind)) { @@ -2104,6 +2181,7 @@ export class TerminalApp { } else { const input = this.editor.text; this.editor.clear(); + this.runningDraft = undefined; this.session.write(`${input}\r`); return; } @@ -2606,6 +2684,8 @@ export class TerminalApp { const contextAtSubmission = this.context; this.effects.cancel(); this.commandModes.reset(); + this.inputState = undefined; + this.runningDraft = undefined; const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), (mode) => { if (mode === 'PASSTHROUGH' && !this.passthrough && !this.startupPending) { this.cancelPresentation(); @@ -2817,6 +2897,22 @@ export class TerminalApp { this.resumeBrowser = createSessionsView(live, this.sessionId); } + /** Refresh live facts without moving the person's selection, query or composer. */ + private refreshSessionsView(live: SessionInfo[]): boolean { + const browser = this.resumeBrowser; + if (!browser?.liveOnly) return false; + const selected = resumeSelection(browser); + browser.live = live; + const visible = visibleLiveSessions(browser); + const index = selected?.kind === 'live' ? visible.findIndex(session => session.id === selected.session.id) : -1; + browser.selectedIndex = index >= 0 ? index : Math.max(0, Math.min(browser.selectedIndex, visible.length - 1)); + if (browser.confirmKill) { + const target = live.find(session => session.id === browser.confirmKill); + if (!target || target.state !== 'detached' || target.id === browser.currentId) browser.confirmKill = undefined; + } + return true; + } + private sessionsViewRows(browser: ResumeBrowserState, columns: number): string[] { const now = Date.now(); const safe = getCurrentGlyphMode() === 'safe'; @@ -2828,10 +2924,10 @@ export class TerminalApp { rows.forEach((row, index) => { const selected = index === browser.selectedIndex; const marker = row.current ? (safe ? '*' : '●') : row.session.state === 'detached' ? (safe ? '-' : '◌') : (safe ? 'o' : '○'); - const color = row.state === 'failed' ? ERROR : row.state === 'attention' ? ACCENT : row.state === 'completed' ? SUCCESS : SECONDARY; + const color = row.state === 'failed' ? ERROR : row.state === 'attention' || row.state === 'input' || row.state === 'inputLikely' ? ACCENT : row.state === 'completed' ? SUCCESS : SECONDARY; const who = row.agent ? `${agentColor(row.agent.color)}${safe ? row.agent.safeGlyph : row.agent.glyph} ${row.agent.short}${RESET} ` : ''; out.push(truncateAnsi(`${selected ? `${ACCENT}›` : ' '} ${marker} ${PRIMARY}${(row.current ? 'this' : `#${row.ordinal}`).padEnd(5)}${RESET}${SECONDARY}${row.shell.padEnd(5)}${RESET} ` - + `${color}${row.stateLabel.padEnd(16)}${RESET}${who}${focusForeground(selected)}${row.summary}${RESET}`, columns)); + + `${color}${row.stateLabel.padEnd(18)}${RESET}${who}${focusForeground(selected)}${row.summary}${RESET}`, columns)); }); const confirming = browser.live.find(session => session.id === browser.confirmKill); if (confirming) out.push('', truncateAnsi(`${ERROR} Kill the live session in ${confirming.cwd}? Its shell and anything running in it will end. Enter kill · Esc cancel${RESET}`, columns)); @@ -3082,6 +3178,10 @@ export class TerminalApp { // New output is activity: it ends idle visuals and restarts the inactivity timer. this.noteActivity(); if (this.passthrough) { + this.prepareRawInput(); + // Raw bytes still reach the host now; retain only their bounded mode + // state so renderer entry cannot replace the current owner's modes. + if (!this.rendererEntered) this.commandModes.observeModes(data); this.renderer.observePassthrough(data); process.stdout.write(data); } else { @@ -3092,6 +3192,7 @@ export class TerminalApp { this.journal?.schedule(); this.output.setActiveActivities(this.tapActivityObserver.push(data, Date.now())); if (!wasPassthrough && this.passthrough) { + this.prepareRawInput(); this.renderer.observePassthrough(data); process.stdout.write(data); } else if (!this.replaying) { @@ -3108,6 +3209,12 @@ export class TerminalApp { this.shellSuggestions = []; this.lastSuggestionInput = ''; this.shellCwd = cwd; + // The authenticated prompt owns the command directory. Async workspace + // collection may still describe the previous prompt when the next command + // is submitted; refresh this cheap fact now without weakening its policy. + this.context.cwd = cwd; + const cwdFact = promptFacts({cwd, project: ''}, at).cwd; + this.context.facts = {...this.context.facts, cwd: updateFact(cwdFact, this.context.facts?.cwd)}; this.endStartupWatch(); this.switchedShellStarting = false; const initialPrompt = !this.presetShellReady; @@ -3151,6 +3258,8 @@ export class TerminalApp { if (completedRecord) { completedRecord.startedAt = command.startedAt; completedRecord.durationMs = Math.max(0, elapsed); + // Wall-clock duration stays whole; the time spent waiting for the person is a separate, measured fact. + if (this.completedInputTiming) { completedRecord.inputWaitMs = this.completedInputTiming.waitedMs; completedRecord.inputWaits = this.completedInputTiming.waits; } completedRecord.historyEligible = command.historyAllowed === 1 && !isPrivateCommand(command.command, ignorePatternFromEnv()); this.directoryQuery = undefined; this.directoryQueryAbort?.abort(); @@ -3170,6 +3279,7 @@ export class TerminalApp { const failure = isInterrupted ? undefined : classifyShellFailure(command.command, exitCode, outputText); if (failure) parts.main = parts.main.replace('Command failed', failure === 'command-not-found' ? 'Command not found' : 'Shell syntax error'); else if (agent) parts.main = agentCompletionText(agent.id, elapsed, exitCode, isInterrupted); + parts.main += completionWaitFact(this.completedInputTiming); this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`); const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS); this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); @@ -3178,6 +3288,17 @@ export class TerminalApp { if (known) this.output.addHistoryLine(`${INFO}${known}${RESET}`); } this.running = undefined; + this.inputState = undefined; + this.completedInputTiming = undefined; + // An answer typed for this command but never sent must not become the next shell command. + const draft = this.runningDraft; + this.runningDraft = undefined; + if (draft?.answering) { + if (this.editor.text) { + this.editor.clear(); + this.output.addFrontendInteraction(commandWords(command.command)[0] ?? 'command', 'The command finished before your reply was sent; the reply was discarded, not run.', INFO); + } + } if (!this.replaying && !command.cleared && completedRecord) { const interrupted = command.interrupted || exitCode === 130; const now = Date.now(); @@ -3194,6 +3315,7 @@ export class TerminalApp { this.queryExtractor.reset(); if (this.passthrough) { this.passthrough = false; + this.commandModes.reset(); // The program may have set a title or cwd as it exited (Vim: "Thanks for flying Vim"): reclaim both now. this.noteForeignScreen(); this.renderer.resumeAfterPassthrough(); @@ -4013,12 +4135,13 @@ export class TerminalApp { live.forEach((session, index) => { const selected = index === browser.selectedIndex; const state = liveRowState(session, now); - const stateColor = state === 'failed' ? ERROR : state === 'attention' ? ACCENT : state === 'completed' ? SUCCESS : state === 'active' ? PRIMARY : SECONDARY; + const stateColor = state === 'failed' ? ERROR : state === 'attention' || state === 'input' || state === 'inputLikely' ? ACCENT : state === 'completed' ? SUCCESS : state === 'active' ? PRIMARY : SECONDARY; const agent = liveRowAgent(session); // Agent color only when identity is proven and color is allowed; generic otherwise. const who = agent ? `${agentColor(agent.color)}${safe ? agent.safeGlyph : agent.glyph} ${agent.short}${RESET} ` : ''; const signature = signatureAccent(session.signature); - rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${stateColor}${(safe ? '*' : '●')} ${LIVE_ROW_LABELS[state].padEnd(16)}${RESET}${signature ? `${foreground(signature)}${safe ? '+' : '◆'}${RESET} ` : ''}${who}` + const stateMark = state === 'input' ? (safe ? '!' : '◆') : state === 'inputLikely' ? (safe ? '?' : '◇') : (safe ? '*' : '●'); + rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${stateColor}${stateMark} ${LIVE_ROW_LABELS[state].padEnd(17)}${RESET}${signature ? `${foreground(signature)}${safe ? '+' : '◆'}${RESET} ` : ''}${who}` + `${selected ? ACCENT : SECONDARY}${describeLiveRow(session, now)}${RESET}`, columns)); }); rows.push('', `${SUBTLE} ARCHIVED${RESET}`); @@ -4540,7 +4663,9 @@ export class TerminalApp { if (mode === 'off') return undefined; const project = this.context.project && this.context.project !== '…' ? this.context.project : basename(this.shellCwd) || this.shellCwd; const session = this.sessionId ? sessionTitleName(this.sessionIdentity, this.noticeLabels.get(this.sessionId)) : undefined; - const running = this.running && !this.passthrough ? commandWords(this.running.command)[0] : undefined; + const request = this.running && !this.passthrough ? this.inputState?.request : undefined; + const running = request ? `${inputGlyph(request)} ${inputProgram(request, this.running!.command)} needs input` + : this.running && !this.passthrough ? commandWords(this.running.command)[0] : undefined; return composeTitle(mode, project, session, running); } @@ -6560,9 +6685,10 @@ export class TerminalApp { // Expired notices never paint, even between polls. const notices = this.noticeView.notices.filter(notice => noticeVisible(notice, now)); const safe = getCurrentGlyphMode() === 'safe'; - const symbols = {done: safe ? '+' : '✦', attention: safe ? '!' : '◆', failed: safe ? 'x' : '×', ended: safe ? '-' : '○', long: safe ? '~' : '◷'}; + const symbols = {done: safe ? '+' : '✦', attention: safe ? '!' : '◆', failed: safe ? 'x' : '×', ended: safe ? '-' : '○', long: safe ? '~' : '◷', input: safe ? '?' : '◇'}; const rows = notices.map(notice => { const parts = describeNotice(notice, this.noticeLabels.get(notice.sessionId) ?? sessionLabel(notice.sessionId), now); + if (parts.symbol === 'input') return truncateAnsi(`${ACCENT}${inputGlyph({confidence: notice.confidence === 'likely' ? 'likely' : 'confirmed'})}${RESET} ${SECONDARY}${parts.text}${RESET}`, columns); const color = parts.symbol === 'failed' ? ERROR : parts.symbol === 'attention' ? ACCENT : parts.symbol === 'done' ? SUCCESS : SECONDARY; return truncateAnsi(`${color}${symbols[parts.symbol]}${RESET} ${SECONDARY}${parts.text}${RESET}`, columns); }); @@ -6570,10 +6696,10 @@ export class TerminalApp { return rows; } - /** Poll the service's session list on a slow cadence while notices are on and NMSh owns the screen. */ + /** Poll while notices or the explicit live session view need facts and NMSh owns the screen. */ private syncNotices(): void { const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended - && this.sessionMode === 'service' && this.promptConfiguration.sessionNotices; + && this.sessionMode === 'service' && (this.promptConfiguration.sessionNotices || Boolean(this.resumeBrowser?.liveOnly)); if (wanted && !this.noticeTimer) { this.noticeTimer = presentationClock.subscribe(() => void this.refreshNotices(), NOTICE_REFRESH_MS); void this.refreshNotices(); @@ -6587,30 +6713,34 @@ export class TerminalApp { if (this.noticePolling) return; this.noticePolling = true; try { - const {sessions, ended} = await listSessionNotices(); + const {sessions, ended} = await listSessionNotices({throwOnError: true}); if (this.stopped) return; const ordered = [...sessions].sort((a, b) => a.createdAt - b.createdAt); // The person's own name, else the familiar signature, else the old ordinal label. this.noticeLabels = new Map(ordered.map((session, index) => [session.id, session.name || session.signature || sessionLabel(session.id, index + 1)])); this.noteSessionIdentity(sessions.find(session => session.id === this.sessionId)); this.noticeSource = [...sessions.flatMap(session => (session.notice ? [session.notice] : [])), ...ended]; - this.applyNotices(); + const refreshedView = this.refreshSessionsView(sessions); + const noticesChanged = this.applyNotices(); + if (refreshedView && !noticesChanged) this.render(); } catch { /* notices are best effort */ } finally { this.noticePolling = false; } } /** Select what is visible now (expired notices drop out) and wake exactly when the next one expires. */ - private applyNotices(now = Date.now()): void { - const next = selectNotices(this.noticeSource, this.sessionId, this.dismissedNotices, now); + private applyNotices(now = Date.now()): boolean { + const enabled = this.promptConfiguration.sessionNotices; + const next = enabled ? selectNotices(this.noticeSource, this.sessionId, this.dismissedNotices, now) : {notices: [], hidden: 0}; const changed = JSON.stringify(next) !== JSON.stringify(this.noticeView); this.noticeView = next; if (this.noticeExpiry) { clearTimeout(this.noticeExpiry); this.noticeExpiry = undefined; } - const soonest = this.noticeSource.filter(notice => selectNotices([notice], this.sessionId, this.dismissedNotices, now).notices.length) - .map(notice => noticeExpiresAt(notice)).filter((at): at is number => at !== undefined).sort((a, b) => a - b)[0]; + const soonest = enabled ? this.noticeSource.filter(notice => selectNotices([notice], this.sessionId, this.dismissedNotices, now).notices.length) + .map(notice => noticeExpiresAt(notice)).filter((at): at is number => at !== undefined).sort((a, b) => a - b)[0] : undefined; if (soonest !== undefined && !this.stopped) { this.noticeExpiry = setTimeout(() => { this.noticeExpiry = undefined; this.applyNotices(); if (!this.stopped) this.render(); }, Math.max(50, soonest - now + 50)); this.noticeExpiry.unref(); } if (changed) this.render(); + return changed; } /** Clear every visible notice, for every attached frontend where the service supports it. */ @@ -8530,8 +8660,10 @@ export class TerminalApp { } } + const direct = this.directInputRequest(); const inputRows = input.rows.map(row => { const prefix = row.prefix.startsWith(GLYPHS.prompt) ? `${ACCENT}${GLYPHS.prompt}${RESET}${row.prefix.slice(GLYPHS.prompt.length)}` : row.prefix; + if (direct) return row === input.rows[0] ? truncateAnsi(`${prefix}${SUBTLE}${directInputPlaceholder(direct, inputProgram(direct, this.running!.command))}${RESET}`, this.inputColumns(columns)) : ''; let textStyled = ''; const glyphsInRow = graphemes(row.text); for (let i = 0; i < glyphsInRow.length; i++) { @@ -8585,11 +8717,7 @@ export class TerminalApp { ); }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, columns)] : [])]); // The spacer sits between the newest output and the activity line in both positions. - case 'activity': { - if (!this.running) return []; - const activity = truncateAnsi(this.currentActivity(), columns); - return plan.composerPosition === 'top' ? ['', activity] : [activity, '']; - } + case 'activity': return this.activityRows(plan, columns); case 'composerBorder': return [this.composerEdgeRow('composerBorder', plan, columns, now)]; case 'prompt': return [promptLine]; case 'input': return inputRows; @@ -8793,8 +8921,7 @@ export class TerminalApp { const rows = [...frame.rows]; for (const region of plan.regions) { if (region.kind === 'activity' && this.running) { - const line = truncateAnsi(this.currentActivity(), frame.columns ?? 80); - const content = plan.composerPosition === 'top' ? ['', line] : [line, '']; + const content = this.activityRows(plan, frame.columns ?? 80); for (let index = 0; index < region.height; index++) rows[region.top + index] = content[index] ?? ''; } // Live composer divider lines move with Chroma only when Divider lines follow Chroma. @@ -8864,6 +8991,59 @@ export class TerminalApp { } } + /** + * The live activity region (two rows, always reserved while a command runs). Ordinarily the working line and a + * spacer; while the command waits for input, the attention line and the prompt it left open, so the question + * stays in view however far the transcript scrolled or folded. Nothing extra is reserved and the PTY never resizes. + */ + private activityRows(plan: ScreenPlan, columns: number): string[] { + if (!this.running) return []; + const request = this.inputState?.request; + // A wait reads top to bottom (headline, then the question) wherever the composer is docked. + if (request) return inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns).map(row => truncateAnsi(row, columns)); + const activity = truncateAnsi(this.currentActivity(), columns); + return plan.composerPosition === 'top' ? ['', activity] : [activity, '']; + } + + /** Keys go straight to the program (a hidden line or single keys) rather than through the composer. */ + private directInputRequest() { + const request = this.inputState?.request; + return this.running && !this.passthrough && !this.startupPending && directInput(request) ? request : undefined; + } + + private onInputState(state: InputState): void { + // A state for a finished run (a late message) never resurrects it. + if (!this.running) return; + this.inputState = state; + if (state.terminalOwner) { + this.onActiveModeChange('PASSTHROUGH'); + return; + } + const request = state.request; + const draft = this.runningDraft; + // Keys typed after the program wrote its question, in the moment before NMSh could tell how it reads, were + // meant for it: a terminal would already have delivered them. Hand them over now instead of hiding them. + if (request && directInput(request) && draft && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + const typed = this.editor.text; + this.editor.clear(); + this.runningDraft = undefined; + this.session.write(typed); + } else if (request && draft && draft.at >= request.since && this.editor.text) draft.answering = true; + this.render(); + } + + /** Forward one key to the waiting program as the bytes a terminal sends; nothing is kept, shown or logged. */ + private sendDirectKey(key: Key): void { + if (key.kind === 'interrupt') { + if (this.running) this.running.interrupted = true; + this.session.interrupt(); + return; + } + if (key.kind === 'eof') { this.session.endInput(); return; } + const bytes = directKeyBytes(key); + if (bytes) this.session.write(bytes); + } + private currentActivity(): string { if (!this.running) return ''; const elapsed = this.activityAnimationNow - this.running.startedAt; @@ -8875,7 +9055,7 @@ export class TerminalApp { const still = !this.decorativeMotionAllowed() || sweepStill(this.promptConfiguration); const {cells, style} = liveActivityPaint(parts.phrase, this.promptConfiguration, this.activityAnimationNow, still); const phrase = sweepCells(cells, animationElapsed * (isActive ? 1.4 : 1), style, colorLevel(), !this.decorativeMotionAllowed()); - return `${phrase}${SECONDARY}${parts.duration}${RESET}`; + return `${phrase}${SECONDARY}${parts.duration}${waitedDetail(this.inputState, this.activityAnimationNow, elapsed)}${RESET}`; } private jumpAffordance(columns: number): string { @@ -8917,9 +9097,11 @@ export class TerminalApp { } private layoutEditorInput(columns: number, maxVisibleRows = Number.POSITIVE_INFINITY) { + // Direct input: the composer shows where typing goes, never a draft, so the caret sits at the start. + const direct = Boolean(this.directInputRequest()); return layoutInput( - this.editor.displayText, - this.editor.displayCursorIndex, + direct ? '' : this.editor.displayText, + direct ? 0 : this.editor.displayCursorIndex, this.inputColumns(columns), maxVisibleRows, this.railInputPrefix(columns), @@ -9070,6 +9252,7 @@ export class TerminalApp { this.endSweep(); process.stdin.off('data', this.onInput); process.stdout.off('resize', this.onResize); + process.off('exit', this.onProcessExit); process.off('SIGTSTP', this.onSuspend); process.off('SIGCONT', this.onContinue); process.off('SIGTERM', this.onTerminate); diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 2760b5da..47b5d362 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -39,7 +39,11 @@ export interface CompletedCommand { /** Only explicitly eligible commands enter command history; transcript retention is separate. */ historyEligible?: boolean; startedAt?: number; + /** Wall-clock time from start to completion, waiting included. */ durationMs?: number; + /** Of durationMs, the time the command spent waiting for input, and how many waits (see InputWatch). */ + inputWaitMs?: number; + inputWaits?: number; command: string; output: string; lifecycleText: string; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 7d1a79b3..597703a1 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -1,3 +1,4 @@ +import {colorLevel} from '../presentation/capabilities.js'; import {stripTerminalControls} from '../util/terminalControls.js'; import {askFoldLabel} from '../ask/transcriptSummary.js'; import {paintDivider, DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; @@ -96,7 +97,12 @@ export class TranscriptPresenter { private wrap(view: TranscriptView, index: number, width: number): WrappedRow[] { const owner = view.ownerOf(index); const context = owner === undefined ? undefined : view.historicalContexts.get(owner); - const line = view.lines[index] ?? []; + const stored = view.lines[index] ?? []; + // Saved NMSh chrome belongs to the current frontend's presentation policy. + // Program cells keep their original SGR, including across reattach. + const line = view.lineTypes.has(index) && colorLevel() === 'none' + ? stored.map(cell => cell ? {...cell, style: ''} : cell) + : stored; return wrapStyledLine(this.hyperlinks ? this.links.line(line, context?.cwd) : line, width, this.hyperlinks); } diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index d5618d3c..768c3582 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -36,6 +36,7 @@ export class InProcessSessionClient extends EventEmitter im shell.on('exec', (command, historyAllowed) => { this.running = true; this.emit('exec', command, historyAllowed === undefined ? NO_STAMP : {historyAllowed}); }); 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('exit', event => this.emit('exit', event)); } diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts new file mode 100644 index 00000000..ef060a26 --- /dev/null +++ b/src/session/InputWatch.ts @@ -0,0 +1,288 @@ +import {EventEmitter} from 'node:events'; +import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; +import {OpenLine, promptShaped, promptText} from './openLine.js'; +import {AlternateScreenTracker} from './TerminalModes.js'; +import type {ForegroundProcess, TerminalProbe, TerminalSample} from './terminalProbe.js'; +import {type InputMode, type InputRequest, type InputState} from './inputState.js'; + +/** + * Watches one session's running command for a credible request for input. Lives beside the PTY (in the session + * service, or in-process), so every frontend and /sessions read the same state. + * + * Evidence, strongest first; nothing is concluded from silence alone: + * 1. Linux: the kernel reports a foreground thread blocked in read() on this terminal → confirmed. + * 2. The terminal's line settings: echo off with line input (a password read) → confirmed hidden input; line input + * off with a prompt left open on the cursor's line (e2fsck's "Fix? ", `read -k`) → confirmed key input. + * Raw mode alone is not enough: spinners and terminal proxies (docker -it) set it without asking anything. + * 3. Ordinary line input: a question-shaped line left open, output quiet, the foreground group idle across two + * samples (no CPU, nothing running) → likely. A long build, `sleep`, a stalled download or a log stream does + * not leave a question open, rewrites its line, or is busy. + * A program that owns the terminal (full screen, or input modes such as bracketed paste) draws and reads its own + * interface; it is never reported as waiting. The kernel saying the group is blocked elsewhere vetoes 2 and 3. + * + * Observation only: probes read the terminal's settings and the process table, never input, and never pause, + * signal or write to the program. + */ +export interface InputWatchOptions { + probe?: TerminalProbe; + /** The terminal device path, once known. */ + tty?: () => string | undefined; + now?: () => number; + /** Timer seam for tests. */ + setTimer?: (callback: () => void, ms: number) => () => void; +} + +interface InputWatchEvents { + change: [InputState]; +} + +/** Quiet time before the first look: short when a line was left open (a prompt is likely), longer otherwise. */ +const FIRST_LOOK_OPEN_MS = 150; +const FIRST_LOOK_MS = 400; +/** Later looks back off: a long quiet command costs a few cheap probes a minute. */ +const LOOKS_MS = [600, 1200, 2500]; +const STEADY_LOOK_MS = 3000; +const SLOW_LOOK_AFTER_MS = 60_000; +const SLOW_LOOK_MS = 10_000; +/** + * With no line left open, only a hidden-line read (a password with no prompt text) or the kernel's report can say + * anything, so a slow stream (a log line every second) is looked at no more often than this. + */ +const CLOSED_LINE_INTERVAL_MS = 2000; +/** Ordinary line input is only "likely" after the output has been quiet this long. */ +const LIKELY_QUIET_MS = 700; +/** CPU share across two samples above which a foreground process is computing, not waiting. */ +const BUSY_SHARE = 0.25; + +const defaultTimer = (callback: () => void, ms: number) => { + const timer = setTimeout(callback, ms); + timer.unref?.(); + return () => clearTimeout(timer); +}; + +export class InputWatch extends EventEmitter { + private running?: {command: string; since: number}; + private reader = false; + private readonly line = new OpenLine(); + private readonly screen = new AlternateScreenTracker(); + private request?: InputRequest; + private waitedMs = 0; + private waits = 0; + /** + * Changes with every output and every input: one prompt is one epoch. Evidence that lapses and returns within an + * epoch (a resize waking the program for a moment) continues the same prompt; it is never counted twice. + */ + private epoch = 0; + private requestEpoch = -1; + private countedEpoch = -1; + /** When the last wait ended: a new wait never starts earlier, so no time is counted twice. */ + private lastWaitEnd = 0; + /** + * The wait that just closed without output or input (evidence lapsed), and what closing it added. If the evidence + * returns in the same epoch it is the same prompt: it resumes with its own start, and that time is taken back. + */ + private lapsed?: {request: InputRequest; epoch: number; addedMs: number; previousEnd: number}; + private lastActivity = 0; + private lastOutput = 0; + /** Bumped by every stream event; a probe that started before the latest event is stale. */ + private generation = 0; + private looks = 0; + private cancelTimer?: () => void; + private probing = false; + private lastProbeAt = -Infinity; + private previous = new Map(); + private disposed = false; + private readonly now: () => number; + private readonly setTimer: (callback: () => void, ms: number) => () => void; + + constructor(private readonly options: InputWatchOptions = {}) { + super(); + this.now = options.now ?? Date.now; + this.setTimer = options.setTimer ?? defaultTimer; + } + + get state(): InputState { + return {...(this.reader && this.running ? {terminalOwner: true as const} : {}), ...(this.request ? {request: {...this.request}} : {}), timing: {waitedMs: this.waitedMs, waits: this.waits}}; + } + + /** Whether the current command's program owns the terminal (and so its own input). */ + get ownsTerminal(): boolean { + return Boolean(this.running && (this.reader || this.screen.ownsTerminal || shouldPassthrough(this.running.command))); + } + + /** Authenticated shell event, emitted before Fish's builtin reader draws its prompt. */ + onReader(at = this.now()): void { + if (!this.running) return; + this.closeWait(at, true); + this.reader = true; + this.emit('change', this.state); + } + + onExec(command: string, at = this.now()): void { + this.reader = false; + this.closeWait(at, false); + this.running = {command, since: at}; + this.waitedMs = 0; + this.waits = 0; + this.epoch += 1; + this.lastWaitEnd = 0; + this.line.reset(); + this.screen.reset(); + this.previous.clear(); + this.lastOutput = at; + this.touch(at); + this.emit('change', this.state); + } + + onOutput(data: string, at = this.now()): void { + if (!this.running) return; + this.screen.observeModes(data); + this.screen.push(data); + this.line.push(data); + this.lastOutput = at; + this.epoch += 1; + // Output after the prompt: the program moved on (or asks again, which the next look sees). + if (this.request) this.closeWait(at, true); + this.touch(at); + } + + /** Bytes NMSh wrote to the program. An answer ends a wait; typing inside a hidden line does not. */ + onInput(data: string, at = this.now()): void { + if (!this.running) return; + const request = this.request; + if (request && (request.mode === 'key' || /[\r\n\u0003\u0004\u001a]/u.test(data))) this.closeWait(at, true); + this.epoch += 1; + this.touch(at); + } + + /** The command finished (the shell is back at its prompt): close any wait; the final timing stays readable. */ + onPrompt(at = this.now()): void { + if (!this.running) return; + this.closeWait(at, false); + this.running = undefined; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.generation += 1; + this.emit('change', this.state); + } + + dispose(): void { + this.disposed = true; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.removeAllListeners(); + } + + private closeWait(at: number, emit: boolean, lapse = false): void { + const request = this.request; + this.lapsed = undefined; + if (!request) return; + this.request = undefined; + const addedMs = Math.max(0, at - request.since); + if (lapse) this.lapsed = {request, epoch: this.epoch, addedMs, previousEnd: this.lastWaitEnd}; + this.waitedMs += addedMs; + this.lastWaitEnd = Math.max(this.lastWaitEnd, at); + if (this.requestEpoch !== this.countedEpoch) { this.waits += 1; this.countedEpoch = this.requestEpoch; } + if (emit) this.emit('change', this.state); + } + + private touch(at: number): void { + this.lastActivity = at; + this.generation += 1; + this.looks = 0; + this.schedule(); + } + + private schedule(): void { + this.cancelTimer?.(); + this.cancelTimer = undefined; + if (this.disposed || !this.running || !this.options.probe) return; + const quiet = this.now() - this.lastActivity; + const planned = this.looks === 0 ? (this.line.open ? FIRST_LOOK_OPEN_MS : FIRST_LOOK_MS) + : LOOKS_MS[this.looks - 1] ?? (quiet > SLOW_LOOK_AFTER_MS ? SLOW_LOOK_MS : STEADY_LOOK_MS); + const delay = this.line.open ? planned : Math.max(planned, this.lastProbeAt + CLOSED_LINE_INTERVAL_MS - this.now()); + this.cancelTimer = this.setTimer(() => { this.cancelTimer = undefined; void this.look(); }, delay); + } + + private async look(): Promise { + if (this.disposed || !this.running || this.probing) return; + this.looks += 1; + if (this.ownsTerminal) { + if (this.request) this.closeWait(this.now(), true); + this.schedule(); + return; + } + const tty = this.options.tty?.(); + if (!tty || !this.options.probe) return; + const generation = this.generation; + this.probing = true; + this.lastProbeAt = this.now(); + let sample: TerminalSample | undefined; + try { sample = await this.options.probe({tty}); } catch { sample = undefined; } finally { this.probing = false; } + if (this.disposed || !this.running) return; + if (generation === this.generation && sample) this.apply(sample, this.now()); + this.schedule(); + } + + /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ + apply(sample: TerminalSample, now: number): void { + if (!this.running || this.ownsTerminal) return; + const busy = this.busy(sample.foreground, now); + const next = this.decide(sample, busy, now); + const current = this.request; + if (!next) { + // The evidence went away without input or output (a timed-out read, a restored terminal): the wait ended no + // later than now. + if (current) this.closeWait(now, true, true); + return; + } + // The same prompt after a lapse (nothing was written or typed since): it keeps its start, counted once. + const resumed = !current && this.lapsed && this.lapsed.epoch === this.epoch ? this.lapsed : undefined; + if (resumed) { + this.waitedMs = Math.max(0, this.waitedMs - resumed.addedMs); + this.lastWaitEnd = resumed.previousEnd; + } + this.lapsed = undefined; + const program = sample.foreground.find(item => item.command)?.command; + const request: InputRequest = {since: current?.since ?? resumed?.request.since ?? Math.max(this.running.since, this.lastOutput, this.lastWaitEnd), confidence: next.confidence, mode: next.mode, + ...(promptText(this.line.line) ? {prompt: promptText(this.line.line)} : {}), ...(program ? {program: program.slice(0, 64)} : {})}; + if (current && current.confidence === request.confidence && current.mode === request.mode && current.prompt === request.prompt + && current.program === request.program) return; + if (!current) this.requestEpoch = this.epoch; + this.request = request; + this.emit('change', this.state); + } + + private decide(sample: TerminalSample, busy: boolean | undefined, now: number): {confidence: 'confirmed' | 'likely'; mode: InputMode} | undefined { + const modes = sample.modes; + const mode: InputMode = !modes ? 'line' : !modes.canonical ? 'key' : !modes.echo ? 'hidden' : 'line'; + if (sample.readingTerminal === true) return {confidence: 'confirmed', mode}; + // The kernel says every foreground thread is blocked on something other than the terminal. + if (sample.readingTerminal === false || busy === true) return undefined; + if (modes && modes.canonical && !modes.echo) return {confidence: 'confirmed', mode: 'hidden'}; + if (modes && !modes.canonical) return this.line.open ? {confidence: 'confirmed', mode: 'key'} : undefined; + // Ordinary line input: only a question left open, quiet output, and an idle group seen across two samples. + if (busy === false && this.line.open && promptShaped(this.line.line) && now - this.lastOutput >= LIKELY_QUIET_MS) return {confidence: 'likely', mode: 'line'}; + return undefined; + } + + /** true: computing; false: idle between this sample and the previous one; undefined: not yet known. */ + private busy(foreground: ForegroundProcess[], now: number): boolean | undefined { + if (!foreground.length) return undefined; + let known = !foreground.some(item => item.state.startsWith('R')) ? true : undefined; + let busy = known === undefined; + for (const item of foreground) { + const before = this.previous.get(item.pid); + if (item.cpuMs === undefined || !before) { if (known) known = false; continue; } + const elapsed = now - before.at; + if (elapsed > 0 && (item.cpuMs - before.cpuMs) / elapsed > BUSY_SHARE) busy = true; + } + this.remember(foreground, now); + if (busy) return true; + return known ? false : undefined; + } + + private remember(foreground: ForegroundProcess[], now: number): void { + this.previous = new Map(foreground.flatMap(item => item.cpuMs === undefined ? [] : [[item.pid, {cpuMs: item.cpuMs, at: now}] as const])); + } +} diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index a1244bb3..871ef1f2 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -3,6 +3,7 @@ import type {ShellMarker} from '../shell/ShellProtocol.js'; import type {TranscriptSession} from '../sessions/TranscriptStore.js'; import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import type {ServiceFeature} from './SessionProtocol.js'; +import type {InputState} from './inputState.js'; /** Position of an event in a service session's stream, when it has one. */ export interface StreamStamp { @@ -24,6 +25,8 @@ export interface SessionClientEvents { startup: [string]; /** Input was not queued or written; the caller can restore it. */ inputRejected: [data: string, submission: boolean]; + /** The running command's input state changed (see InputWatch); the one source for every input surface. */ + inputState: [InputState]; /** The backlog sent after a reattach has been delivered. */ replayed: [{truncatedBytes: number}]; /** The managed shell ended. */ diff --git a/src/session/SessionNotices.ts b/src/session/SessionNotices.ts index 515ec38f..4f9effc8 100644 --- a/src/session/SessionNotices.ts +++ b/src/session/SessionNotices.ts @@ -19,7 +19,7 @@ import {formatAge, tildePath} from './sessionList.js'; * agent or program said. */ -export const NOTICE_KINDS = ['completed', 'failed', 'attention', 'ended', 'long-running'] as const; +export const NOTICE_KINDS = ['completed', 'failed', 'attention', 'ended', 'long-running', 'input'] as const; export type NoticeKind = typeof NOTICE_KINDS[number]; export interface SessionNotice { @@ -34,9 +34,13 @@ export interface SessionNotice { exitCode?: number; durationMs?: number; cwd?: string; + /** For an input notice: how sure the service is that the program waits for input (see InputWatch). */ + confidence?: string; } export const MAX_VISIBLE_NOTICES = 3; +/** A wait shorter than this is someone answering, not news for another window. */ +export const INPUT_NOTICE_DELAY_MS = 3000; /** A command still running after this long is worth one notice. */ export const LONG_RUNNING_MS = 15 * 60_000; /** @@ -70,7 +74,7 @@ export function noticeVisibleMs(notice: SessionNotice): number | undefined { case 'failed': return NOTICE_VISIBLE_MS.failed; case 'long-running': return NOTICE_VISIBLE_MS.longRunning; case 'ended': return notice.exitCode ? NOTICE_VISIBLE_MS.endedAbnormal : NOTICE_VISIBLE_MS.ended; - case 'attention': return undefined; + case 'attention': case 'input': return undefined; } } @@ -138,6 +142,21 @@ export class SessionNoticeTracker { this.current = {sessionId: this.sessionId, kind: 'attention', at, ...programIdentity(this.running.command, this.running.process)}; } + /** + * The running program has been waiting for input (since `since`) long enough to be news. One notice per wait; + * sticky until the wait ends, the session is focused, or a newer transition replaces it. + */ + onInputNeeded(since: number, confidence: string, program?: string): void { + if (!this.running || since <= this.clearedAt) return; + if (this.current?.kind === 'input' && this.current.at === since) return; + this.current = {sessionId: this.sessionId, kind: 'input', at: since, ...programIdentity(this.running.command, program ?? this.running.process), confidence}; + } + + /** The wait ended (answered, interrupted, or the program moved on): an input notice is no longer true. */ + onInputResolved(): void { + if (this.current?.kind === 'input') this.current = undefined; + } + /** Raise one long-running notice per run, lazily when someone lists sessions; no timers. */ checkLongRunning(now: number): void { const run = this.running; @@ -214,7 +233,7 @@ export function sessionLabel(sessionId: string, ordinal?: number): string { export interface NoticeLineParts { kind: NoticeKind; /** The glyph slot; the caller picks Nerd/Safe. */ - symbol: 'done' | 'attention' | 'failed' | 'ended' | 'long'; + symbol: 'done' | 'attention' | 'failed' | 'ended' | 'long' | 'input'; text: string; } @@ -232,6 +251,10 @@ export function describeNotice(notice: SessionNotice, label: string, now: number return {kind: notice.kind, symbol: 'failed', text: `${label} · ${who ?? 'command'} failed (exit ${notice.exitCode ?? '?'}) · ${age} ago`}; case 'attention': return {kind: notice.kind, symbol: 'attention', text: `${label} · ${who ?? 'program'} asked for attention · ${age} ago`}; + case 'input': + return {kind: notice.kind, symbol: 'input', text: `${label} · ${who ?? 'a command'} ${notice.confidence === 'likely' ? 'may be waiting for input' : 'is waiting for input'} · ${age} · /resume`}; + case 'input': + return {kind: notice.kind, symbol: 'input', text: `${label} · ${who ?? 'a command'} ${notice.confidence === 'likely' ? 'may be waiting for input' : 'is waiting for input'} · ${age} · /resume`}; case 'long-running': return {kind: notice.kind, symbol: 'long', text: `${label} · ${who ?? 'command'} still running · ${formatDuration(notice.durationMs ?? 0)}`}; case 'ended': diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index eac4b178..93bc63e6 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -10,6 +10,7 @@ */ import {NOTICE_KINDS, type SessionNotice} from './SessionNotices.js'; +import {INPUT_CONFIDENCES, INPUT_MODES, type InputState} from './inputState.js'; // v2: a frontend going away detaches its session instead of ending it. export const PROTOCOL_VERSION = 2; @@ -21,15 +22,28 @@ export const MAX_FRAME_BYTES = 8 * 1024 * 1024; * talking to an older service (same protocol version, still running its live * sessions) degrades factually instead of failing later. */ -export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; +/** + * What a frontend understands beyond the base protocol, sent in its hello. A service sends a feature's messages or + * values (the input-state message, the "input" notice kind) only to a frontend that listed it, so an older frontend + * attached to a newer service never receives a frame it would reject. + */ +export const CLIENT_FEATURES = ['input-state'] as const; +export type ClientFeature = typeof CLIENT_FEATURES[number]; + +export function parseClientFeatures(text: string | undefined): Set { + return new Set((text ?? '').split(',').map(item => item.trim()).filter((item): item is ClientFeature => (CLIENT_FEATURES as readonly string[]).includes(item))); +} + export function parseFeatures(text: string | undefined): Set { return new Set((text ?? '').split(',').map(item => item.trim()).filter((item): item is ServiceFeature => (SERVICE_FEATURES as readonly string[]).includes(item))); } export type ClientMessage = - | {type: 'hello'; version: number; client: string} + /** features: comma-separated CLIENT_FEATURES this frontend understands; absent from older frontends. */ + | {type: 'hello'; version: number; client: string; features?: string} /** shell: backend id (zsh, fish, bash); absent means zsh (older frontends). */ | {type: 'create'; cwd: string; env: Record; columns: number; rows: number; shell?: string} | {type: 'attach'; sessionId: string; columns: number; rows: number} @@ -80,7 +94,12 @@ export type ServerMessage = */ | {type: 'output'; data: string; seq?: number; at?: number} | {type: 'exec'; command: string; seq: number; at: number; historyAllowed?: number} - | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number} + | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number; inputWaitMs?: number; inputWaits?: 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. + */ + | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} /** End of the backlog sent after attach. */ | {type: 'replayed'; truncatedBytes: number} | {type: 'killed'; sessionId: string} @@ -124,6 +143,11 @@ export interface SessionInfo { name?: string; /** The session's current cross-session notice, until it is focused (#v0.16; absent from older services). */ notice?: SessionNotice; + /** While the running command credibly waits for input: since when, how sure, how it reads, and the prompt shown. */ + inputSince?: number; + inputConfidence?: string; + inputMode?: string; + inputPrompt?: string; } export type ProtocolMessage = ClientMessage | ServerMessage; @@ -140,7 +164,7 @@ type Kind = 'string' | 'int' | 'env' | 'int?' | 'string?' | 'sessions' | 'notice type Shape = Record; const SHAPES: Record = { - hello: {version: 'int', client: 'string'}, + hello: {version: 'int', client: 'string', features: 'string?'}, create: {cwd: 'string', env: 'env', columns: 'int', rows: 'int', shell: 'string?'}, 'switch-shell': {shell: 'string', cwd: 'string'}, rename: {sessionId: 'string', name: 'string'}, @@ -166,7 +190,8 @@ const SHAPES: Record = { dismissed: {sessionId: 'string'}, output: {data: 'string', seq: 'int?', at: 'int?'}, exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?'}, - prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?'}, + prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?', inputWaitMs: 'int?', inputWaits: 'int?'}, + 'input-state': {terminalOwner: 'int?', since: 'int?', confidence: 'string?', mode: 'string?', prompt: 'string?', program: 'string?', waitedMs: 'int', waits: 'int'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, exit: {exitCode: 'int', signal: 'int?'}, @@ -179,10 +204,11 @@ function isEnv(value: unknown): value is Record { const INFO_SHAPE: Shape = {id: 'string', pid: 'int', state: 'string', cwd: 'string', createdAt: 'int', running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?', - process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?', notice: 'notice?', shell: 'string?', signature: 'string?', name: 'string?'}; + process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?', notice: 'notice?', shell: 'string?', signature: 'string?', name: 'string?', + inputSince: 'int?', inputConfidence: 'string?', inputMode: 'string?', inputPrompt: 'string?'}; const NOTICE_SHAPE: Shape = {sessionId: 'string', kind: 'string', at: 'int', program: 'string?', agent: 'string?', exitCode: 'int?', - durationMs: 'int?', cwd: 'string?'}; + durationMs: 'int?', cwd: 'string?', confidence: 'string?'}; function decodeNotice(raw: unknown): SessionNotice | undefined { const decoded = decodeShape(NOTICE_SHAPE, raw) as SessionNotice | undefined; @@ -257,3 +283,22 @@ export class FrameDecoder { return results; } } + +/** The input-state message for a state; bounded prompt text. */ +export function inputStateMessage(state: InputState): Extract { + const request = state.request; + return {type: 'input-state', waitedMs: Math.round(state.timing.waitedMs), waits: state.timing.waits, + ...(state.terminalOwner ? {terminalOwner: 1} : {}), + ...(request ? {since: request.since, confidence: request.confidence, mode: request.mode, + ...(request.prompt ? {prompt: request.prompt.slice(0, 200)} : {}), ...(request.program ? {program: request.program.slice(0, 64)} : {})} : {})}; +} + +/** The state an input-state message describes; an unknown confidence or mode is dropped, never guessed. */ +export function inputStateFrom(message: Extract): InputState { + const confidence = INPUT_CONFIDENCES.find(item => item === message.confidence); + const mode = INPUT_MODES.find(item => item === message.mode); + const timing = {waitedMs: message.waitedMs, waits: message.waits}; + if (message.terminalOwner === 1) return {terminalOwner: true, timing}; + if (message.since === undefined || !confidence || !mode) return {timing}; + return {request: {since: message.since, confidence, mode, ...(message.prompt ? {prompt: message.prompt} : {}), ...(message.program ? {program: message.program} : {})}, timing}; +} diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index bef57336..3c0c5d67 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -6,10 +6,12 @@ import {ShellSession} from '../shell/ShellSession.js'; import {isShellId, knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; import {shellAdapter} from '../shell/adapters/registry.js'; import {SessionEvidence} from './SessionEvidence.js'; -import {FrameDecoder, PROTOCOL_VERSION, SERVICE_FEATURES, encodeMessage, type ServerMessage, type SessionInfo, type SessionState} from './SessionProtocol.js'; +import {FrameDecoder, PROTOCOL_VERSION, SERVICE_FEATURES, encodeMessage, inputStateMessage, parseClientFeatures, type ClientFeature, type ServerMessage, + type SessionInfo, type SessionState} from './SessionProtocol.js'; +import type {InputState} from './inputState.js'; import {SESSION_ID_ENV, SESSION_MODE_ENV} from './SessionClient.js'; import {ensurePrivateRuntimeDir, socketPathFor, spoolPathFor} from './runtimeDir.js'; -import {EndedNotices, SessionNoticeTracker} from './SessionNotices.js'; +import {EndedNotices, INPUT_NOTICE_DELAY_MS, SessionNoticeTracker} from './SessionNotices.js'; import {StreamBacklog, type BacklogEvent, type BacklogLimits} from './StreamBacklog.js'; export const SERVICE_NAME = 'nmshd'; @@ -39,6 +41,12 @@ interface ManagedSession { size: {columns: number; rows: number}; /** The one writable frontend; undefined while detached. */ controller?: Send; + /** What the controlling frontend said it understands (its hello). */ + controllerFeatures?: ReadonlySet; + /** The running command's input state (InputWatch, beside the PTY): the one source every surface reads. */ + input?: InputState; + /** Pending check that a wait lasted long enough to become a cross-session notice. */ + inputNoticeTimer?: NodeJS.Timeout; running?: {command: string; since: number}; /** When zsh last returned to its prompt. */ idleSince: number; @@ -79,7 +87,8 @@ function toMessage(event: BacklogEvent): ServerMessage { case 'exec': return {type: 'exec', command: event.command, seq: event.seq, at: event.at, ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed})}; case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at, - ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge})}; + ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge}), + ...(event.inputWaits ? {inputWaitMs: event.inputWaitMs ?? 0, inputWaits: event.inputWaits} : {})}; } } @@ -120,15 +129,17 @@ export class SessionService { return [...this.sessions.values()].map(session => ({...session.record})); } - private info(session: ManagedSession): SessionInfo { + private info(session: ManagedSession, features: ReadonlySet = new Set()): SessionInfo { const {record, running} = session; + const request = running ? session.input?.request : undefined; const evidence = session.evidence.snapshot(); // Read only when someone lists sessions; nothing polls the process table. const process = running ? session.shell.foregroundProcess : undefined; session.notices.observeProcess(process); if (evidence.attentionSince !== undefined) session.notices.onAttention(evidence.attentionSince); session.notices.checkLongRunning(Date.now()); - const notice = session.notices.notice; + // A notice kind an older frontend cannot decode is left out for it (it would reject the whole list). + const notice = session.notices.notice?.kind === 'input' && !features.has('input-state') ? undefined : session.notices.notice; return {id: record.id, pid: record.pid, state: record.state, cwd: record.cwd, createdAt: Date.parse(record.createdAt), shell: session.backend, ...(record.signature ? {signature: record.signature} : {}), ...(record.name ? {name: record.name} : {}), ...(running ? {running: running.command, runningSince: running.since} : {idleSince: session.idleSince}), @@ -139,7 +150,9 @@ export class SessionService { ...(evidence.title ? {title: evidence.title} : {}), ...(evidence.attentionSince !== undefined ? {attentionSince: evidence.attentionSince} : {}), ...(evidence.lastExit !== undefined && !running ? {lastExit: evidence.lastExit} : {}), - ...(notice ? {notice} : {})}; + ...(notice ? {notice} : {}), + ...(request ? {inputSince: request.since, inputConfidence: request.confidence, inputMode: request.mode, + ...(request.prompt ? {inputPrompt: request.prompt.slice(0, 120)} : {})} : {})}; } async start(): Promise { @@ -174,6 +187,7 @@ export class SessionService { const send = (message: ServerMessage) => { if (!socket.destroyed) socket.write(encodeMessage(message)); }; let greeted = false; let owned: ManagedSession | undefined; + let features: ReadonlySet = new Set(); socket.on('data', chunk => { for (const result of decoder.push(chunk as unknown as string)) { @@ -191,6 +205,7 @@ export class SessionService { return; } greeted = true; + features = parseClientFeatures(message.features); send({type: 'welcome', version: PROTOCOL_VERSION, service: SERVICE_NAME, startupSafety: 1, features: SERVICE_FEATURES.join(','), ...(this.options.build ? {build: this.options.build} : {})}); continue; @@ -206,6 +221,7 @@ export class SessionService { const backend = message.shell === undefined ? 'zsh' : message.shell; if (!isShellId(backend)) throw new Error(`unknown shell backend ${backend}`); owned = this.create(message.cwd, message.env, message.columns, message.rows, send, backend); + owned.controllerFeatures = features; send({type: 'created', sessionId: owned.record.id, pid: owned.record.pid, shell: owned.backend}); } catch (error) { send({type: 'error', code: 'spawn', message: error instanceof Error ? error.message : String(error)}); @@ -217,11 +233,11 @@ export class SessionService { if (!session) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } if (session.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; } owned = session; - this.bind(session, send); + this.bind(session, send, features); // Opening a session is focusing it: its notice is no longer news in any window. session.notices.clear(); this.endedNotices.dismiss(session.record.id); - const info = this.info(session); + const info = this.info(session, features); const {backlog} = session; send({type: 'attached', sessionId: info.id, pid: info.pid, cwd: info.cwd, fullscreen: session.screen.ownsTerminal ? 1 : 0, ...(session.screen.ownsTerminal && session.screen.restoreSequence() ? {modes: session.screen.restoreSequence()} : {}), @@ -233,6 +249,8 @@ export class SessionService { const missed = backlog.events(); for (const event of missed) send(toMessage(event)); send({type: 'replayed', truncatedBytes: backlog.truncatedBytes}); + // The live input state is not a stream event: a reattaching frontend gets it now, after the replay. + if (features.has('input-state') && info.running && session.input) send(inputStateMessage(session.input)); this.redraw(session, message.columns, message.rows); break; } @@ -245,7 +263,7 @@ export class SessionService { } break; case 'list': - send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session)), ended: this.endedNotices.list(Date.now())}); + send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session, features)), ended: this.endedNotices.list(Date.now())}); break; case 'dismiss': this.sessions.get(message.sessionId)?.notices.clear(); @@ -279,7 +297,7 @@ export class SessionService { if (!target) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } const name = message.name.replace(/[\u0000-\u001f\u007f]/gu, '').trim().slice(0, 40); if (name) target.record.name = name; else delete target.record.name; - send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session)), ended: this.endedNotices.list(Date.now())}); + send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session, features)), ended: this.endedNotices.list(Date.now())}); break; } case 'resize': @@ -309,17 +327,46 @@ export class SessionService { }); } - private bind(session: ManagedSession, send: Send): void { + private bind(session: ManagedSession, send: Send, features: ReadonlySet): void { session.controller = send; + session.controllerFeatures = features; session.record.state = 'attached'; } private detach(session: ManagedSession, send: Send): void { if (session.controller !== send) return; session.controller = undefined; + session.controllerFeatures = undefined; session.record.state = 'detached'; } + /** + * A new input state from the session's watch: the attached frontend hears it at once; a wait that lasts becomes + * one cross-session notice (sticky until answered or focused), and a resolved wait withdraws it. + */ + private onInputState(session: ManagedSession, state: InputState): void { + const before = session.input?.request; + session.input = state; + // 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; + if (session.controllerFeatures?.has('input-state')) session.controller?.(inputStateMessage(state)); + const request = state.request; + if (!request) { + if (session.inputNoticeTimer) { clearTimeout(session.inputNoticeTimer); session.inputNoticeTimer = undefined; } + if (before) session.notices.onInputResolved(); + return; + } + if (before?.since === request.since && session.inputNoticeTimer) return; + if (session.inputNoticeTimer) clearTimeout(session.inputNoticeTimer); + session.inputNoticeTimer = setTimeout(() => { + session.inputNoticeTimer = undefined; + const current = session.input?.request; + if (current && current.since === request.since) session.notices.onInputNeeded(current.since, current.confidence, current.program); + }, Math.max(0, request.since + INPUT_NOTICE_DELAY_MS - Date.now())); + session.inputNoticeTimer.unref?.(); + } + /** * Adopt the attaching frontend's size. A fullscreen app only repaints on a * real size change, so step through a neighbouring size first: each step @@ -380,6 +427,7 @@ export class SessionService { else session.controller?.({type: 'output', data}); }); shell.on('startup', output => session.controller?.({type: 'startup', output})); + shell.on('inputState', state => this.onInputState(session, state)); shell.on('exec', (command, historyAllowed) => { const at = Date.now(); session.running = {command, since: at}; @@ -396,10 +444,12 @@ export class SessionService { session.notices.onPrompt(marker.exitCode, Date.now(), marker.cwd); 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.knowledge === undefined ? {} : {knowledge: marker.knowledge}), + ...(marker.inputWaits ? {inputWaitMs: marker.inputWaitMs ?? 0, inputWaits: marker.inputWaits} : {})}); }); shell.on('exit', event => { this.sessions.delete(record.id); + if (session.inputNoticeTimer) { clearTimeout(session.inputNoticeTimer); session.inputNoticeTimer = undefined; } // Ending with no window attached is news; with one attached, that window saw it. if (!session.controller) this.endedNotices.add(session.notices.ended(event.exitCode, Date.now(), record.cwd)); // Detached: keep what the journal lacks on disk for archiving. Attached: diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 810d4b20..5ed21ee3 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -1,6 +1,8 @@ import {EventEmitter} from 'node:events'; import {connect, type Socket} from 'node:net'; -import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, parseFeatures, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; +import {dirname} from 'node:path'; +import {ensurePrivateRuntimeDir} from './runtimeDir.js'; +import {CLIENT_FEATURES, FrameDecoder, PROTOCOL_VERSION, encodeMessage, inputStateFrom, parseFeatures, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; import type {SessionInfo} from './SessionProtocol.js'; import type {SessionNotice} from './SessionNotices.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; @@ -33,6 +35,9 @@ interface Established { function request(socketPath: string, timeoutMs: number, first: ClientMessage | undefined, onMessage: (message: ServerMessage) => T | undefined): Promise> { return new Promise((resolve, reject) => { + // Every request, including listings and best-effort notices, crosses the + // same ownership boundary as create/attach before any handshake is sent. + ensurePrivateRuntimeDir(dirname(socketPath)); const socket = connect(socketPath); socket.setEncoding('utf8'); const decoder = new FrameDecoder(); @@ -78,7 +83,7 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage }; const onError = (error: NodeJS.ErrnoException) => fail(`session service unreachable (${error.code ?? 'error'})`, error.code ?? 'error'); const onClose = () => fail('session service closed the connection', 'closed'); - socket.once('connect', () => send({type: 'hello', version: PROTOCOL_VERSION, client: 'nmsh'})); + socket.once('connect', () => send({type: 'hello', version: PROTOCOL_VERSION, client: 'nmsh', features: CLIENT_FEATURES.join(',')})); socket.on('error', onError); socket.on('close', onClose); socket.on('data', onData); @@ -233,7 +238,9 @@ 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})}, {seq: message.seq, at: message.at}); + ...(message.knowledge === undefined ? {} : {knowledge: message.knowledge}), + ...(message.inputWaits ? {inputWaitMs: message.inputWaitMs ?? 0, inputWaits: message.inputWaits} : {})}, {seq: message.seq, at: message.at}); + else if (message.type === 'input-state') this.emit('inputState', inputStateFrom(message)); else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at, historyAllowed: message.historyAllowed}); else if (message.type === 'input-rejected') this.emit('inputRejected', message.data, message.submission === 1); else if (message.type === 'startup') this.emit('startup', message.output); @@ -262,14 +269,25 @@ export class SocketSessionClient extends EventEmitter imple interrupt(): void { this.send({type: 'input', data: '\u0003'}); } endInput(): void { this.send({type: 'input', data: '\u0004'}); } resize(columns: number, rows: number): void { this.send({type: 'resize', columns: Math.max(2, columns), rows: Math.max(2, rows)}); } + private endStream(): void { + // Before start(), discard withheld output rather than exposing it during + // cancellation. Drain the paused readable half so peer EOF closes it. + if (this.pending) { + this.pending = undefined; + this.socket.removeAllListeners('data'); + } + this.socket.end(); + this.socket.resume(); + } + kill(): void { this.send({type: 'terminate'}); - this.socket.end(); + this.endStream(); } ack(seq: number, journalId: string): void { this.send({type: 'ack', seq, journalId}); } detach(): void { this.detaching = true; this.send({type: 'detach'}); - this.socket.end(); + this.endStream(); } } diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index 6d4c6e1d..d936184d 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} - | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string}; + | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string; inputWaitMs?: number; inputWaits?: number}; /** Non-event spool records: journal acknowledgements, truncation and the shell's end. */ type SpoolRecord = BacklogEvent diff --git a/src/session/connectSession.ts b/src/session/connectSession.ts index c0010f0c..a83d46e5 100644 --- a/src/session/connectSession.ts +++ b/src/session/connectSession.ts @@ -132,11 +132,13 @@ export async function connectSession(options: ConnectSessionOptions): Promise { +export async function listSessionNotices(options: {env?: NodeJS.ProcessEnv; runtimeDir?: string; throwOnError?: boolean} = {}): Promise<{sessions: SessionInfo[]; ended: SessionNotice[]}> { const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(options.env ?? process.env); try { return await listSessionsWithNotices(socketPathFor(runtimeDir), 1500); - } catch { + } catch (error) { + // Live views must distinguish a failed poll from a validated empty registry. + if (options.throwOnError) throw error; return {sessions: [], ended: []}; } } diff --git a/src/session/inputState.ts b/src/session/inputState.ts new file mode 100644 index 00000000..cfdb7af8 --- /dev/null +++ b/src/session/inputState.ts @@ -0,0 +1,53 @@ +/** + * The one shared description of "a command needs the person": produced next to the PTY (InputWatch), carried by the + * session protocol, and only rendered by frontends. No surface guesses on its own. + */ + +/** How the foreground program reads: a line until Enter, a hidden line (password), or single keys. */ +export type InputMode = 'line' | 'hidden' | 'key'; +export const INPUT_MODES: readonly InputMode[] = ['line', 'hidden', 'key']; + +/** + * confirmed: the terminal or the kernel says a program is reading input (hidden or key input set on the terminal + * with a prompt left on screen, or a blocked read of the terminal). likely: a question-shaped line was left open, + * the output is quiet and the foreground processes are idle, but nothing proves they are reading. + */ +export type InputConfidence = 'confirmed' | 'likely'; +export const INPUT_CONFIDENCES: readonly InputConfidence[] = ['confirmed', 'likely']; + +export interface InputRequest { + /** When the program started waiting: when it wrote the prompt it is waiting on (epoch ms). */ + since: number; + confidence: InputConfidence; + mode: InputMode; + /** The open prompt line, sanitized and bounded; absent when the program left none. */ + prompt?: string; + /** Foreground program name, when the platform reports it. */ + program?: string; +} + +/** Waiting time of the current (or just finished) command; only intervals whose boundaries NMSh saw. */ +export interface InputTiming { + waitedMs: number; + waits: number; +} + +export interface InputState { + /** The shell explicitly entered its interactive reader; ownership lasts until the next prompt. */ + terminalOwner?: true; + request?: InputRequest; + timing: InputTiming; +} + +export const NO_INPUT_TIMING: InputTiming = Object.freeze({waitedMs: 0, waits: 0}); + +/** Waiting time including an open wait up to now. */ +export function waitedSoFar(state: InputState | undefined, now: number): number { + if (!state) return 0; + return state.timing.waitedMs + (state.request ? Math.max(0, now - state.request.since) : 0); +} + +/** Keys go to the program one by one (hidden line or key input) instead of through the composer's line editor. */ +export function directInput(request: InputRequest | undefined): boolean { + return request?.confidence === 'confirmed' && (request.mode === 'hidden' || request.mode === 'key'); +} diff --git a/src/session/liveStatus.ts b/src/session/liveStatus.ts index 37716dba..8cf6448a 100644 --- a/src/session/liveStatus.ts +++ b/src/session/liveStatus.ts @@ -33,8 +33,8 @@ export function knownProgram(session: SessionInfo): string | undefined { /** * Factual status from service evidence. Every clause is something the session * reported: what runs, whether it wrote recently, whether it asked for - * attention, the title it set, or how the last command ended. Nothing is - * inferred about what a program is waiting for; unknown stays unsaid. + * attention, the title it set, whether its input watch saw it wait for + * input, or how the last command ended. Unknown stays unsaid. */ export function liveStatusParts(session: SessionInfo, now: number): string[] { if (!session.running) { @@ -46,7 +46,11 @@ export function liveStatusParts(session: SessionInfo, now: number): string[] { const known = knownProgram(session); if (known) parts.push(known); else if (session.process && session.process !== commandWord(session.running)) parts.push(`process ${session.process}`); - if (session.attentionSince !== undefined) parts.push(`needs attention ${formatAge(now - session.attentionSince)}`); + // The service's input watch is the one source: confirmed or likely, never re-guessed here. + if (session.inputSince !== undefined) { + parts.push(`${session.inputConfidence === 'likely' ? 'probably waiting for input' : 'waiting for input'} ${formatAge(Math.max(0, now - session.inputSince))}`); + if (session.inputPrompt) parts.push(`“${session.inputPrompt.replace(/\s+/gu, ' ').slice(0, 40)}”`); + } else if (session.attentionSince !== undefined) parts.push(`needs attention ${formatAge(now - session.attentionSince)}`); else if (session.lastOutputAt !== undefined) { const quietFor = now - session.lastOutputAt; parts.push(quietFor < ACTIVE_OUTPUT_MS ? 'active' : `quiet ${formatAge(quietFor)}`); diff --git a/src/session/openLine.ts b/src/session/openLine.ts new file mode 100644 index 00000000..c569f501 --- /dev/null +++ b/src/session/openLine.ts @@ -0,0 +1,91 @@ +/** + * The line a running program left the cursor on, as far as its output stream shows it: the evidence a prompt + * leaves behind ("Fix? ", "Password: ", "Continue? [Y/n] "). Only plain text written left to right counts; + * a line the program rewrote (carriage-return progress) or drew with cursor addressing is marked unreliable, so + * a stalled progress bar or a redrawn status line is never mistaken for a question. + */ +const MAX_LINE = 240; +// CSI parameters and intermediates, then the final byte. +const CSI = /^\u001b\[[0-?]*[ -/]*([@-~])/u; +// OSC/DCS/APC/PM/SOS payloads end in BEL or ST. +const STRING = /^\u001b[\]P_^X][\s\S]*?(?:\u0007|\u001b\\)/u; + +export class OpenLine { + private text = ''; + private rewrites = 0; + private addressed = false; + private pendingReturn = false; + private carry = ''; + + push(data: string): void { + const input = this.carry + data; + this.carry = ''; + let index = 0; + while (index < input.length) { + const char = input[index]!; + if (this.pendingReturn) { + this.pendingReturn = false; + if (char !== '\n') { this.text = ''; this.rewrites += 1; } + } + if (char === '\u001b') { + const rest = input.slice(index); + const csi = CSI.exec(rest); + if (csi) { + // Cursor addressing (moves, column/row positioning) means the line is drawn, not written. + if (/[A-Hdf`]/u.test(csi[1]!)) this.addressed = true; + index += csi[0].length; + continue; + } + const string = STRING.exec(rest); + if (string) { index += string[0].length; continue; } + // Possibly a sequence split across reads: keep a bounded tail for the next chunk. + if (rest.length < 64 && /^\u001b(?:\[[0-?]*[ -/]*|[\]P_^X][^\u0007]*)?$/u.test(rest)) { this.carry = rest; break; } + // ESC + one byte (charset, keypad mode): no text. + index += Math.min(2, rest.length); + continue; + } + if (char === '\n') { this.text = ''; this.rewrites = 0; this.addressed = false; } + else if (char === '\r') this.pendingReturn = true; + else if (char === '\b') this.text = [...this.text].slice(0, -1).join(''); + else if (char === '\t') this.text += ' '; + else if (char >= ' ' && char !== '\u007f') this.text += char; + index += 1; + } + if (this.text.length > MAX_LINE) this.text = this.text.slice(-MAX_LINE); + } + + reset(): void { + this.text = ''; + this.rewrites = 0; + this.addressed = false; + this.pendingReturn = false; + this.carry = ''; + } + + /** The visible text left on the cursor's line (trailing spaces kept: they are part of a prompt). */ + get line(): string { return this.pendingReturn ? '' : this.text; } + + /** Rewritten in place or drawn with cursor addressing: not a line written once, so not prompt evidence. */ + get unreliable(): boolean { return this.rewrites > 0 || this.addressed; } + + /** Text was left on the line and nothing rewrote it. */ + get open(): boolean { return this.line.trim() !== '' && !this.unreliable; } +} + +/** + * The shape a question or entry prompt ends in: a question mark, a colon, or a closing bracket/angle that ends a + * choice list ("[Y/n]", "(yes/no)", "", ">>>"). A line ending in an ellipsis is a status ("Downloading..."), + * not a request. This is a shape test on the open line only; it never decides anything on its own. + */ +export function promptShaped(line: string): boolean { + const text = line.trimEnd(); + if (!text || /(?:\.\.\.|…)$/u.test(text)) return false; + return /[?:>\])»?:]$/u.test(text); +} + +/** The prompt as NMSh shows it: one line, control-free, bounded. */ +export function promptText(line: string, limit = 120): string | undefined { + const text = line.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').trim(); + if (!text) return undefined; + return text.length > limit ? `…${text.slice(-(limit - 1))}` : text; +} diff --git a/src/session/terminalProbe.ts b/src/session/terminalProbe.ts new file mode 100644 index 00000000..3f60f500 --- /dev/null +++ b/src/session/terminalProbe.ts @@ -0,0 +1,168 @@ +import {execFile} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {readdir, readFile, readlink} from 'node:fs/promises'; +import {arch as hostArch} from 'node:os'; + +/** + * Read-only facts about a session's terminal and its foreground process group, gathered from the operating system + * while a command runs and its output has gone quiet. Nothing here writes to the terminal, signals a process or + * reads input: `stty -a` on the terminal device only reports its line settings, `ps` lists processes, and Linux's + * /proc reports what a process is blocked in. Every fact is optional; what the platform cannot tell stays unknown. + */ +export interface TerminalModes { + /** ICANON: the kernel collects a line until Enter. Off means the program reads keys as they come. */ + canonical: boolean; + /** ECHO: typed characters are shown. Off while a program reads a password or a single key. */ + echo: boolean; + /** ISIG: Ctrl+C and Ctrl+Z become signals. Off in full raw mode (terminal proxies, editors). */ + signals: boolean; +} + +export interface ForegroundProcess { + pid: number; + /** ps state letters; R is running on a CPU. */ + state: string; + /** Cumulative CPU time in milliseconds, when the platform reports it. */ + cpuMs?: number; + command: string; +} + +export interface TerminalSample { + modes?: TerminalModes; + foreground: ForegroundProcess[]; + /** + * The kernel's report (Linux): true when a foreground process is blocked in read() on this terminal; false when + * every foreground thread is blocked in something else (sleeping, waiting on a socket or a child); undefined + * when the platform cannot tell or a thread is waiting in poll/select, whose descriptors are not visible. + */ + readingTerminal?: boolean; +} + +export interface ProbeTarget { + /** The terminal device, e.g. /dev/ttys015 or /dev/pts/3. */ + tty: string; +} + +export type TerminalProbe = (target: ProbeTarget) => Promise; + +const TIMEOUT_MS = 1500; + +function run(file: string, args: string[]): Promise { + return new Promise(resolve => { + execFile(file, args, {timeout: TIMEOUT_MS, maxBuffer: 256 * 1024, encoding: 'utf8', env: {LC_ALL: 'C', PATH: '/usr/bin:/bin'}}, + (error, stdout) => resolve(error ? undefined : stdout)); + }); +} + +/** Line settings from `stty -a` (BSD and GNU layouts): flags are whole words, `-flag` when off. */ +export function parseStty(text: string): TerminalModes | undefined { + const words = new Set(text.split(/[\s;]+/u)); + const flag = (name: string) => words.has(name) ? true : words.has(`-${name}`) ? false : undefined; + const canonical = flag('icanon'), echo = flag('echo'), signals = flag('isig'); + if (canonical === undefined || echo === undefined) return undefined; + return {canonical, echo, signals: signals ?? true}; +} + +/** CPU time as ps prints it: [[dd-]hh:]mm:ss[.cc]. */ +export function parseCpuTime(text: string): number | undefined { + const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)(?:\.(\d+))?$/u.exec(text.trim()); + if (!match) return undefined; + const [, days = '0', hours = '0', minutes, seconds, fraction = ''] = match; + return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60_000 + Number(seconds) * 1000 + Number(`0.${fraction || '0'}`) * 1000; +} + +/** + * The foreground process group of the terminal from `ps -o pid=,pgid=,tpgid=,stat=,time=,comm= -t TTY`: the + * processes whose group is the terminal's foreground group (pgid equals tpgid). + */ +export function parseForeground(text: string): ForegroundProcess[] { + const rows: ForegroundProcess[] = []; + for (const line of text.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(.*)$/u.exec(line); + if (!match || match[2] !== match[3]) continue; + const cpuMs = parseCpuTime(match[5]!); + rows.push({pid: Number(match[1]), state: match[4]!, ...(cpuMs === undefined ? {} : {cpuMs}), command: match[6]!.trim().split('/').pop() ?? ''}); + } + return rows.slice(0, 64); +} + +/** Syscall numbers of read(2)-family calls and of poll/select-family waits, per Linux architecture. */ +const LINUX_SYSCALLS: Record = { + x64: {read: [0, 17, 19, 295, 327], wait: [7, 23, 232, 270, 271, 281, 441]}, + arm64: {read: [63, 65, 67, 69, 286], wait: [22, 72, 73, 441]}, +}; + +/** + * One line of /proc/PID/task/TID/syscall: "NR ARG0 ... SP PC" while blocked in a call, "running" or "-1 ..." + * otherwise. Returns what the thread waits on: a read of descriptor fd, a poll/select wait, something else, or + * undefined while it runs. + */ +export function parseSyscall(text: string, arch = hostArch()): {kind: 'read'; fd: number} | {kind: 'wait' | 'other'} | undefined { + const fields = text.trim().split(/\s+/u); + const nr = Number(fields[0]); + if (!Number.isInteger(nr) || nr < 0) return undefined; + const table = LINUX_SYSCALLS[arch]; + if (!table) return {kind: 'other'}; + if (table.read.includes(nr)) { + const fd = Number.parseInt(fields[1] ?? '', 16); + return Number.isInteger(fd) ? {kind: 'read', fd} : {kind: 'other'}; + } + return table.wait.includes(nr) ? {kind: 'wait'} : {kind: 'other'}; +} + +/** Linux: whether the foreground group is blocked reading this terminal, from the kernel's per-thread report. */ +async function linuxReading(pids: number[], tty: string): Promise { + let unknown = false; + for (const pid of pids.slice(0, 16)) { + let threads: string[]; + try { threads = (await readdir(`/proc/${pid}/task`)).slice(0, 64); } catch { return undefined; } + for (const tid of threads) { + let text: string; + // Readable for the shell's own descendants; a privileged (setuid) program refuses, which stays unknown. + try { text = await readFile(`/proc/${pid}/task/${tid}/syscall`, 'utf8'); } catch { return undefined; } + const call = parseSyscall(text); + if (!call) { unknown = true; continue; } + if (call.kind === 'wait') { unknown = true; continue; } + if (call.kind !== 'read') continue; + let target: string; + try { target = await readlink(`/proc/${pid}/fd/${call.fd}`); } catch { unknown = true; continue; } + if (target === tty || target === '/dev/tty') return true; + } + } + return unknown ? undefined : false; +} + +async function linuxCpu(pid: number): Promise { + try { + const stat = await readFile(`/proc/${pid}/stat`, 'utf8'); + // Fields after the parenthesised command; utime and stime are the 12th and 13th of them, in clock ticks (100 Hz). + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + return (Number(fields[11]) + Number(fields[12])) * 10; + } catch { return undefined; } +} + +const STTY = ['/bin/stty', '/usr/bin/stty'].find(existsSync); +const PS = ['/bin/ps', '/usr/bin/ps'].find(existsSync); + +/** The probe for this platform, or undefined where NMSh has no read-only way to look (Windows, missing tools). */ +export function systemProbe(platform: NodeJS.Platform = process.platform): TerminalProbe | undefined { + if ((platform !== 'darwin' && platform !== 'linux') || !STTY || !PS) return undefined; + const linux = platform === 'linux'; + return async ({tty}) => { + const name = tty.replace(/^\/dev\//u, ''); + const [stty, ps] = await Promise.all([run(STTY, [linux ? '-F' : '-f', tty, '-a']), run(PS, ['-o', 'pid=,pgid=,tpgid=,stat=,time=,comm=', '-t', name])]); + if (stty === undefined && ps === undefined) return undefined; + let foreground = ps === undefined ? [] : parseForeground(ps); + let readingTerminal: boolean | undefined; + if (linux && foreground.length) { + // ps prints whole seconds on Linux; the kernel's tick counts are precise enough to see a busy process. + foreground = await Promise.all(foreground.map(async item => { + const cpuMs = await linuxCpu(item.pid); + return cpuMs === undefined ? item : {...item, cpuMs}; + })); + readingTerminal = await linuxReading(foreground.map(item => item.pid), tty); + } + const modes = stty === undefined ? undefined : parseStty(stty); + return {...(modes ? {modes} : {}), foreground, ...(readingTerminal === undefined ? {} : {readingTerminal})}; + }; +} diff --git a/src/sessions/LiveSessionView.ts b/src/sessions/LiveSessionView.ts index 99789bc5..d2c8182b 100644 --- a/src/sessions/LiveSessionView.ts +++ b/src/sessions/LiveSessionView.ts @@ -27,7 +27,9 @@ export function liveSessionRows(sessions: readonly SessionInfo[], currentId: str return [...sessions].sort((a, b) => a.createdAt - b.createdAt).map((session, index) => { const state = liveRowState(session, now); const agent = liveRowAgent(session); - const what = session.running ? `running ${sessionText(session.running.replace(/\s+/gu, ' ')).slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` + // The question comes first: it is what the person needs, and long command lines are cut at the row's end. + const prompt = session.running && session.inputPrompt ? `“${sessionText(session.inputPrompt).slice(0, 48)}” · ` : ''; + const what = session.running ? `${prompt}running ${sessionText(session.running.replace(/\s+/gu, ' ')).slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` : `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`; const attachment = session.id === currentId ? 'this window' : session.state === 'attached' ? 'open in another window' : 'detached'; const notice = session.notice ? ` · notice: ${session.notice.kind}` : ''; diff --git a/src/sessions/ResumeBrowser.ts b/src/sessions/ResumeBrowser.ts index 52d7972a..28213f2e 100644 --- a/src/sessions/ResumeBrowser.ts +++ b/src/sessions/ResumeBrowser.ts @@ -116,14 +116,15 @@ export function resumeDayLabel(timestamp: string, now = new Date()): string { /** * The one-word state a live row leads with, strongest fact first. Only what - * the service reports: a pending attention request, recent output, a running - * command, or how the last command ended. "Awaiting input" is claimed only - * when the program itself asked for attention. + * the service reports: its input watch seeing the command wait for input + * (confirmed or likely, never re-guessed here), a pending attention request, + * recent output, a running command, or how the last command ended. */ -export type LiveRowState = 'attention' | 'active' | 'running' | 'completed' | 'failed' | 'idle'; +export type LiveRowState = 'input' | 'inputLikely' | 'attention' | 'active' | 'running' | 'completed' | 'failed' | 'idle'; export function liveRowState(session: SessionInfo, now: number): LiveRowState { if (session.running) { + if (session.inputSince !== undefined) return session.inputConfidence === 'likely' ? 'inputLikely' : 'input'; if (session.attentionSince !== undefined) return 'attention'; if (session.lastOutputAt !== undefined && now - session.lastOutputAt < ACTIVE_OUTPUT_MS) return 'active'; return 'running'; @@ -134,7 +135,7 @@ export function liveRowState(session: SessionInfo, now: number): LiveRowState { } export const LIVE_ROW_LABELS: Record = { - attention: 'Needs attention', active: 'Active', running: 'Running', completed: 'Completed', failed: 'Failed', idle: 'Idle', + input: 'Waiting for input', inputLikely: 'Probably waiting', attention: 'Needs attention', active: 'Active', running: 'Running', completed: 'Completed', failed: 'Failed', idle: 'Idle', }; /** A known agent, only when the command's program word or the foreground process proves it. */ @@ -146,10 +147,11 @@ export function liveRowAgent(session: SessionInfo): AgentDescriptor | undefined export function describeLiveRow(session: SessionInfo, now: number): string { const where = tildePath(session.cwd); const attached = session.state === 'attached' ? 'open in another window' : 'detached'; + const asked = session.running && session.inputPrompt ? `“${sessionText(session.inputPrompt).slice(0, 40)}” · ` : ''; const what = session.running - ? `${sessionText(session.running.replace(/\s+/gu, ' ')).slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` + ? `${asked}${sessionText(session.running.replace(/\s+/gu, ' ')).slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` : `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}${session.lastExit !== undefined && session.lastExit !== 0 ? ` · last exit ${session.lastExit}` : ''}`; - const extra = session.title && session.running ? ` · “${sessionText(session.title).slice(0, 32)}”` : ''; + const extra = session.title && session.running && !session.inputPrompt ? ` · “${sessionText(session.title).slice(0, 32)}”` : ''; const name = session.name || session.signature; return `${name ? `${name} · ` : ''}${where} · ${what}${extra} · ${attached} · age ${formatAge(now - session.createdAt)}`; } diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index d915fb6a..ff39da30 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -85,6 +85,8 @@ function isTranscript(value: unknown): value is OutputTranscript { && transcript.records.every(record => record && (record.historyEligible === undefined || typeof record.historyEligible === 'boolean') && (record.startedAt === undefined || Number.isFinite(record.startedAt)) && (record.durationMs === undefined || (Number.isFinite(record.durationMs) && record.durationMs >= 0)) + && (record.inputWaitMs === undefined || (Number.isFinite(record.inputWaitMs) && record.inputWaitMs >= 0)) + && (record.inputWaits === undefined || (Number.isSafeInteger(record.inputWaits) && record.inputWaits >= 0)) && typeof record.command === 'string' && typeof record.output === 'string' && typeof record.lifecycleText === 'string' && typeof record.exitCode === 'number' && typeof record.startId === 'number' diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index a749f3b5..7e5455ea 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -2,10 +2,14 @@ export interface ShellMarker { exitCode: number; cwd: string; knowledge?: string; + /** Time the finished command spent waiting for input, and how many waits (see InputWatch); absent when none. */ + inputWaitMs?: number; + inputWaits?: number; } export type ProtocolEvent = | {kind: 'data'; data: string} + | {kind: 'reader'} | {kind: 'marker'; marker: ShellMarker} /** zsh preexec: a command line is about to run in the foreground. */ | {kind: 'exec'; command: string; historyAllowed?: number}; @@ -76,6 +80,7 @@ export class ShellProtocolDecoder { } private emit(payload: string, events: ProtocolEvent[]): void { + if (payload === 'reader') { events.push({kind: 'reader'}); return; } if (/^exec2;[01];/u.test(payload)) { events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); return; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 159f61fd..03b1594f 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -11,6 +11,9 @@ import { basename, join } from 'node:path'; import {bridgeEnvPath} from '../themeBridge/environment.js'; import {PENDING_INPUT_LIMIT, sanitizeStartupOutput, STARTUP_RAW_LIMIT, utf8Tail} from './startupOutput.js'; import { ShellProtocolDecoder, type ShellMarker } from './ShellProtocol.js'; +import {InputWatch} from '../session/InputWatch.js'; +import type {InputState} from '../session/inputState.js'; +import {systemProbe, type TerminalProbe} from '../session/terminalProbe.js'; interface SessionEvents { data: [string]; @@ -20,6 +23,8 @@ interface SessionEvents { inputRejected: [string, boolean]; exec: [string, number?]; exit: [{ exitCode: number; signal?: number }]; + /** Whether the running command credibly waits for input, and its waiting time (see InputWatch). */ + inputState: [InputState]; } /** node-pty's error for an ioctl on a PTY whose descriptor is already closed. */ @@ -52,10 +57,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 = ''; + /** One authoritative input-awareness state per session, next to the PTY. */ + readonly inputWatch: InputWatch; constructor(cwd: string, columns: number, rows: number, home = process.env.HOME || '', env: NodeJS.ProcessEnv = process.env, - shell: ShellId | ShellAdapter = 'zsh') { + shell: ShellId | ShellAdapter = 'zsh', probe: TerminalProbe | undefined = systemProbe()) { super(); + this.inputWatch = new InputWatch({probe, tty: () => this.ttyPath}); + this.inputWatch.on('change', state => this.emit('inputState', state)); this.adapter = typeof shell === 'string' ? shellAdapter(shell) : shell; this.order = new QueryOrder(data => { if (!this.exited) this.pty.write(data); }, QUERY_HOLD_MS, this.adapter.editorSettled); const token = randomBytes(12).toString('hex'); @@ -95,6 +104,7 @@ export class ShellSession extends EventEmitter { this.pty.onData(data => this.receive(data)); this.pty.onExit(event => { this.exited = true; + this.inputWatch.dispose(); this.pendingInput = ''; this.order.dispose(); if (this.startupTimer) { clearTimeout(this.startupTimer); this.startupTimer = undefined; } @@ -123,6 +133,12 @@ export class ShellSession extends EventEmitter { try { return this.pty.process || undefined; } catch { return undefined; } } + /** The PTY's terminal device (node-pty keeps it as a private field on Unix); undefined where unknown. */ + get ttyPath(): string | undefined { + const path = (this.pty as unknown as {_pty?: unknown})._pty; + return typeof path === 'string' && path.startsWith('/dev/') ? path : undefined; + } + /** Whether the first prompt has been reached. */ get isReady(): boolean { return this.ready; } @@ -152,7 +168,7 @@ export class ShellSession extends EventEmitter { */ private send(data: string, submission = false): void { if (submission) this.lastSubmitted = data.replace(/\r$/u, ''); - if (this.ready) { this.order.input(data); return; } + if (this.ready) { this.inputWatch.onInput(data); this.order.input(data); return; } if (this.pendingInputBytes() + Buffer.byteLength(data, 'utf8') <= PENDING_INPUT_LIMIT) this.pendingInput += data; else this.emit('inputRejected', data, submission); } @@ -161,11 +177,13 @@ export class ShellSession extends EventEmitter { // 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. this.order.discard(); + this.inputWatch.onInput('\u0003'); if (!this.exited) this.pty.write('\u0003'); } endInput(): void { // End of input is ordinary input: it keeps its place after anything typed before it. + this.inputWatch.onInput('\u0004'); this.order.input('\u0004'); } @@ -191,6 +209,7 @@ export class ShellSession extends EventEmitter { this.exited = true; this.pendingInput = ''; this.order.dispose(); + this.inputWatch.dispose(); if (this.startupTimer) { clearTimeout(this.startupTimer); this.startupTimer = undefined; } try { this.pty.kill(); } finally { this.cleanup(); } } @@ -204,13 +223,19 @@ export class ShellSession extends EventEmitter { else if (this.adapter.editorChrome === 'prompt-to-exec' && this.atPrompt) this.answerChrome(event.data); else { const data = this.answerEditor(this.commandStart && this.adapter.scrubCommandStart ? this.adapter.scrubCommandStart(event.data) : event.data); - if (data) { this.commandStart = false; this.emit('data', data); } + if (data) { this.commandStart = false; this.inputWatch.onOutput(data); this.emit('data', data); } } + } else if (event.kind === 'reader') { + if (this.ready && !this.atPrompt) this.inputWatch.onReader(); } else if (event.kind === 'exec') { this.atPrompt = false; this.commandStart = true; // Bash cannot report the text of a line it chose not to record; the submitted text stands in. - if (this.ready) this.emit('exec', event.command || (this.adapter.id === 'zsh' ? '' : this.lastSubmitted), event.historyAllowed); + if (this.ready) { + const command = event.command || (this.adapter.id === 'zsh' ? '' : this.lastSubmitted); + this.inputWatch.onExec(command); + this.emit('exec', command, event.historyAllowed); + } } else if (!this.ready) { this.atPrompt = true; this.ready = true; @@ -222,7 +247,10 @@ export class ShellSession extends EventEmitter { if (pending && !this.exited) this.pty.write(pending); } else { this.atPrompt = true; - this.emit('prompt', this.withKnowledge(event.marker)); + // 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} : {})}); } } } diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index de8ac0a3..d4ef5f6f 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -48,6 +48,12 @@ ${bridgeEnvPath ? bridgeBootstrap('fish', bridgeEnvPath) : ''} function fish_right_prompt; end function fish_mode_prompt; end function fish_title; end +# Fish 3's read editor does not advertise bracketed paste. Report its real +# reader event before it draws or accepts input; never infer ownership from +# command text, a prompt's appearance, or a delayed process probe. +function __nmsh_reader --on-event fish_read + ${marker('reader')} +end function __nmsh_knowledge begin printf 'jobs %d\\n' (count (jobs -p 2>/dev/null)) diff --git a/src/shell/capture.zsh b/src/shell/capture.zsh index 8d51e165..13f090ba 100755 --- a/src/shell/capture.zsh +++ b/src/shell/capture.zsh @@ -36,9 +36,15 @@ setopt rcquotes else zpty -w z source $1 fi + local initialization_output='' while (( EPOCHREALTIME < deadline )) && zpty -t z; do if zpty -r z line; then - [[ $line == ok* || $line == *$'\nok'* ]] && return + # Nonblocking reads can split the readiness line. Preserve its bytes + # within a fixed bound; never turn a trimmed line fragment into readiness. + (( ${#initialization_output} + ${#line} > 65536 )) && break + initialization_output+=$line + [[ $initialization_output == $'ok\r\n'* || $initialization_output == $'ok\n'* || + $initialization_output == *$'\nok\r\n'* || $initialization_output == *$'\nok\n'* ]] && return else sleep 0.005 fi diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts new file mode 100644 index 00000000..7feff4c2 --- /dev/null +++ b/src/status/inputStatus.ts @@ -0,0 +1,88 @@ +import {displayWidth, truncateText} from '../util/text.js'; +import {formatDuration} from './commandTiming.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {commandWord} from '../session/liveStatus.js'; +import {waitedSoFar, type InputRequest, type InputState, type InputTiming} from '../session/inputState.js'; + +/** + * How NMSh shows a command that needs the person. One state (InputWatch) drives every surface; this module only + * words and paints it. The look is NMSh's attention vocabulary: ◆ (confirmed) or ◇ (likely) in the accent with + * weight, never animated, so it reads differently from the shimmering "Running" line with or without color. + */ +const RESET = '\u001b[0m'; +const BOLD = '\u001b[1m'; + +export function inputGlyph(request: Pick): string { + const safe = getCurrentGlyphMode() === 'safe'; + return request.confidence === 'confirmed' ? (safe ? '!' : '◆') : (safe ? '?' : '◇'); +} + +/** The program the person is answering: what the platform reports in the foreground, else the command's word. */ +export function inputProgram(request: InputRequest, command: string): string { + return request.program || commandWord(command) || 'the command'; +} + +/** "Waiting for input" (confirmed) or "Probably waiting for input" (likely): the words carry the certainty. */ +export function inputHeadline(request: Pick): string { + return request.confidence === 'confirmed' ? 'Waiting for input' : 'Probably waiting for input'; +} + +/** How typing reaches the program, in a few words. */ +export function inputHint(request: InputRequest, program: string): string { + if (request.confidence === 'likely') return `Enter sends a line to ${program}`; + switch (request.mode) { + case 'hidden': return `typing is hidden and goes straight to ${program} · Enter sends`; + case 'key': return `each key goes straight to ${program}`; + case 'line': return `Enter sends a line to ${program}`; + } +} + +/** + * The two rows of the live activity region while a command waits: the attention line (headline, program, how long + * it has waited, and the total when it differs) and the prompt it left open with how input reaches it. Fits + * `columns` by dropping the least important parts first. + */ +export function inputActivityRows(state: InputState & {request: InputRequest}, command: string, startedAt: number, now: number, columns: number): [string, string] { + const request = state.request; + const program = inputProgram(request, command); + const accent = foreground(UI_COLORS.accent); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + // The wait comes from the session's clock and the total from this frontend's: never show a wait longer than the run. + const total = Math.max(0, now - startedAt); + const waitedMs = Math.min(Math.max(0, now - request.since), total); + const waiting = formatDuration(waitedMs); + const showTotal = total - waitedMs >= 1000; + const head = `${inputGlyph(request)} ${inputHeadline(request)}`; + const tails = [[program, waiting, ...(showTotal ? [`${formatDuration(total)} total`] : [])], [program, waiting], [waiting]]; + const tail = tails.find(parts => displayWidth(`${head} · ${parts.join(' · ')}`) <= columns) ?? []; + const first = `${accent}${BOLD}${truncateText(head, columns)}${RESET}${tail.length ? `${secondary} · ${tail.join(' · ')}${RESET}` : ''}`; + const prompt = request.prompt ?? ''; + const hint = inputHint(request, program); + const indent = ' '; + const room = Math.max(1, columns - displayWidth(indent)); + let second: string; + // The question outranks the hint: the hint only shows when both fit; otherwise the question takes the row. + if (!prompt) second = `${indent}${subtle}${truncateText(hint, room)}${RESET}`; + else if (displayWidth(`${prompt} · ${hint}`) <= room) second = `${indent}${BOLD}${prompt}${RESET}${subtle} · ${hint}${RESET}`; + else second = `${indent}${BOLD}${truncateText(prompt, room)}${RESET}`; + return [first, second]; +} + +/** A running line's extra detail once the command has waited before: " · waited 18s" (never more than `elapsedMs`). */ +export function waitedDetail(state: InputState | undefined, now: number, elapsedMs = Number.POSITIVE_INFINITY): string { + const waited = Math.min(waitedSoFar(state, now), elapsedMs); + return waited >= 1000 ? ` · waited ${formatDuration(waited)}` : ''; +} + +/** The completion fact: " · 18s waiting for input" (or "in 3 waits"); empty when the command never waited. */ +export function completionWaitFact(timing: InputTiming | undefined): string { + if (!timing?.waits || timing.waitedMs < 1000) return ''; + return ` · ${formatDuration(timing.waitedMs)} waiting for input${timing.waits > 1 ? ` (${timing.waits} prompts)` : ''}`; +} + +/** The composer's placeholder while keys go straight to the program (hidden or key input). Never shows what is typed. */ +export function directInputPlaceholder(request: InputRequest, program: string): string { + return request.mode === 'hidden' ? `Hidden input for ${program} · nothing you type is shown` : `Keys go straight to ${program}`; +} diff --git a/src/terminal/TerminalRenderer.ts b/src/terminal/TerminalRenderer.ts index 171468d4..9cb49577 100644 --- a/src/terminal/TerminalRenderer.ts +++ b/src/terminal/TerminalRenderer.ts @@ -48,6 +48,7 @@ export class TerminalRenderer { /** NMSh's caret style while it owns the composer; '' leaves the host's cursor alone. */ private cursorStyle = ''; private readonly childModes = new AlternateScreenTracker(); + private preEntryPassthrough = false; constructor(private readonly write: (data: string) => unknown = data => process.stdout.write(data), private capabilities: Readonly = BASELINE_CAPABILITIES) {} @@ -108,6 +109,13 @@ export class TerminalRenderer { enter(): void { if (this.active) return; + if (this.preEntryPassthrough) { + // Raw startup output may have pushed a child keyboard mode on MAIN. + // Release those owned pushes before entry restores it on ALTERNATE. + this.write(this.childModes.releaseKeyboardSequence()); + this.childModes.reset('alternate'); + this.preEntryPassthrough = false; + } this.active = true; // Push alt screen FIRST, then push kitty mode onto the alt screen's stack this.suspended = false; @@ -208,7 +216,10 @@ export class TerminalRenderer { /** Observe only bytes actually sent to the host terminal while the child owns it. */ observePassthrough(data: string): void { - if (this.suspended) this.childModes.observeModes(data); + if (this.suspended || !this.active) { + this.childModes.observeModes(data); + if (!this.active) this.preEntryPassthrough = true; + } } resumeAfterPassthrough(): void { @@ -229,7 +240,14 @@ export class TerminalRenderer { } leave(): void { - if (!this.active) return; + if (!this.active) { + if (this.preEntryPassthrough) { + this.write(`${this.reconcileModes(false)}\u001B[?25h\u001B[?1049l`); + this.childModes.reset(); + this.preEntryPassthrough = false; + } + return; + } this.clearOverlay(); this.active = false; this.previous = []; diff --git a/src/terminal/keyBytes.ts b/src/terminal/keyBytes.ts new file mode 100644 index 00000000..ca5ab363 --- /dev/null +++ b/src/terminal/keyBytes.ts @@ -0,0 +1,33 @@ +import type {Key} from './keys.js'; + +/** + * The bytes an ordinary terminal sends for a key, for a program reading the terminal directly (a hidden line or + * single keys) while NMSh's own terminal speaks an extended keyboard protocol. Only keys with one conventional + * encoding are sent; NMSh's own gestures (selection, history search, palette, focus) and pointer events are not, + * so nothing the program did not ask for reaches it. Ctrl+C and Ctrl+D are the caller's (signal and end of input). + */ +const PLAIN: Partial> = { + enter: '\r', + // Shift+Enter has no meaning to a program reading a line or keys: it submits, as Enter does. + newline: '\r', + backspace: '\u007f', + delete: '\u001b[3~', + complete: '\t', + focusPrevious: '\u001b[Z', + escape: '\u001b', + up: '\u001b[A', + down: '\u001b[B', + right: '\u001b[C', + left: '\u001b[D', + lineHome: '\u001b[H', + lineEnd: '\u001b[F', + // The line discipline's own editing keys: word and line erase. + deleteWord: '\u0017', + deleteLineBefore: '\u0015', + suspend: '\u001a', +}; + +export function directKeyBytes(key: Key): string | undefined { + if (key.kind === 'text' || key.kind === 'paste') return key.value; + return PLAIN[key.kind]; +} diff --git a/tests/commandNotifications.test.ts b/tests/commandNotifications.test.ts index 69653979..5cbc6722 100644 --- a/tests/commandNotifications.test.ts +++ b/tests/commandNotifications.test.ts @@ -125,6 +125,7 @@ test('backend rejection and throw cannot prevent shell completion; passthrough a Object.defineProperty(instance, 'renderer', {value: new TerminalRenderer(data => writes.push(data))}); try { instance['renderer'].enter(); + instance['rendererEntered'] = true; // Match run()'s entered-renderer phase before exercising handoff. instance['terminalFocus'] = 'focused'; instance['onActiveModeChange']('PASSTHROUGH'); assert.equal(instance['terminalFocus'], 'unknown'); diff --git a/tests/contextEngine.test.ts b/tests/contextEngine.test.ts index 5dec89e5..3033bca6 100644 --- a/tests/contextEngine.test.ts +++ b/tests/contextEngine.test.ts @@ -542,3 +542,27 @@ test('saved Rail routing reaches the real frontend renderer and command-context 'live Rail context stays out of PTY command output'); } finally { await box.dispose(); } }); + +test('authenticated prompt cwd reaches history before asynchronous context refresh, preserving privacy', () => { + for (const provider of ['none', 'nmsh'] as const) { + for (const persistence of ['snapshot-safe', 'display-only', 'never-store'] as const) { + const app = new TerminalApp(); + try { + app['promptConfiguration'] = normalizePromptConfiguration({...config(), provider}); + app['effectivePromptProvider'] = provider; + const previous = {cwd: '/previous', project: 'previous'}; + const facts = promptFacts(previous); + app['context'] = {...previous, facts: {...facts, cwd: {...facts.cwd!, persistence}}}; + app['refreshContext'] = async () => {}; + app['render'] = () => {}; + app['onShellPrompt'](0, '/authenticated/origin'); + const historical = app['historicalContext'](app['shellCwd'], app['context'], 'printf original'); + assert.equal(historical.cwd, persistence === 'snapshot-safe' ? '/authenticated/origin' : '', + `${provider}/${persistence}: command directory cannot come from the previous prompt`); + if (provider === 'nmsh') assert.equal(historical.prompt?.cwd, historical.cwd, + 'Native prompt and command metadata agree on the same permitted directory'); + assert.equal(app['context'].facts?.cwd?.persistence, persistence, 'refresh keeps the directory privacy policy'); + } finally { app['stop'](0); app['session'].kill(); } + } + } +}); diff --git a/tests/fixtures/askyn.py b/tests/fixtures/askyn.py new file mode 100755 index 00000000..c3f0b79b --- /dev/null +++ b/tests/fixtures/askyn.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""A harmless stand-in for e2fsck's yes/no questions: the terminal in single-key, no-echo mode (as e2fsck's ask_yn sets +it), one key per answer, Enter takes the default. Repairs nothing; prints what it was told.""" +import os, sys, termios, tty + +questions = sys.argv[1:] or ['Padding at end of inode bitmap is not set. Fix? '] +fd = sys.stdin.fileno() +saved = termios.tcgetattr(fd) +answers = [] +try: + for question in questions: + mode = termios.tcgetattr(fd) + mode[3] &= ~(termios.ICANON | termios.ECHO) + mode[6][termios.VMIN], mode[6][termios.VTIME] = 1, 0 + termios.tcsetattr(fd, termios.TCSANOW, mode) + sys.stdout.write(question); sys.stdout.flush() + key = os.read(fd, 1).decode(errors='replace') + termios.tcsetattr(fd, termios.TCSANOW, saved) + answer = 'yes' if key in ('y', 'Y', '\r', '\n') else 'no' + answers.append(answer) + sys.stdout.write(answer + '\n\n'); sys.stdout.flush() +finally: + termios.tcsetattr(fd, termios.TCSANOW, saved) +print('ANSWERS=' + ','.join(answers)) diff --git a/tests/fixtures/earlyStartupSubmission.ts b/tests/fixtures/earlyStartupSubmission.ts new file mode 100644 index 00000000..92a0b550 --- /dev/null +++ b/tests/fixtures/earlyStartupSubmission.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import {TerminalApp} from '../../src/app/TerminalApp.js'; + +// Submit through the real frontend before its startup notice can fire. The +// service test must not depend on disk IO leaving a visible composer window. +const run = TerminalApp.prototype.run; +TerminalApp.prototype.run = async function() { + assert.equal(process.argv.includes('--attach'), false, 'fresh frontend fixture only'); + assert.equal(this['startupPending'], true); + assert.equal(this['startupPanel'], undefined, 'submission precedes the startup notice'); + this['editor'].insert('echo HELD-$((1+1))'); + await this['submit'](); + process.stdout.write('TEST-EARLY-SUBMISSION\n'); + return run.call(this); +}; +await import('../../src/index.js'); diff --git a/tests/fixtures/heldStartupJournal.ts b/tests/fixtures/heldStartupJournal.ts new file mode 100644 index 00000000..d16d28d4 --- /dev/null +++ b/tests/fixtures/heldStartupJournal.ts @@ -0,0 +1,19 @@ +import {existsSync} from 'node:fs'; +import {setTimeout as sleep} from 'node:timers/promises'; +import {TranscriptStore} from '../../src/sessions/TranscriptStore.js'; + +// Hold actual initial persistence, after SessionJournal has created its state. +// CLI, signal dispatch, detached helpers and session service all remain real. +const gate = process.env.NMSH_TEST_JOURNAL_GATE; +if (!gate) throw new Error('a private journal gate is required'); +const save = TranscriptStore.prototype.save; +let held = false; +TranscriptStore.prototype.save = async function(...args) { + if (!held) { + held = true; + process.stdout.write('TEST-STARTUP-JOURNAL-WAIT\n'); + while (!existsSync(gate)) await sleep(10); + } + return save.apply(this, args); +}; +await import('../../src/index.js'); diff --git a/tests/fixtures/slowJournalAttach.ts b/tests/fixtures/slowJournalAttach.ts new file mode 100644 index 00000000..bbd711b9 --- /dev/null +++ b/tests/fixtures/slowJournalAttach.ts @@ -0,0 +1,16 @@ +import {SessionJournal} from '../../src/sessions/SessionJournal.js'; + +// Deterministic slow disk seam. The actual CLI, session protocol, frontend and both PTYs stay real. +const continueJournal = SessionJournal.prototype.continue; +SessionJournal.prototype.continue = async function(existing) { + process.stdout.write('TEST-JOURNAL-WAIT\n'); + await new Promise(done => setTimeout(done, 1000)); + return continueJournal.call(this, existing); +}; +const startJournal = SessionJournal.prototype.start; +SessionJournal.prototype.start = async function() { + process.stdout.write('TEST-JOURNAL-WAIT\n'); + await new Promise(done => setTimeout(done, 1000)); + return startJournal.call(this); +}; +await import('../../src/index.js'); diff --git a/tests/fullscreenFailure.test.ts b/tests/fullscreenFailure.test.ts new file mode 100644 index 00000000..d1c30372 --- /dev/null +++ b/tests/fullscreenFailure.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fullscreenFailure} from './helpers/fullscreenFailure.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('fullscreen failure observation reads both owned PTYs without resizing or submitting input', async () => { + const sandbox = new LiveSandbox(); + try { + const frontend = sandbox.launch([], {cols: 90, rows: 33}); + await frontend.waitFor(/❯/); + await until(async () => (await sandbox.sessions())[0]?.lastExit !== undefined, 15000, 'authenticated startup'); + const {id, pid} = (await sandbox.sessions())[0]!; + const observation = await fullscreenFailure(sandbox, id, pid, frontend); + assert.ok('session' in observation, JSON.stringify(observation)); + assert.equal(observation.session.id, id); + assert.equal(observation.session.pid, pid); + assert.equal(observation.session.runningPresent, false); + assert.ok('size' in observation.frontend, JSON.stringify(observation.frontend)); + assert.equal(observation.frontend.size.status, 0); + assert.equal(observation.frontend.size.output, '33 90'); + assert.ok('size' in observation.backend, JSON.stringify(observation.backend)); + assert.equal(observation.backend.size.status, 0); + assert.match(observation.backend.size.output ?? '', /^\d+ 90$/); + assert.notEqual(observation.backend.tty, observation.frontend.tty, 'inner and outer PTYs are distinct'); + assert.ok(observation.processes.some(value => value.pid === pid)); + assert.ok(observation.children.length > 0, 'failed or empty child queries retain their status'); + const again = await fullscreenFailure(sandbox, id, pid, frontend); + assert.ok('session' in again, JSON.stringify(again)); + assert.deepEqual(again.backend, observation.backend); + assert.deepEqual(again.frontend, observation.frontend); + assert.equal(again.session.runningPresent, false); + await frontend.run('echo PROBE-UNCHANGED', /PROBE-UNCHANGED/); + } finally { + await sandbox.dispose(); + } +}); + +test('fullscreen failure observation refuses a mismatched live identity before OS probes', async () => { + let calls = 0; + const sandbox = {sessions: async () => { calls++; return [{id: 'owned', pid: process.pid}]; }}; + // Neither mismatch may inspect the supplied process or dereference a frontend. + const fakeFrontend = undefined as unknown as Parameters[3]; + assert.deepEqual(await fullscreenFailure(sandbox as unknown as LiveSandbox, 'other', process.pid, fakeFrontend), + {unavailable: 'live session does not match the owned backend'}); + assert.deepEqual(await fullscreenFailure(sandbox as unknown as LiveSandbox, 'owned', 1, fakeFrontend), + {unavailable: 'live session does not match the owned backend'}); + assert.equal(calls, 2); +}); diff --git a/tests/fullscreenResizeSampling.test.ts b/tests/fullscreenResizeSampling.test.ts new file mode 100644 index 00000000..f1b6d1d0 --- /dev/null +++ b/tests/fullscreenResizeSampling.test.ts @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fullscreenFailure} from './helpers/fullscreenFailure.js'; +import {fullscreenFixture} from './helpers/fullscreenFixture.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('fullscreen fixture repaints the latest size when WINCH arrives during an older external size sample', async () => { + const sandbox = new LiveSandbox(); + try { + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; + const bin = join(sandbox.home, 'bin'); + const sample = join(sandbox.home, 'sample'); + const release = join(sandbox.home, 'release'); + const armed = join(sandbox.home, 'armed'); + const progress = join(sandbox.home, 'loops'); + const stop = join(sandbox.home, 'stop'); + mkdirSync(bin); + const stty = process.platform === 'darwin' ? '/bin/stty' : '/usr/bin/stty'; + // Only the fixture's explicit size query is held; bootstrap termios calls + // still use real stty. Sampling precedes the gate, as it can under load. + writeFileSync(join(bin, 'stty'), [ + '#!/bin/bash', + `if [ "$#" != 1 ] || [ "$1" != size ]; then exec ${stty} "$@"; fi`, + `size=$(${stty} size)`, + `if [ "$size" = '32 90' ] && [ -f ${quote(armed)} ]; then`, + ` printf '%s' "$size" > ${quote(sample)}`, + ` while [ ! -f ${quote(release)} ]; do sleep 0.01; done`, + `fi`, + `printf '%s\\n' "$size"`, + ].join('\n'), {mode: 0o700}); + const fixture = join(sandbox.home, 'fullscreen.zsh'); + writeFileSync(fixture, fullscreenFixture(stop, progress)); + const first = sandbox.launch([], undefined, {PATH: `${bin}:${process.env.PATH}`}); + await first.waitFor(/❯/); + first.pty.write(`zsh ${fixture}\r`); + await first.waitFor(/TRAP-READY/); + const {id, pid} = (await sandbox.sessions())[0]!; + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'fixture detach'); + const second = sandbox.launch(['--attach', id], {cols: 90, rows: 33}); + await second.waitFor(/REDRAW 33 90/); + await until(() => existsSync(progress), 15000, 'fixture main loop'); + const loop = Number(readFileSync(progress, 'utf8')); + await until(() => Number(readFileSync(progress, 'utf8')) > loop, 15000, 'trap completion and main-loop continuation'); + writeFileSync(armed, ''); + second.pty.resize(90, 32); + await until(() => existsSync(sample), 15000, 'held external size sample'); + assert.equal(readFileSync(sample, 'utf8'), '32 90'); + second.pty.resize(90, 33); + let lastObservation = ''; + await until(async () => { + const current = await fullscreenFailure(sandbox, id, pid, second); + lastObservation = JSON.stringify(current); + return 'backend' in current && 'size' in current.backend && current.backend.size.output === '33 90'; + }, 15000, () => `independent managed PTY size: ${lastObservation}`); + const observation = await fullscreenFailure(sandbox, id, pid, second); + assert.ok('session' in observation, JSON.stringify(observation)); + assert.equal(observation.session.fullscreen, 1); + assert.equal(observation.session.runningPresent, true); + assert.ok('size' in observation.frontend, JSON.stringify(observation.frontend)); + assert.equal(observation.frontend.size.output, '33 90'); + // Mark after the held window: historical redraws cannot satisfy the new one. + const mark = second.mark; + writeFileSync(release, ''); + try { + await second.waitFor(/REDRAW 33 90/, mark); + } catch (error) { + if (error instanceof Error) error.message += `; actual geometry=${JSON.stringify(await fullscreenFailure(sandbox, id, pid, second))}`; + throw error; + } + writeFileSync(stop, ''); + await second.waitFor(/❯/, second.output.lastIndexOf('REDRAW')); + await second.run('echo AFTER-SAMPLED-RESIZE', /AFTER-SAMPLED-RESIZE/); + } finally { + await sandbox.dispose(); + } +}); diff --git a/tests/helpers/fullscreenFailure.ts b/tests/helpers/fullscreenFailure.ts new file mode 100644 index 00000000..e37c4af1 --- /dev/null +++ b/tests/helpers/fullscreenFailure.ts @@ -0,0 +1,82 @@ +import {spawnSync} from 'node:child_process'; +import {readFileSync, readlinkSync} from 'node:fs'; +import type {SessionInfo} from '../../src/session/SessionProtocol.js'; +import type {Frontend, LiveSandbox} from './liveFrontend.js'; + +// Failure-only, read-only observations. No terminal input, resize, signals, +// environment or command contents; every subprocess has a fixed bound. +function probe(command: string, args: string[], deadline: number) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return {status: null, signal: null, error: 'diagnostic budget exhausted', output: '', stderr: ''}; + const result = spawnSync(command, args, {encoding: 'utf8', timeout: Math.min(1000, remaining), maxBuffer: 16 * 1024}); + return {status: result.status, signal: result.signal, error: result.error?.message, + output: result.stdout?.trim().slice(0, 2048), stderr: result.stderr?.trim().slice(0, 512)}; +} + +type TerminalObservation = {unavailable: string; ttyProbe?: ReturnType} | {tty: string; size: ReturnType}; + +function terminalSize(pid: number, deadline: number): TerminalObservation { + if (!Number.isSafeInteger(pid) || pid <= 1) return {unavailable: 'invalid owned PID'}; + if (process.platform === 'linux') { + const fd = `/proc/${pid}/fd/0`; + try { + const tty = readlinkSync(fd); + if (!/^\/dev\/pts\/\d+$/.test(tty)) return {unavailable: 'stdin is not a PTY slave'}; + return {tty, size: probe('stty', ['-F', fd, 'size'], deadline)}; + } catch (error) { return {unavailable: String(error)}; } + } + if (process.platform === 'darwin') { + const ttyProbe = probe('ps', ['-p', String(pid), '-o', 'tty='], deadline); + const tty = ttyProbe.output; + if (ttyProbe.status !== 0 || !tty || !/^ttys\d+$/.test(tty)) return {unavailable: 'no validated PTY slave', ttyProbe}; + return {tty, size: probe('stty', ['-f', `/dev/${tty}`, 'size'], deadline)}; + } + return {unavailable: `unsupported platform ${process.platform}`}; +} + +function children(pid: number, deadline: number) { + const result = probe('pgrep', ['-P', String(pid)], deadline); + const pids = result.status === 0 ? (result.output ?? '').split('\n').map(Number) + .filter(value => Number.isSafeInteger(value) && value > 1).slice(0, 8) : []; + return {parent: pid, pids, probe: result}; +} + +function processState(pid: number, deadline: number) { + const state = probe('ps', ['-p', String(pid), '-o', 'pid=,ppid=,pgid=,state=,comm='], deadline); + if (process.platform !== 'linux') return {pid, state}; + try { + const status = readFileSync(`/proc/${pid}/status`, 'utf8').split('\n') + .filter(line => /^(State|Pid|PPid|NSpgid|NSsid|SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt):/.test(line)); + const wchan = readFileSync(`/proc/${pid}/wchan`, 'utf8').trim().slice(0, 128); + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + return {pid, state, status, wchan, ttyNumber: fields[4], foregroundGroup: fields[5]}; + } catch (error) { return {pid, state, unavailable: String(error)}; } +} + +type FullscreenObservation = {unavailable: string} | { + session: Pick & {runningPresent: boolean}; + backend: TerminalObservation; + frontend: TerminalObservation; + children: ReturnType[]; + processes: ReturnType[]; +}; + +export async function fullscreenFailure(sandbox: LiveSandbox, id: string, backendPid: number, frontend: Frontend): Promise { + const deadline = Date.now() + 5000; + try { + const session = (await sandbox.sessions()).find(value => value.id === id); + if (!session || session.pid !== backendPid || !Number.isSafeInteger(backendPid) || backendPid <= 1) { + return {unavailable: 'live session does not match the owned backend'}; + } + const backend = terminalSize(backendPid, deadline); + const outer = terminalSize(frontend.pty.pid, deadline); + const direct = children(backendPid, deadline); + const descendants = direct.pids.map(pid => children(pid, deadline)); + return {session: {id: session.id, pid: session.pid, state: session.state, fullscreen: session.fullscreen, + runningPresent: session.running !== undefined, lastExit: session.lastExit}, + backend, frontend: outer, + children: [direct, ...descendants], + processes: [...new Set([backendPid, ...direct.pids, ...descendants.flatMap(value => value.pids)])].slice(0, 12).map(pid => processState(pid, deadline))}; + } catch (error) { return {unavailable: String(error)}; } +} diff --git a/tests/helpers/fullscreenFixture.ts b/tests/helpers/fullscreenFixture.ts new file mode 100644 index 00000000..e239c157 --- /dev/null +++ b/tests/helpers/fullscreenFixture.ts @@ -0,0 +1,22 @@ +/** Shared real fullscreen fixture; an optional loop counter is a test synchronization seam. */ +export function fullscreenFixture(stop: string, progressPath?: string): string { + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; + return [ + `printf '\\e[?1049h'`, + // Query outside the trap: a WINCH during stty must request another repaint. + `redraw=0`, + `trap 'redraw=1' WINCH`, + `printf 'TRAP-READY\\n'`, + `loops=0`, + `while [ ! -f ${quote(stop)} ]; do`, + ` if [ "$redraw" = 1 ]; then`, + // Clear before sampling so a later WINCH is not erased by this repaint. + ` redraw=0`, + ` printf "\\e[H\\e[2JREDRAW %s\\n" "$(stty size)"`, + ` fi`, + ...(progressPath ? [` loops=$((loops+1))`, ` printf '%s' "$loops" > ${quote(progressPath)}`] : []), + ` sleep 0.05`, + `done`, + `printf '\\e[?1049l'`, + ].join('\n'); +} diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index e72ac4e7..58d278e7 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -58,10 +58,10 @@ export class LiveSandbox { /** The journals frontends in this sandbox wrote. */ transcripts() { return new TranscriptStore(join(this.config, 'nmsh', 'sessions')); } - launch(args: string[] = [], size = {cols: 100, rows: 30}, attachmentEnv: Record = {}): Frontend { + launch(args: string[] = [], size = {cols: 100, rows: 30}, attachmentEnv: Record = {}, entry = ENTRY): Frontend { // Run outside the repository: a SIGKILLed frontend must never leave git // state (index.lock from a prompt's git status) behind in the checkout. - return this.trackFrontend(nodePty.spawn(process.execPath, [`--import=${TSX}`, ENTRY, ...args], + return this.trackFrontend(nodePty.spawn(process.execPath, [`--import=${TSX}`, entry, ...args], {cwd: this.home, ...size, env: {...this.env, ...attachmentEnv} as Record})); } @@ -157,11 +157,12 @@ export class LiveSandbox { export class Frontend { output = ''; exitCode: number | undefined; + exitSignal: number | undefined; readonly exited: Promise; constructor(readonly pty: IPty, private readonly completedCount: (command: string) => Promise) { pty.onData(data => { this.output += data; }); - this.exited = new Promise(resolve => pty.onExit(event => { this.exitCode = event.exitCode; resolve(event.exitCode); })); + this.exited = new Promise(resolve => pty.onExit(event => { this.exitCode = event.exitCode; this.exitSignal = event.signal; resolve(event.exitCode); })); } get mark(): number { return this.output.length; } @@ -174,7 +175,7 @@ export class Frontend { } catch (error) { const state = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,state=,comm=', '-p', String(this.pty.pid)], {encoding: 'utf8', timeout: 2000}); if (error instanceof Error) error.message += `; frontend=${JSON.stringify({pid: this.pty.pid, elapsedMs: Date.now() - started, - exitCode: this.exitCode, outputBytes: this.output.length, alive: processAlive(this.pty.pid), + exitCode: this.exitCode, exitSignal: this.exitSignal, outputBytes: this.output.length, alive: processAlive(this.pty.pid), state: state.stdout?.trim(), stateStatus: state.status, stateError: state.error?.message})}`; throw error; } diff --git a/tests/helpers/nativeCompletion.ts b/tests/helpers/nativeCompletion.ts new file mode 100644 index 00000000..37e90df2 --- /dev/null +++ b/tests/helpers/nativeCompletion.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import {runExternal} from '../../src/providers/providers.js'; + +/** Prepare only a fixture-owned completion cache; never source host dotfiles. */ +export async function prepareNativeCompletionHome(fixtureHome: string): Promise { + const ready = await runExternal('zsh', ['-f', '-c', 'autoload -Uz compinit; compinit -d "$HOME/.zcompdump_capture"'], { + cwd: fixtureHome, env: {...process.env, HOME: fixtureHome}, timeoutMs: 10_000, + }); + assert.ok(ready.ok, 'fixture completion setup succeeds before capture assertions'); +} diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts new file mode 100644 index 00000000..5ae4ad5d --- /dev/null +++ b/tests/inputAwareness.test.ts @@ -0,0 +1,441 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {OpenLine, promptShaped, promptText} from '../src/session/openLine.js'; +import {parseCpuTime, parseForeground, parseStty, parseSyscall, type TerminalSample} from '../src/session/terminalProbe.js'; +import {InputWatch} from '../src/session/InputWatch.js'; +import {directInput, waitedSoFar, type InputState} from '../src/session/inputState.js'; +import {decodeMessage, encodeMessage, inputStateFrom, inputStateMessage, parseClientFeatures, type SessionInfo} from '../src/session/SessionProtocol.js'; +import {describeNotice, noticeVisibleMs, SessionNoticeTracker} from '../src/session/SessionNotices.js'; +import {liveStatusParts} from '../src/session/liveStatus.js'; +import {LIVE_ROW_LABELS, liveRowState} from '../src/sessions/ResumeBrowser.js'; +import {directKeyBytes} from '../src/terminal/keyBytes.js'; +import {completionWaitFact, directInputPlaceholder, inputActivityRows, waitedDetail} from '../src/status/inputStatus.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +function withEnv(patch: Record, run: () => T): T { + const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]])); + for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + try { return run(); } finally { + for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + } +} + +// ------------------------------------------------------------------ open line + +test('a shell reader owns input until the command returns, without claiming a wait', () => { + const watch = new InputWatch(); + watch.onExec('read -s -P Password: value', 10); + watch.onReader(); + assert.equal(watch.ownsTerminal, true); + assert.equal(watch.state.terminalOwner, true); + assert.equal(watch.state.request, undefined); + const decoded = decodeMessage(encodeMessage(inputStateMessage(watch.state)).trimEnd()); + assert.ok(decoded.ok); + assert.deepEqual(inputStateFrom(decoded.message as ReturnType), watch.state); + watch.onPrompt(20); + assert.equal(watch.state.terminalOwner, undefined); + watch.onExec('sleep 1', 30); + assert.equal(watch.ownsTerminal, false); + watch.dispose(); +}); + +test('open line: a prompt left without a newline is open; a finished line, a rewrite or cursor drawing is not', () => { + const line = new OpenLine(); + line.push('Pass 1: Checking inodes\r\nPadding at end of inode bitmap is not set. Fix? '); + assert.equal(line.line, 'Padding at end of inode bitmap is not set. Fix? '); + assert.equal(line.open, true); + line.push('yes\r\n\r\n'); + assert.equal(line.open, false, 'the echoed answer ends the line'); + + line.push('[=====> ] 45%'); + line.push('\r[=======> ] 61%'); + assert.equal(line.open, false, 'a carriage-return progress bar rewrites its line'); + line.push('\r\n'); + line.push('\u001b[2K\u001b[1Gstatus: building'); + assert.equal(line.open, false, 'a line drawn with cursor addressing is not prompt evidence'); + line.push('\n'); + line.push('\u001b[32mContinue?\u001b[0m [y/N] '); + assert.equal(line.line, 'Continue? [y/N] ', 'colors are not text'); + assert.equal(line.open, true); +}); + +test('open line: escape sequences and CRLF split across reads; backspace edits; OSC titles are not text', () => { + const line = new OpenLine(); + line.push('Name: \u001b'); + line.push(']0;my title\u0007'); + assert.equal(line.line, 'Name: '); + line.push('ab\b'); + assert.equal(line.line, 'Name: a'); + line.push('\r'); + assert.equal(line.line, '', 'a lone carriage return may be a rewrite or the start of CRLF'); + line.push('\n'); + assert.equal(line.open, false); + assert.equal(line.unreliable, false, 'CRLF split across reads is a newline, not a rewrite'); +}); + +test('prompt shape: question and entry endings count; statuses and ellipses do not', () => { + for (const prompt of ['Fix? ', 'Password:', 'Continue? [Y/n] ', 'Overwrite (yes/no)', '>>> ', 'Choose 1-3 »']) assert.equal(promptShaped(prompt), true, prompt); + for (const status of ['Downloading...', 'Compiling crate…', 'building 45%', '']) assert.equal(promptShaped(status), false, status); + assert.equal(promptText(' Fix? '), 'Fix?'); + assert.equal(promptText('\u0007'), undefined); + assert.equal(promptText('x'.repeat(300))!.length, 120); +}); + +// ------------------------------------------------------------------ probe parsers + +test('stty: BSD and GNU layouts; flags are whole words', () => { + const bsd = 'speed 9600 baud; 24 rows; 80 columns;\nlflags: -icanon isig iexten -echo echoe echok echoke -echonl echoctl\n'; + assert.deepEqual(parseStty(bsd), {canonical: false, echo: false, signals: true}); + const gnu = 'speed 38400 baud; rows 24; columns 80; line = 0;\n-ignbrk -brkint\nisig icanon iexten -echo echoe echok -echonl -noflsh\n'; + assert.deepEqual(parseStty(gnu), {canonical: true, echo: false, signals: true}); + assert.equal(parseStty('lflags: echoe echok'), undefined, 'echoe is not echo'); +}); + +test('ps: only the terminal\'s foreground group; CPU time in both formats', () => { + const ps = ' 4101 4101 4120 Ss 0:00.21 /bin/zsh\n 4120 4120 4120 S+ 0:01.50 /opt/homebrew/sbin/e2fsck\n 4122 4121 4120 S 0:00.00 helper\n'; + assert.deepEqual(parseForeground(ps), [{pid: 4120, state: 'S+', cpuMs: 1500, command: 'e2fsck'}]); + assert.equal(parseCpuTime('1-02:03:04'), ((26 * 60 + 3) * 60 + 4) * 1000); + assert.equal(parseCpuTime('00:00:07'), 7000); + assert.equal(parseCpuTime('bad'), undefined); +}); + +test('Linux syscall report: a read of a descriptor, a poll/select wait, something else, or running', () => { + assert.deepEqual(parseSyscall('0 0x0 0x7ffd 0x1 0x0 0x0 0x0 0x7ffd 0x7f', 'x64'), {kind: 'read', fd: 0}); + assert.deepEqual(parseSyscall('63 0x3 0xffff 0x1 0 0 0 0xff 0xff', 'arm64'), {kind: 'read', fd: 3}); + assert.deepEqual(parseSyscall('7 0x7ffd 0x1 0xffffffff 0 0 0 0x1 0x2', 'x64'), {kind: 'wait'}); + assert.deepEqual(parseSyscall('230 0x0 0x0 0x7ffd 0 0 0 0x1 0x2', 'x64'), {kind: 'other'}, 'clock_nanosleep: sleeping, not reading'); + // A timed read (zsh `read -t`, bash `read -t`) waits in select/pselect6/ppoll: a wait, never a confirmed read. + for (const nr of [23, 270, 271]) assert.deepEqual(parseSyscall(`${nr} 0x1 0x7ffd 0x0 0x0 0x7ffd 0x0 0x1 0x2`, 'x64'), {kind: 'wait'}); + assert.deepEqual(parseSyscall('73 0x7ffd 0x1 0x7ffd 0x0 0x8 0x0 0x1 0x2', 'arm64'), {kind: 'wait'}, 'ppoll'); + assert.equal(parseSyscall('running', 'x64'), undefined); + assert.equal(parseSyscall('-1 0x7ffd 0x1', 'x64'), undefined); +}); + +// ------------------------------------------------------------------ the watch + +/** A watch driven by hand: a clock, a timer queue and a probe answering with whatever the test sets. */ +function harness(command = 'e2fsck -f disk.img') { + let now = 1_000_000; + const timers: Array<{at: number; run: () => void}> = []; + let sample: TerminalSample | undefined = {foreground: []}; + const changes: InputState[] = []; + const watch = new InputWatch({probe: async () => sample, tty: () => '/dev/ttys999', now: () => now, + setTimer: (run, ms) => { const timer = {at: now + ms, run}; timers.push(timer); return () => { timers.splice(timers.indexOf(timer), 1); }; }}); + watch.on('change', state => changes.push(state)); + const advance = async (ms: number) => { + const until = now + ms; + for (;;) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > until) break; + timers.shift(); + now = next.at; + next.run(); + // The probe resolves on a microtask. + for (let index = 0; index < 5; index++) await Promise.resolve(); + } + now = until; + }; + watch.onExec(command, now); + return {watch, changes, advance, set: (next: TerminalSample | undefined) => { sample = next; }, at: () => now, setNow: (value: number) => { now = value; }}; +} + +const fg = (state = 'S+', cpuMs = 100, command = 'e2fsck') => [{pid: 4120, state, cpuMs, command}]; +const keyModes = {canonical: false, echo: false, signals: true}; +const lineModes = {canonical: true, echo: true, signals: true}; +const hiddenModes = {canonical: true, echo: false, signals: true}; + +test('watch: single-key input with a prompt left open is confirmed at once, from when the prompt was written', async () => { + const h = harness(); + h.watch.onOutput('Pass 1\r\nPadding at end of inode bitmap is not set. Fix? ', h.at()); + const written = h.at(); + h.set({modes: keyModes, foreground: fg()}); + await h.advance(200); + const request = h.watch.state.request!; + assert.equal(request.confidence, 'confirmed'); + assert.equal(request.mode, 'key'); + assert.equal(request.prompt, 'Padding at end of inode bitmap is not set. Fix?'); + assert.equal(request.program, 'e2fsck'); + assert.equal(request.since, written); + assert.equal(directInput(request), true, 'keys go straight to the program'); + // One key answers; the wait is closed at that moment. + await h.advance(17_800); + h.watch.onInput('y', h.at()); + assert.equal(h.watch.state.request, undefined); + assert.equal(h.watch.state.timing.waitedMs, 18_000); + assert.equal(h.watch.state.timing.waits, 1); +}); + +test('watch: raw mode with no prompt left (a quiet docker -it server, a spinner between frames) is never a request', async () => { + const h = harness('docker run -it image'); + h.watch.onOutput('listening on :8080\r\n', h.at()); + h.set({modes: {canonical: false, echo: false, signals: false}, foreground: fg('S+', 100, 'docker')}); + await h.advance(30_000); + assert.equal(h.watch.state.request, undefined); +}); + +test('watch: a hidden line (echo off, line input on) is confirmed even with no prompt text', async () => { + const h = harness('read -s secret'); + h.set({modes: hiddenModes, foreground: fg('Ss+', 10, 'zsh')}); + await h.advance(500); + assert.equal(h.watch.state.request?.mode, 'hidden'); + assert.equal(h.watch.state.request?.confidence, 'confirmed'); + // Typing inside a hidden line does not end the wait; Enter does. + h.watch.onInput('hunter', h.at()); + assert.ok(h.watch.state.request, 'still waiting while the line is typed'); + h.watch.onInput('2\r', h.at()); + assert.equal(h.watch.state.request, undefined); +}); + +test('watch: ordinary line input is only "likely", and only after the group is seen idle twice with a question open', async () => { + const h = harness('rm -i notes.txt'); + h.watch.onOutput('remove notes.txt? ', h.at()); + h.set({modes: lineModes, foreground: fg('S+', 100, 'rm')}); + await h.advance(200); + assert.equal(h.watch.state.request, undefined, 'one sample cannot tell idle from busy'); + await h.advance(1000); + assert.equal(h.watch.state.request?.confidence, 'likely'); + assert.equal(h.watch.state.request?.mode, 'line'); + assert.equal(directInput(h.watch.state.request), false, 'a likely request keeps the composer\'s line editor'); + // Enter submits the line and ends the wait. + h.watch.onInput('y\r', h.at()); + assert.equal(h.watch.state.request, undefined); +}); + +test('watch: no false positives for builds, sleep, network waits, progress bars, statuses or log streams', async () => { + // A build that printed a colon-ended status and is computing. + const build = harness('make'); + build.watch.onOutput('Linking target:', build.at()); + let cpu = 0; + for (let index = 0; index < 10; index++) { + cpu += 900; + build.set({modes: lineModes, foreground: fg('S+', cpu, 'ld')}); + await build.advance(1000); + } + assert.equal(build.watch.state.request, undefined, 'busy CPU across samples'); + const running = harness('cargo build'); + running.watch.onOutput('Compiling serde:', running.at()); + running.set({modes: lineModes, foreground: fg('R+', 100, 'rustc')}); + await running.advance(10_000); + assert.equal(running.watch.state.request, undefined, 'a process on a CPU is not waiting'); + // sleep and a network wait: idle, but no question was left open. + for (const [command, output] of [['sleep 600', ''], ['curl https://example.com', ' % Total % Received\r\n'], ['tail -f log', 'GET /index 200\r\n']]) { + const quiet = harness(command!); + if (output) quiet.watch.onOutput(output, quiet.at()); + quiet.set({modes: lineModes, foreground: fg('S+', 5, 'x')}); + await quiet.advance(60_000); + assert.equal(quiet.watch.state.request, undefined, command); + } + // A stalled progress bar and a status line left open. + const stalled = harness('npm install'); + stalled.watch.onOutput('[====> ] 45%', stalled.at()); + stalled.watch.onOutput('\r[=====> ] 47%:', stalled.at()); + stalled.set({modes: lineModes, foreground: fg('S+', 5, 'node')}); + await stalled.advance(30_000); + assert.equal(stalled.watch.state.request, undefined, 'a rewritten line is not a question'); + const status = harness('fetch'); + status.watch.onOutput('Downloading...', status.at()); + status.set({modes: lineModes, foreground: fg('S+', 5, 'fetch')}); + await status.advance(30_000); + assert.equal(status.watch.state.request, undefined, 'an ellipsis is a status'); +}); + +test('watch: programs that own the terminal (full screen, bracketed paste, known full-screen commands) are never reported', async () => { + for (const [command, output] of [['htop', '\u001b[?1049hsomething?'], ['python3', '\u001b[?2004h>>> '], ['vim notes', 'Swap file exists. [O]pen? ']]) { + const h = harness(command!); + h.watch.onOutput(output!, h.at()); + h.set({modes: keyModes, foreground: fg('S+', 5, 'x')}); + await h.advance(5000); + assert.equal(h.watch.state.request, undefined, command); + assert.equal(h.watch.ownsTerminal, true, command); + } +}); + +test('watch: the kernel\'s report decides when it exists (Linux): reading confirms, blocked elsewhere vetoes', async () => { + const reading = harness('./menu.sh'); + reading.watch.onOutput('1) start\r\n2) stop\r\nChoice:\r\n', reading.at()); + reading.set({modes: lineModes, foreground: fg('S+', 5, 'bash'), readingTerminal: true}); + await reading.advance(500); + assert.equal(reading.watch.state.request?.confidence, 'confirmed', 'a prompt that ended with a newline is still a read'); + assert.equal(reading.watch.state.request?.mode, 'line'); + const elsewhere = harness('ssh-keygen'); + elsewhere.watch.onOutput('Enter passphrase: ', elsewhere.at()); + elsewhere.set({modes: hiddenModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}); + await elsewhere.advance(5000); + assert.equal(elsewhere.watch.state.request, undefined); +}); + +test('watch: several questions in one command accumulate waiting time; completion closes an open wait', async () => { + const h = harness(); + h.set({modes: keyModes, foreground: fg()}); + h.watch.onOutput('Fix one? ', h.at()); + await h.advance(2000); + h.watch.onInput('y', h.at()); + h.watch.onOutput('yes\r\n\r\nFix two? ', h.at()); + await h.advance(3000); + assert.equal(h.watch.state.request?.prompt, 'Fix two?'); + assert.equal(waitedSoFar(h.watch.state, h.at()), 5000); + h.watch.onPrompt(h.at()); + assert.deepEqual(h.watch.state.timing, {waitedMs: 5000, waits: 2}); + assert.equal(h.watch.state.request, undefined); + // Ctrl+C ends a wait as an answer does. + const interrupted = harness('read x'); + interrupted.set({modes: hiddenModes, foreground: fg('S+', 5, 'zsh')}); + await interrupted.advance(600); + interrupted.watch.onInput('\u0003', interrupted.at()); + assert.equal(interrupted.watch.state.request, undefined); +}); + +test('watch: output after the prompt ends the wait; a probe begun before newer output is discarded', async () => { + const h = harness('slow'); + h.watch.onOutput('Continue? [y/N] ', h.at()); + let resolve!: (sample: TerminalSample) => void; + const pending = new Promise(done => { resolve = done; }); + const watch = new InputWatch({probe: () => pending, tty: () => '/dev/ttys1', now: () => h.at(), + setTimer: (run, ms) => { const timer = setTimeout(run, ms); return () => clearTimeout(timer); }}); + watch.onExec('slow', h.at()); + watch.onOutput('Continue? [y/N] ', h.at()); + await new Promise(done => setTimeout(done, 200)); + watch.onOutput('\r\nmoving on\r\n', h.at()); + resolve({modes: keyModes, foreground: fg()}); + await new Promise(done => setTimeout(done, 10)); + assert.equal(watch.state.request, undefined, 'the sample described the terminal before the newer output'); + watch.dispose(); + // An established wait ends when the program writes again. + h.set({modes: keyModes, foreground: fg()}); + await h.advance(200); + assert.ok(h.watch.state.request); + h.watch.onOutput('Timed out, assuming no\r\n', h.at() + 1000); + assert.equal(h.watch.state.request, undefined); + assert.equal(h.watch.state.timing.waits, 1); +}); + +test('watch: evidence that disappears without input or output (a timed-out read) ends the wait at that look', async () => { + const h = harness('read -t 5 -k1'); + h.watch.onOutput('Key? ', h.at()); + h.set({modes: keyModes, foreground: fg('S+', 5, 'zsh')}); + await h.advance(200); + assert.ok(h.watch.state.request); + h.set({modes: lineModes, foreground: [{pid: 9, state: 'S+', cpuMs: 5, command: 'sleep'}]}); + await h.advance(1000); + // Line input with a question still open and an idle group is the likely rule, not the old confirmed one. + assert.notEqual(h.watch.state.request?.confidence, 'confirmed'); +}); + +test('watch: a prompt whose evidence lapses for a moment (a resize waking the program) is one prompt, its time counted once', async () => { + const h = harness('python3 askyn.py'); + const asked = h.at(); + h.watch.onOutput('First question. Fix? ', asked); + h.set({modes: keyModes, foreground: fg('S+', 10, 'python3')}); + await h.advance(5000); + assert.equal(h.watch.state.request?.since, asked); + // Reattaching resizes the PTY: the program wakes for SIGWINCH and one look sees it running. + h.set({modes: keyModes, foreground: fg('R+', 40, 'python3')}); + await h.advance(3000); + assert.equal(h.watch.state.request, undefined); + assert.ok(h.watch.state.timing.waitedMs > 0, 'closing counted the time so far'); + h.set({modes: keyModes, foreground: fg('S+', 40, 'python3')}); + await h.advance(3000); + const reopened = h.watch.state.request; + assert.ok(reopened); + assert.equal(reopened.since, asked, 'the same prompt: it keeps its own start (what other windows and /sessions show)'); + assert.equal(h.watch.state.timing.waitedMs, 0, 'resuming took that back: the open wait counts it once, when it ends'); + h.watch.onInput('y', h.at()); + h.watch.onOutput('yes\r\nSecond question. Fix? ', h.at()); + h.set({modes: keyModes, foreground: fg('S+', 41, 'python3')}); + await h.advance(300); + h.watch.onInput('n', h.at()); + const elapsed = h.at() - asked; + const {waitedMs, waits} = h.watch.state.timing; + assert.equal(waits, 2, 'two questions, two prompts'); + assert.ok(waitedMs <= elapsed, `waiting (${waitedMs} ms) never exceeds the time since the first question (${elapsed} ms)`); +}); + +// ------------------------------------------------------------------ protocol, notices, surfaces + +test('protocol: input-state round-trips; unknown values are dropped, never guessed; hello features gate new values', () => { + const state: InputState = {request: {since: 5, confidence: 'confirmed', mode: 'hidden', prompt: 'Password:', program: 'sudo'}, timing: {waitedMs: 1200.4, waits: 1}}; + const decoded = decodeMessage(encodeMessage(inputStateMessage(state)).trimEnd()); + assert.ok(decoded.ok); + assert.deepEqual(inputStateFrom(decoded.message as never), {request: state.request, timing: {waitedMs: 1200, waits: 1}}); + assert.deepEqual(inputStateFrom({type: 'input-state', since: 5, confidence: 'certain', mode: 'line', waitedMs: 0, waits: 0}), {timing: {waitedMs: 0, waits: 0}}); + const hello = decodeMessage(encodeMessage({type: 'hello', version: 2, client: 'nmsh', features: 'input-state,future'}).trimEnd()); + assert.ok(hello.ok && hello.message.type === 'hello'); + assert.deepEqual([...parseClientFeatures((hello.message as {features?: string}).features)], ['input-state']); + assert.deepEqual([...parseClientFeatures(undefined)], [], 'an older frontend says nothing and gets nothing new'); + const prompt = decodeMessage(encodeMessage({type: 'prompt', exitCode: 0, cwd: '/', seq: 1, at: 2, inputWaitMs: 18000, inputWaits: 2}).trimEnd()); + assert.ok(prompt.ok && prompt.message.type === 'prompt' && (prompt.message as {inputWaitMs?: number}).inputWaitMs === 18000); +}); + +test('notices: one sticky input notice per wait, withdrawn when answered, worded by confidence', () => { + const tracker = new SessionNoticeTracker('s1'); + tracker.onExec('e2fsck -f disk.img', 100); + tracker.onInputNeeded(200, 'confirmed', 'e2fsck'); + const notice = tracker.notice!; + assert.equal(notice.kind, 'input'); + assert.equal(noticeVisibleMs(notice), undefined, 'sticky until answered or focused'); + assert.match(describeNotice(notice, 'Mango', 60_200).text, /^Mango · e2fsck is waiting for input · 1m · \/resume$/u); + tracker.onInputNeeded(200, 'confirmed'); + assert.equal(tracker.notice, notice, 'the same wait is never raised twice'); + tracker.onInputResolved(); + assert.equal(tracker.notice, undefined); + tracker.onInputNeeded(300, 'likely'); + assert.match(describeNotice(tracker.notice!, 'Mango', 400).text, /may be waiting for input/u); + tracker.clear(500); + tracker.onInputNeeded(450, 'confirmed'); + assert.equal(tracker.notice, undefined, 'a wait someone already saw by focusing the session is not news'); +}); + +test('/sessions and /resume read the service\'s state; nothing is re-guessed from quiet output', () => { + const base: SessionInfo = {id: 'a', pid: 1, state: 'detached', cwd: '/tmp', createdAt: 0, running: 'e2fsck -f disk.img', runningSince: 0, lastOutputAt: 0}; + const waiting = {...base, inputSince: 10_000, inputConfidence: 'confirmed', inputMode: 'key', inputPrompt: 'Fix?'}; + assert.equal(liveRowState(waiting, 70_000), 'input'); + assert.equal(LIVE_ROW_LABELS.input, 'Waiting for input'); + assert.equal(liveRowState({...waiting, inputConfidence: 'likely'}, 70_000), 'inputLikely'); + assert.match(liveStatusParts(waiting, 70_000).join(' · '), /waiting for input 1m · “Fix\?”/u); + assert.match(liveStatusParts(base, 70_000).join(' · '), /quiet/u, 'quiet output alone stays "quiet"'); + assert.equal(liveRowState(base, 70_000), 'running'); +}); + +test('direct keys: the bytes a plain terminal sends; NMSh gestures and pointer events send nothing', () => { + assert.equal(directKeyBytes({kind: 'text', value: 'y'}), 'y'); + assert.equal(directKeyBytes({kind: 'enter'}), '\r'); + assert.equal(directKeyBytes({kind: 'backspace'}), '\u007f'); + assert.equal(directKeyBytes({kind: 'deleteLineBefore'}), '\u0015'); + assert.equal(directKeyBytes({kind: 'up'}), '\u001b[A'); + assert.equal(directKeyBytes({kind: 'paste', value: 'secret'}), 'secret'); + for (const kind of ['selectAll', 'historySearch', 'palette', 'mouseClick', 'focusIn', 'toggleDetails'] as const) assert.equal(directKeyBytes({kind} as never), undefined, kind); +}); + +test('presentation: distinct from Running in words and glyphs; fits narrow widths; NO_COLOR and Safe glyphs keep it legible', () => { + const state = {request: {since: 82_000, confidence: 'confirmed' as const, mode: 'key' as const, prompt: 'Padding at end of inode bitmap is not set. Fix?', program: 'e2fsck'}, + timing: {waitedMs: 0, waits: 0}}; + withEnv({NO_COLOR: undefined, NMSH_ICONS: undefined, TERM: 'xterm-256color'}, () => { + const [first, second] = inputActivityRows(state, 'e2fsck -f disk.img', 0, 100_000, 100); + assert.equal(stripAnsi(first), '◆ Waiting for input · e2fsck · 18.0s · 1m 40s total'); + assert.equal(stripAnsi(second), ' Padding at end of inode bitmap is not set. Fix? · each key goes straight to e2fsck'); + for (const columns of [20, 32, 48]) { + for (const row of inputActivityRows(state, 'e2fsck -f disk.img', 0, 100_000, columns)) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + const narrow = inputActivityRows(state, 'e2fsck', 0, 100_000, 32).map(stripAnsi); + assert.match(narrow[0], /^◆ Waiting for input · 18\.0s$/u, 'the wait and its time survive; detail goes first'); + assert.match(narrow[1], /^ {2}Padding at end/u, 'the question outranks the hint'); + assert.equal(displayWidth(narrow[1]), 32, 'without the hint, the question takes the whole row'); + }); + withEnv({NO_COLOR: '1', NMSH_ICONS: 'safe'}, () => { + const [first, second] = inputActivityRows({...state, request: {...state.request, confidence: 'likely', mode: 'line'}}, 'rm -i x', 82_000, 100_000, 80); + assert.doesNotMatch(first + second, /\u001b\[38;/u, 'no color under NO_COLOR'); + assert.match(first, /\u001b\[1m/u, 'weight still marks it'); + assert.match(stripAnsi(first), /^\? Probably waiting for input · e2fsck · 18\.0s$/u); + assert.equal(stripAnsi(inputActivityRows(state, 'e2fsck', 0, 100_000, 80)[0]).charAt(0), '!'); + }); + assert.equal(directInputPlaceholder({...state.request, mode: 'hidden'}, 'sudo'), 'Hidden input for sudo · nothing you type is shown'); + assert.equal(completionWaitFact({waitedMs: 18_000, waits: 1}), ' · 18.0s waiting for input'); + assert.equal(completionWaitFact({waitedMs: 65_000, waits: 3}), ' · 1m 5s waiting for input (3 prompts)'); + assert.equal(completionWaitFact({waitedMs: 300, waits: 1}), '', 'an instant answer is not worth a fact'); + assert.equal(waitedDetail({timing: {waitedMs: 18_000, waits: 1}}, 0), ' · waited 18.0s'); + // The wait (session clock) is never shown longer than the run (this frontend's clock), e.g. after a reattach. + assert.match(stripAnsi(inputActivityRows(state, 'e2fsck', 91_000, 100_000, 100)[0]), /· 9\.0s$/u); + assert.equal(waitedDetail({timing: {waitedMs: 10_500, waits: 1}}, 0, 9000), ' · waited 9.0s'); + assert.equal(waitedDetail(undefined, 0), ''); +}); diff --git a/tests/inputAwarenessLive.test.ts b/tests/inputAwarenessLive.test.ts new file mode 100644 index 00000000..9d565607 --- /dev/null +++ b/tests/inputAwarenessLive.test.ts @@ -0,0 +1,289 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, writeFileSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; + +/** + * Input awareness through the real composer, PTY and session service, with harmless fixtures only: the shells' + * own `read`, and tests/fixtures/askyn.py, which asks yes/no questions the way e2fsck does (single key, no echo, + * Enter takes the default) and repairs nothing. + */ +const ASKYN = resolve('tests/fixtures/askyn.py'); +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 python = spawnSync('python3', ['-c', 'import termios'], {encoding: 'utf8'}).status === 0; +const supported = process.platform === 'darwin' || process.platform === 'linux'; +/** + * What each platform can know (docs/design/input-awareness.md): Linux's kernel confirms an ordinary line read (◆), + * macOS can only infer it (◇ Probably). The foreground program is named as the platform reports it. + */ +const LINE_WAIT = process.platform === 'linux' ? '◆ Waiting for input' : '◇ Probably waiting for input'; +const PYTHON = process.platform === 'linux' ? 'python3' : 'Python'; + +type Shell = 'zsh' | 'bash' | 'fish'; + +async function start(shell: Shell, options: {size?: {cols: number; rows: number}; env?: Record; args?: string[]; sandbox?: LiveSandbox} = {}) { + const sandbox = options.sandbox ?? new LiveSandbox({provider: 'none', shellBackend: shell, themeBridge: {enabled: true, targets: {pager: {mode: 'follow'}}}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch(options.args ?? [], options.size ?? {cols: 100, rows: 30}, options.env ?? {}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh|bash|fish/u); + // A banner or generated bridge file can appear before the shell prompt. + // The service records lastExit only from an authenticated prompt; journals + // need not checkpoint a fresh idle shell before its first command. + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 20_000, 'the first authenticated shell prompt'); + await frontend.run('echo READY', /READY/u); + return {sandbox, frontend}; + } catch (error) { + // The caller never receives the sandbox when startup fails. Dispose here + // so the original deadline is reported instead of leaving the test worker + // held open by a live PTY; retain cleanup failures as additional evidence. + try { await sandbox.dispose(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'input fixture startup and cleanup failed'); } + throw error; + } +} + +test('fixture: failed startup disposes its sandbox and preserves both deadline and cleanup evidence', async () => { + for (const cleanupFails of [false, true]) { + let disposed = false; + const startup = new Error('startup deadline'); + const cleanup = new Error('cleanup deadline'); + const sandbox = {launch: () => ({waitFor: async () => { throw startup; }}), dispose: async () => { + disposed = true; + if (cleanupFails) throw cleanup; + }} as unknown as LiveSandbox; + await assert.rejects(start('fish', {sandbox}), error => cleanupFails + ? error instanceof AggregateError && error.errors[0] === startup && error.errors[1] === cleanup + : error === startup); + assert.equal(disposed, true, 'the caller never received the sandbox, so startup must dispose it'); + } +}); + +/** Text the frontend drew after `from`, without escapes. */ +const drawn = (frontend: Frontend, from: number) => strip(frontend.output.slice(from)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: read prompts: a line (likely), a hidden line and a single key reach the program natively`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + // An ordinary line: the evidence is a question left open while the shell idles, so NMSh says "probably". + let mark = frontend.mark; + frontend.pty.write(`${shell === 'zsh' ? "read 'x?Continue? [y/N] '" : "read -p 'Continue? [y/N] ' x"}; echo got=$x\r`); + await frontend.waitFor(new RegExp(`${LINE_WAIT} · (?:zsh|bash)[\\s\\S]*Continue\\? \\[y/N\\]`, 'u'), mark); + // A considered answer (waits under a second are not worth a completion fact). + await pause(1200); + frontend.pty.write('y\r'); + await frontend.waitFor(/got=y[\s\S]*Completed · [\d.]+s · [\d.]+s waiting for input/u, mark); + + // A hidden line: keys go straight to the shell's read; nothing typed is drawn by NMSh. + mark = frontend.mark; + frontend.pty.write(`${shell === 'zsh' ? "read -s 'p?Password: '" : "read -s -p 'Password: ' p"}; echo; echo len=\${#p}\r`); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Hidden input for (?:zsh|bash) · nothing you type is shown/u, mark); + const typed = frontend.mark; + frontend.pty.write('hunter2'); + await pause(300); + frontend.pty.write('\r'); + await frontend.waitFor(/len=7/u, mark); + assert.doesNotMatch(drawn(frontend, typed), /hunter2|•{3}/u, 'the secret is never drawn, not even as dots'); + + // A single key: answered without Enter, and no stray Enter reaches the shell afterwards. + mark = frontend.mark; + frontend.pty.write(`${shell === 'zsh' ? "read -k1 'k?Key: '" : "read -n1 -p 'Key: ' k"}; echo; echo key=$k\r`); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Keys go straight to (?:zsh|bash)/u, mark); + frontend.pty.write('z'); + await frontend.waitFor(/key=z[\s\S]*Completed/u, mark); + await frontend.run('echo AFTER', /AFTER/u); + } finally { await sandbox.dispose(); } + }); +} + +/** + * Answer the terminal queries a program sends through NMSh (primary device attributes, cursor position), as a real + * terminal does. Fish's reader waits for these answers; the test PTY alone never sends them. + */ +test('live fish: a hidden reader inside a function keeps ownership across detach, then releases it at the prompt', {skip: supported && fish ? false : 'fish not installed', timeout: 90_000}, async () => { + const {sandbox, frontend} = await start('fish'); + try { + await frontend.run("function ask; read -s -P (printf 'Pass%s: ' word) secret; echo; echo len=(string length -- $secret); end", /Completed/u); + const mark = frontend.mark; + frontend.pty.write('ask\r'); + await frontend.waitFor(/Password: /u, mark); + const [session] = await sandbox.sessions(); + frontend.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session!.id)?.state === 'detached', 15_000, 'reader detached'); + const back = sandbox.launch(['--attach', session!.id], {cols: 100, rows: 30}, {}, resolve('tests/fixtures/slowJournalAttach.ts')); + await back.waitFor(/TEST-JOURNAL-WAIT/u); + await back.waitFor(/Password: /u); + const reply = back.mark; + back.pty.write('never-a-command\r'); + await back.waitFor(/len=15[\s\S]*Completed/u, reply); + assert.doesNotMatch(strip(back.output.slice(reply)), /never-a-command|Queued/u); + await back.run('echo AFTER-READER', /AFTER-READER/u); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + assert.equal(records.some(record => record.command.includes('never-a-command')), false); + } finally { await sandbox.dispose(); } +}); + +function answerQueries(frontend: Frontend): () => void { + let seen = frontend.mark; + const timer = setInterval(() => { + const fresh = frontend.output.slice(seen); + seen = frontend.output.length; + for (const match of fresh.matchAll(/\u001b\[(0?c|6n)/gu)) frontend.pty.write(match[1] === '6n' ? '\u001b[1;1R' : '\u001b[?62;22c'); + }, 20); + return () => clearInterval(timer); +} + +test('live fish: read is fish\'s own line editor: handed the terminal, answered natively, never claimed as waiting', {skip: supported && fish ? false : 'fish not installed', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('fish'); + const stop = answerQueries(frontend); + try { + let mark = frontend.mark; + // The prompt is built by fish, so "Name: " is not in the command text NMSh echoes and shows while it runs. + frontend.pty.write(`read -P (printf 'Na%s: ' me) x; echo got=$x\r`); + // Fish reports its reader before drawing; Fish 3 does not enable bracketed paste here. + await until(() => frontend.output.slice(mark).includes('Name: '), 15_000, 'fish draws its own prompt'); + frontend.pty.write('abc\r'); + await frontend.waitFor(/got=abc[\s\S]*Completed/u, mark); + mark = frontend.mark; + frontend.pty.write(`read -s -P (printf 'Pass%s: ' word) p; echo len=(string length -- $p)\r`); + await until(() => frontend.output.slice(mark).includes('Password: '), 15_000, 'fish draws its hidden prompt'); + frontend.pty.write('hunter2\r'); + // This command's own completion: a bare "Completed" could match the previous command's row. + await frontend.waitFor(/len=7[\s\S]*Completed/u, mark); + assert.doesNotMatch(frontend.output, /Waiting for input/u, 'a program that owns the terminal is not reported as waiting'); + // The next command follows at once (tests/typingAfterRead.test.ts covers this across shells). + await frontend.run('echo AFTER-READ', /AFTER-READ/u); + // External programs under fish get the same awareness as under zsh. + if (python) { + mark = frontend.mark; + frontend.pty.write(`python3 ${ASKYN} 'Fix? '\r`); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Fix\?/u, mark); + frontend.pty.write('n'); + await frontend.waitFor(/ANSWERS=no[\s\S]*Completed/u, mark); + } + } finally { stop(); await sandbox.dispose(); } +}); + +test('live: an e2fsck-style yes/no session: each key answers one question, Enter only when pressed; timing stays truthful', {skip: supported && python ? false : 'python3 with termios not available', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + const started = Date.now(); + frontend.pty.write(`python3 ${ASKYN} 'Padding at end of inode bitmap is not set. Fix? ' 'Inode 12 ref count is 2, should be 1. Fix? ' 'Free blocks count wrong. Fix? '\r`); + await frontend.waitFor(new RegExp(`◆ Waiting for input · ${PYTHON}[\\s\\S]*Padding at end of inode bitmap is not set\\. Fix\\? · each key goes straight to ${PYTHON}`, 'u'), mark); + assert.match(drawn(frontend, mark), new RegExp(`Keys go straight to ${PYTHON}`, 'u'), 'the composer says where typing goes'); + await pause(1200); + frontend.pty.write('n'); + await frontend.waitFor(/Inode 12 ref count is 2, should be 1\. Fix\? · each key/u, mark); + const third = frontend.mark; + frontend.pty.write('\r'); + // A fast typist answers the moment the question appears, before NMSh knows how it reads: the key still reaches it. + await until(() => frontend.output.slice(third).includes('Free blocks count wrong'), 15_000, 'the third question'); + frontend.pty.write('y'); + await frontend.waitFor(/ANSWERS=no,yes,yes[\s\S]*Completed · [\d.]+s · [\d.]+s waiting for input \(3 prompts\)/u, mark); + const completed = /Completed · ([\d.]+)s · ([\d.]+)s waiting for input \(3 prompts\)/u.exec(drawn(frontend, mark)); + assert.ok(completed, drawn(frontend, mark).slice(-400)); + const [total, waited] = [Number(completed[1]), Number(completed[2])]; + assert.ok(waited <= total && total <= (Date.now() - started) / 1000 + 0.5, `waited ${waited}s within ${total}s`); + assert.ok(waited >= 1, 'the deliberate pause is counted as waiting'); + // Nothing leaked to the shell: the composer is empty and the next command runs normally. + await frontend.run('echo NEXT', /NEXT/u); + + // Ctrl+C at a waiting prompt interrupts the program, exactly as in a terminal. + const interrupt = frontend.mark; + frontend.pty.write(`python3 ${ASKYN} 'Fix? '\r`); + await frontend.waitFor(/◆ Waiting for input/u, interrupt); + frontend.pty.write('\u0003'); + await frontend.waitFor(/Interrupted|exit 130|KeyboardInterrupt/u, interrupt); + await frontend.run('echo STILL-HERE', /STILL-HERE/u); + } finally { await sandbox.dispose(); } +}); + +test('live: no false positives: sleep, a streaming build, a status left open, a full-screen pager', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + await frontend.run('sleep 3', /Completed/u); + await frontend.run('for i in 1 2 3 4 5 6; do echo compiling unit $i; sleep 0.5; done', /compiling unit 6[\s\S]*Completed/u); + await frontend.run(`printf 'Downloading...'; sleep 3; echo ' done'`, /done[\s\S]*Completed/u); + writeFileSync(join(sandbox.home, 'notes.txt'), 'line one\nline two\n'); + const pager = frontend.mark; + frontend.pty.write('less notes.txt\r'); + await until(() => frontend.output.slice(pager).includes('line two'), 15_000, 'less draws'); + await pause(2000); + frontend.pty.write('q'); + await frontend.waitFor(/Completed/u, pager); + assert.doesNotMatch(drawn(frontend, mark), /Waiting for input|waiting for input/u); + } finally { await sandbox.dispose(); } +}); + +test('live: a waiting session is visible from another window, survives detach, and is answered after reattach', {skip: supported && python ? false : 'python3 with termios not available', timeout: 150_000}, async () => { + const {sandbox, frontend: first} = await start('zsh'); + try { + const mark = first.mark; + first.pty.write(`python3 ${ASKYN} 'First question. Fix? ' 'Second question. Fix? '\r`); + await first.waitFor(/◆ Waiting for input/u, mark); + const [session] = await sandbox.sessions(); + assert.equal(session!.inputConfidence, 'confirmed'); + assert.equal(session!.inputMode, 'key'); + assert.equal(session!.inputPrompt, 'First question. Fix?'); + + // Another window: one notice once the wait has lasted, and /sessions says it in words. + const other = sandbox.launch(['--new']); + await other.waitFor(/❯/u); + await other.waitFor(/is waiting for input · .* · \/resume/u, 0, 30_000); + const list = other.mark; + other.pty.write('/sessions\r'); + await other.waitFor(/Waiting for input[\s\S]*First question/u, list); + other.pty.write('\u001b'); + + // The window goes away; the question keeps waiting, detached. + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session!.id)?.state === 'detached', 15_000, 'detached'); + assert.equal((await sandbox.sessions()).find(item => item.id === session!.id)?.inputSince, session!.inputSince, 'the same wait, not a new one'); + const back = sandbox.launch(['--attach', session!.id]); + await back.waitFor(/◆ Waiting for input[\s\S]*First question\. Fix\?/u, 0, 30_000); + const answered = back.mark; + back.pty.write('y'); + await back.waitFor(/Second question\. Fix\? · each key/u, answered); + back.pty.write('n'); + await back.waitFor(/ANSWERS=yes,no[\s\S]*Completed[\s\S]*waiting for input \(2 prompts\)/u); + } finally { await sandbox.dispose(); } +}); + +test('live: narrow, NO_COLOR and Safe glyphs keep the request legible', {skip: supported && python ? false : 'python3 with termios not available', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh', {size: {cols: 44, rows: 20}, env: {NO_COLOR: '1', NMSH_ICONS: 'safe'}}); + try { + const mark = frontend.mark; + frontend.pty.write(`python3 ${ASKYN} 'Fix? '\r`); + await frontend.waitFor(/! Waiting for input[\s\S]*Fix\?/u, mark); + const painted = frontend.output.slice(mark); + assert.doesNotMatch(painted.slice(painted.lastIndexOf('Waiting for input') - 40), /\u001b\[38;[25];/u, 'no color under NO_COLOR'); + assert.doesNotMatch(drawn(frontend, mark), /◆|◇/u, 'Safe glyphs only'); + frontend.pty.write('y'); + await frontend.waitFor(/ANSWERS=yes/u, mark); + } finally { await sandbox.dispose(); } +}); + +test('live: a reply typed for a command that finishes first is discarded, never run as a shell command', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const marker = join(sandbox.home, 'must-not-exist'); + const mark = frontend.mark; + frontend.pty.write(`read -t 4 'x?Quick? ' || echo TIMED-OUT\r`); + // A timed read waits in poll/select, not read(), so even Linux's kernel evidence leaves it "probably". + await frontend.waitFor(/[◆◇] (?:Probably w|W)aiting for input/u, mark); + frontend.pty.write(`touch ${marker}`); + await frontend.waitFor(/reply was discarded, not run/u, mark, 20_000); + frontend.pty.write('\r'); + await pause(1500); + assert.equal(existsSync(marker), false); + await frontend.run('echo CLEAN', /CLEAN/u); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/inputPrivacy.test.ts b/tests/inputPrivacy.test.ts new file mode 100644 index 00000000..953488ab --- /dev/null +++ b/tests/inputPrivacy.test.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {syncBuiltinESMExports} from 'node:module'; +import test, {mock} from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +test('key diagnostics observe only the idle composer, never startup or program input', () => { + const previous = process.env.NMSH_DEBUG_KEYS; + process.env.NMSH_DEBUG_KEYS = '1'; + const app = new TerminalApp(); + const logged: string[] = [], sent: string[] = []; + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'logEditorKeyInput', {value: (data: string) => { logged.push(data); }}); + Object.defineProperty(app['session'], 'write', {value: (data: string) => { sent.push(data); }}); + try { + app['startupPending'] = true; + app['onInput']('startup-secret'); + app['startupPending'] = false; + app['onShellExec']('ask'); + app['passthrough'] = true; + app['onInput']('hidden-secret\r'); + assert.deepEqual(sent, ['hidden-secret\r']); + assert.deepEqual(logged, []); + app['passthrough'] = false; + app['onInput']('program-answer'); + assert.deepEqual(logged, [], 'running input is private before any probe can classify it'); + app['onShellPrompt'](0, '/tmp'); + app['editor'].clear(); + app['onInput']('idle draft'); + assert.deepEqual(logged, ['idle draft']); + } finally { + app['stop'](0); app['session'].kill(); + if (previous === undefined) delete process.env.NMSH_DEBUG_KEYS; else process.env.NMSH_DEBUG_KEYS = previous; + } +}); + +test('key diagnostic files respect the configured temporary directory', () => { + const previous = process.env.NMSH_DEBUG_KEYS; + process.env.NMSH_DEBUG_KEYS = '1'; + const paths: string[] = []; + const probe = mock.method(fs, 'appendFileSync', (path: string) => { paths.push(path); }); + syncBuiltinESMExports(); + const app = new TerminalApp(); + try { + app['logEditorKeyInput']('safe-fixture'); + assert.deepEqual(paths, [join(tmpdir(), 'nmsh-key-debug.log')]); + } finally { + app['stop'](0); app['session'].kill(); + probe.mock.restore(); syncBuiltinESMExports(); + if (previous === undefined) delete process.env.NMSH_DEBUG_KEYS; else process.env.NMSH_DEBUG_KEYS = previous; + } +}); diff --git a/tests/intelligenceHardening.test.ts b/tests/intelligenceHardening.test.ts index c389971d..b3cc3573 100644 --- a/tests/intelligenceHardening.test.ts +++ b/tests/intelligenceHardening.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {mkdtemp, mkdir, writeFile, rm, readdir} from 'node:fs/promises'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; +import {prepareNativeCompletionHome} from './helpers/nativeCompletion.js'; import {NativeCompletionSource} from '../src/shell/CompletionService.js'; import {HistoryIndex, historyId} from '../src/shell/HistoryIndex.js'; import {HistoryService, indexImportedHistory} from '../src/shell/HistoryService.js'; @@ -13,6 +14,9 @@ test('native fuzzy filtering preserves nested path capture context and cached in process.env.HOME = home; try { await mkdir(join(home, 'src')); await writeFile(join(home, 'src', 'alpha.ts'), ''); + // Capture has its own startup deadline. This test concerns path context and + // cached insertion ranges; cold-start expiry is exercised separately. + await prepareNativeCompletionHome(home); const source = new NativeCompletionSource(); const context = {buffer: 'ls src/al', cwd: home}; const items = await source.query(context, new AbortController().signal); diff --git a/tests/miseProject.test.ts b/tests/miseProject.test.ts index 62e59469..4c3fd216 100644 --- a/tests/miseProject.test.ts +++ b/tests/miseProject.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync} from 'node:fs'; +import {mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, statSync, utimesSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {detectMiseProject, MiseProjectService, miseTaskCommand, parseMiseMetadata} from '../src/tools/MiseProject.js'; @@ -137,7 +137,11 @@ test('local mise config files change project identity and are markers', () => { const localPath = join(f.root, name); const localFact = (identity: string) => (JSON.parse(identity) as string[]).find(fact => fact.startsWith(localPath + ':')); assert.ok(localFact(after.identity), 'identity includes the local marker itself'); - writeFileSync(join(f.root, name), '[tools]\nnode = "24"\n'); + const previous = statSync(localPath); + writeFileSync(localPath, '[tools]\nnode = "24"\n'); + // Metadata-only detection requires a changed stat. An immediate same-size + // write may retain both mtime and ctime, even at nanosecond precision. + utimesSync(localPath, previous.atime, new Date(previous.mtimeMs + 1000)); const edited = detectMiseProject(f.root, f.binary); assert.notEqual(localFact(edited.identity), localFact(after.identity), `${name} file identity changes on edit`); } diff --git a/tests/nativeCaptureLifecycle.test.ts b/tests/nativeCaptureLifecycle.test.ts index ea1f2f86..c6ff5207 100644 --- a/tests/nativeCaptureLifecycle.test.ts +++ b/tests/nativeCaptureLifecycle.test.ts @@ -5,10 +5,18 @@ import {mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdir import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {prepareNativeCompletionHome} from './helpers/nativeCompletion.js'; import {NativeCompletionSource} from '../src/shell/CompletionService.js'; +import {resolveCommand} from '../src/providers/providers.js'; import {until, processAlive} from './helpers/liveFrontend.js'; const capture = fileURLToPath(new URL('../src/shell/capture.zsh', import.meta.url)); +function publishedPid(path: string): number | undefined { + if (!existsSync(path)) return undefined; + const pid = Number(readFileSync(path, 'utf8')); + return Number.isSafeInteger(pid) && pid > 1 ? pid : undefined; +} + const groupEnded = (pid: number): boolean => spawnSync('pgrep', ['-g', String(pid)], {timeout: 2000}).status === 1; test('native capture bounds its own startup and cleans the separate PTY process group', async () => { @@ -20,8 +28,7 @@ test('native capture bounds its own startup and cleans the separate PTY process env: {...process.env, HOME: root, FPATH: root, NMSH_CAPTURE_ROOT: transport}, stdio: 'ignore'}); let inner = 0; try { - await until(() => existsSync(join(transport, 'pid')), 2000, 'capture PID'); - inner = Number(readFileSync(join(transport, 'pid'), 'utf8')); + await until(() => { inner = publishedPid(join(transport, 'pid')) ?? 0; return inner > 1; }, 2000, 'capture PID'); assert.ok(inner > 1); await until(() => child.exitCode !== null || child.signalCode !== null, 2500, 'capture self deadline'); await until(() => !processAlive(inner), 2000, 'inner PTY cleanup'); @@ -62,8 +69,7 @@ test('native cancellation reaps a PTY before it can process the initialization c }); let inner = 0; try { - await until(() => existsSync(join(root, 'early-pid')), 3000, 'inner before initialization'); - inner = Number(readFileSync(join(root, 'early-pid'), 'utf8')); + await until(() => { inner = publishedPid(join(root, 'early-pid')) ?? 0; return inner > 1; }, 3000, 'inner before initialization'); await until(() => owner.exitCode !== null || owner.signalCode !== null, 2000, 'cancelled owner'); assert.equal(owner.exitCode, 0); await until(() => !processAlive(inner) && groupEnded(inner), 2000, 'early PTY cleanup'); @@ -78,12 +84,138 @@ test('native cancellation reaps a PTY before it can process the initialization c test('bounded nonblocking capture preserves native filesystem candidates', async () => { const root = mkdtempSync(join(tmpdir(), 'nmsh-native-files-')); writeFileSync(join(root, 'capture-file'), ''); + const previousHome = process.env.HOME; + process.env.HOME = root; try { + await prepareNativeCompletionHome(root); const values = await new NativeCompletionSource().query({buffer: 'ls cap', cwd: root}, new AbortController().signal); assert.ok(values.some(value => value.value === 'capture-file')); - } finally { rmSync(root, {recursive: true, force: true}); } + } finally { + if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + rmSync(root, {recursive: true, force: true}); + } +}); + +test('native startup expiry returns no candidates and does not poison the next capture', async () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-capture-retry-')); + const previousHome = process.env.HOME, previousFpath = process.env.FPATH; + process.env.HOME = root; + try { + writeFileSync(join(root, 'capture-file'), ''); + await prepareNativeCompletionHome(root); + const slow = join(root, 'slow'); mkdirSync(slow); + writeFileSync(join(slow, 'compinit'), 'sleep 20\n'); + process.env.FPATH = slow; + const source = new NativeCompletionSource(); + const context = {buffer: 'ls cap', cwd: root}; + assert.deepEqual(await source.query(context, new AbortController().signal), [], 'expired capture has no usable candidates'); + if (previousFpath === undefined) delete process.env.FPATH; else process.env.FPATH = previousFpath; + const recovered = await source.query(context, new AbortController().signal); + assert.ok(recovered.some(value => value.value === 'capture-file'), 'same context is captured again after expiry'); + } finally { + if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousFpath === undefined) delete process.env.FPATH; else process.env.FPATH = previousFpath; + rmSync(root, {recursive: true, force: true}); + } +}); + +test('native capture recognizes readiness split across nonblocking PTY reads', async () => { + const realZsh = resolveCommand('zsh'); + assert.ok(realZsh); + const root = mkdtempSync(join(tmpdir(), 'nmsh-ready-split-')); + const previousHome = process.env.HOME, previousPath = process.env.PATH; + const previousSplit = process.env.NMSH_CAPTURE_PROBE_SPLIT; + try { + const bin = join(root, 'bin'); mkdirSync(bin); + const shadow = join(root, 'capture-shadow.zsh'); + // Forward real zpty calls. Split only the helper's actual readiness chunk, + // preserving every byte; successful nonblocking reads need not be whole lines. + const splitReadiness = [ + "typeset -g nmsh_probe_pending='' nmsh_probe_seen='' nmsh_probe_split_done=0", + "zpty() {", + " if [[ $1 == -r && $2 == z && $3 == line ]]; then", + " if [[ -n $nmsh_probe_pending ]]; then", + " line=$nmsh_probe_pending", + " nmsh_probe_pending=''", + " return 0", + " fi", + " builtin zpty \"$@\"", + " local nmsh_probe_read_rc=$?", + " if (( nmsh_probe_read_rc == 0 && nmsh_probe_split_done == 0 )) && [[ $NMSH_CAPTURE_PROBE_SPLIT == 1 ]]; then", + " local nmsh_probe_original=$line", + " local nmsh_probe_before=$nmsh_probe_seen", + " (( ${#nmsh_probe_seen} + ${#line} > 65536 )) && exit 97", + " nmsh_probe_seen+=$line", + " # A naturally split marker is already a valid fragmentation witness.", + " if [[ $nmsh_probe_before == o || $nmsh_probe_before == *$'\\no' ]] && [[ $line == k* ]]; then", + " nmsh_probe_split_done=1", + " print -r -- bytes-preserved > \"$HOME/split-witness\"", + " elif [[ $line == ok* ]]; then", + " nmsh_probe_pending=${line#o}", + " line=o", + " elif [[ $line == *$'\\nok'* ]]; then", + " nmsh_probe_pending=k${line#*$'\\nok'}", + " line=${line%%$'\\nok'*}$'\\no'", + " fi", + " if [[ -n $nmsh_probe_pending ]]; then", + " [[ $nmsh_probe_original == \"$line$nmsh_probe_pending\" ]] || exit 97", + " nmsh_probe_split_done=1", + " print -r -- bytes-preserved > \"$HOME/split-witness\"", + " fi", + " fi", + " return $nmsh_probe_read_rc", + " fi", + " builtin zpty \"$@\"", + "}", + ].join('\n'); + const original = readFileSync(capture, 'utf8'); + assert.equal(original.split('# spawn shell').length, 2); + writeFileSync(shadow, original.replace('# spawn shell', () => splitReadiness + '\n# spawn shell')); + const syntax = spawnSync(realZsh, ['-n', shadow], {encoding: 'utf8', timeout: 2000}); + assert.equal(syntax.status, 0, syntax.stderr); + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; + writeFileSync(join(bin, 'zsh'), '#!/bin/zsh -f\n' + + 'if [[ $1 == -f && $2 == ' + quote(capture) + ' ]]; then\n' + + ' shift 2\n exec ' + quote(realZsh) + ' -f ' + quote(shadow) + ' "$@"\nfi\n' + + 'exec ' + quote(realZsh) + ' "$@"\n'); + chmodSync(join(bin, 'zsh'), 0o700); + process.env.HOME = root; + process.env.PATH = `${bin}:${previousPath ?? ''}`; + writeFileSync(join(root, 'capture-file'), ''); + await prepareNativeCompletionHome(root); + const context = {buffer: 'ls cap', cwd: root}; + process.env.NMSH_CAPTURE_PROBE_SPLIT = '0'; + const baseline = await new NativeCompletionSource().query(context, new AbortController().signal); + assert.ok(baseline.some(value => value.value === 'capture-file'), 'forwarding fixture preserves native capture'); + process.env.NMSH_CAPTURE_PROBE_SPLIT = '1'; + // A new source ensures the unsplit result cannot hide the split behind a cache hit. + const fragmented = await new NativeCompletionSource().query(context, new AbortController().signal); + assert.equal(readFileSync(join(root, 'split-witness'), 'utf8').trim(), 'bytes-preserved'); + assert.ok(fragmented.some(value => value.value === 'capture-file'), 'split readiness still captures native candidates'); + assert.equal(readdirSync(root).some(name => name.startsWith('nmsh-capture-')), false); + } finally { + for (const [key, value] of [['HOME', previousHome], ['PATH', previousPath], ['NMSH_CAPTURE_PROBE_SPLIT', previousSplit]] as const) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + rmSync(root, {recursive: true, force: true}); + } }); +/** Read one published capture identity before the fixture signals or observes its process. */ +async function publishedCapture(root: string, prefix: string): Promise<{transport: string; pid: number}> { + let identity: {transport: string; pid: number} | undefined; + await until(() => { + const name = readdirSync(root).find(name => name.startsWith(prefix)); + if (!name || !existsSync(join(root, name, 'pid'))) return false; + const transport = join(root, name); + const pid = publishedPid(join(transport, 'pid')); + if (pid === undefined) return false; + identity = {transport, pid}; + return true; + }, 3000, 'owned inner shell'); + return identity!; +} + /** The helpers' own startup deadline (capture.zsh, configured-completion.zsh default). */ const HELPER_DEADLINE_MS = 1500; @@ -110,14 +242,9 @@ for (const configured of [false, true]) test(`${configured ? 'configured' : 'nat owner.stderr!.on('data', data => { ownerError = (ownerError + String(data)).slice(-4096); }); let inner = 0; try { - let transport = ''; - await until(() => { - const name = readdirSync(root).find(name => name.startsWith(prefix)); - if (!name || !existsSync(join(root, name, 'pid'))) return false; - transport = join(root, name); - return true; - }, 3000, 'owned inner shell'); - inner = Number(readFileSync(join(transport, 'pid'), 'utf8')); + const identity = await publishedCapture(root, prefix); + const transport = identity.transport; + inner = identity.pid; owner.kill('SIGKILL'); // With the frontend gone, the helper cleans up on its own startup deadline (1.5 s in capture.zsh and the configured // helper); the window is that deadline plus scheduling margin for a loaded machine running the suite in parallel. @@ -127,7 +254,7 @@ for (const configured of [false, true]) test(`${configured ? 'configured' : 'nat } catch (error) { // Failure-only diagnostics distinguish an unreaped zombie from a live // helper or a leftover transport, without weakening the cleanup deadline. - const state = {elapsedMs: Date.now() - killedAt, transportExists: existsSync(transport), innerAlive: processAlive(inner)}; + const state = {innerPid: inner, elapsedMs: Date.now() - killedAt, transportExists: existsSync(transport), innerAlive: processAlive(inner)}; const processState = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,state=,comm=', '-p', String(inner)], {encoding: 'utf8', timeout: 2000}); if (error instanceof Error) error.message += `; ${JSON.stringify(state)}; inner process: ${processState.stdout?.trim() || 'unavailable'}`; throw error; @@ -149,3 +276,49 @@ for (const configured of [false, true]) test(`${configured ? 'configured' : 'nat rmSync(root, {recursive: true, force: true}); } }); + + +test('abrupt capture cleanup waits through a PID-file rewrite rather than observing process group zero', async () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-capture-pid-')); + const bin = join(root, 'bin'); mkdirSync(bin); + const release = join(root, 'release'); + const marker = join(root, 'rewriting'); + writeFileSync(join(root, 'compinit'), 'sleep 20\n'); + writeFileSync(join(bin, 'zsh'), `#!/bin/zsh -f +if [[ $2 != -i ]]; then exec /bin/zsh "$@"; fi +while [[ ! -s $NMSH_CAPTURE_ROOT/pid ]]; do sleep 0.005; done +print -r -- $$ > "$HOME/inner-pid" +: > "$NMSH_CAPTURE_ROOT/pid" +print ready > "$HOME/rewriting" +while [[ ! -f $HOME/release ]]; do sleep 0.005; done +exec /bin/zsh "$@" +`); + chmodSync(join(bin, 'zsh'), 0o700); + const module = new URL('../src/shell/CompletionService.ts', import.meta.url).href; + const program = `import {NativeCompletionSource} from ${JSON.stringify(module)}; +await new NativeCompletionSource().query({buffer:'g',cwd:${JSON.stringify(root)}},new AbortController().signal);`; + const owner = spawn(process.execPath, ['--import=tsx', '--input-type=module', '-e', program], { + env: {...process.env, HOME: root, FPATH: root, TMPDIR: root, PATH: `${bin}:${process.env.PATH}`}, stdio: 'ignore', detached: true, + }); + let inner = 0; + try { + await until(() => existsSync(marker), 3000, 'controlled PID rewrite'); + inner = Number(readFileSync(join(root, 'inner-pid'), 'utf8')); + assert.ok(Number.isSafeInteger(inner) && inner > 1); + let observed = false; + const pending = publishedCapture(root, 'nmsh-capture-').then(identity => { observed = true; return identity; }); + await new Promise(resolve => setTimeout(resolve, 50)); + writeFileSync(release, ''); + const identity = await pending; + assert.equal(observed, true); + assert.ok(identity.pid > 1, 'only a valid process identity authorizes cleanup'); + assert.equal(identity.pid, inner); + owner.kill('SIGKILL'); + await until(() => !existsSync(identity.transport) && !processAlive(inner), 8000, 'controlled capture cleanup'); + await until(() => groupEnded(inner), 4000, 'controlled capture descendants'); + } finally { + if (owner.pid) { try { process.kill(-owner.pid, 'SIGKILL'); } catch {} } + if (inner > 1) { try { process.kill(-inner, 'SIGKILL'); } catch {} } + rmSync(root, {recursive: true, force: true}); + } +}); diff --git a/tests/platformFoundations.test.ts b/tests/platformFoundations.test.ts index cc62ba86..da506c65 100644 --- a/tests/platformFoundations.test.ts +++ b/tests/platformFoundations.test.ts @@ -24,7 +24,7 @@ test('zsh discovery accepts executable symlinks and Unicode/spaces; rejects miss }); test('Linux runtime prefers private absolute XDG runtime; unsafe, relative, symlink and long paths fall back', () => { - const root = mkdtempSync('/tmp/nr-'); chmodSync(root, 0o700); + const root = mkdtempSync(join(tmpdir(), 'nr-')); chmodSync(root, 0o700); try { const selected = defaultRuntimeDir({XDG_RUNTIME_DIR: root}, 'linux'); assert.equal(selected, join(root, 'nmsh')); diff --git a/tests/promptNone.test.ts b/tests/promptNone.test.ts index 17af2cbc..80a0d79b 100644 --- a/tests/promptNone.test.ts +++ b/tests/promptNone.test.ts @@ -15,10 +15,17 @@ import {historicalPromptLevel} from '../src/output/TranscriptPanel.js'; import {stripAnsi} from '../src/util/text.js'; import {describePromptConfiguration, PROVIDER_ORDER} from '../src/prompt/PromptPanel.js'; +const FIXTURE_CWD = '/fixture/project'; + function harness(config: object): {app: TerminalApp; frames: TerminalFrame[]; cleanup: () => void} { const isolation = isolateConfig(); const app = new TerminalApp(); const frames: TerminalFrame[] = []; + // Geometry is compared at a fixed size and context. Host checkout paths + // can legitimately consume the one-line prefix and wrap the input. + app['dimensions'] = () => ({columns: 80, rows: 24}); + app['context'] = {cwd: FIXTURE_CWD, project: 'project', home: '/fixture', exitStatus: 0}; + app['refreshContext'] = async () => {}; app['renderer'].render = (frame: TerminalFrame) => { frames.push(frame); }; app['fetchSuggestions'] = async () => {}; app['presentationStarted'] = true; @@ -41,7 +48,7 @@ test('Prompt None: the marker is the configured prompt symbol (default, choice, const {app, frames, cleanup} = harness({provider: 'none', onboardingComplete: true, ...config}); try { await app['refreshProviderPrompt'](); - app['onShellPrompt'](0, process.cwd()); + app['onShellPrompt'](0, FIXTURE_CWD); app['editor'].insert('echo hello'); app['render'](); assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${nerd} echo hello`)), `marker ${nerd}`); @@ -76,7 +83,7 @@ for (const layout of ['twoLine', 'oneLine'] as const) { try { for (const instance of [none, native]) { await instance.app['refreshProviderPrompt'](); - instance.app['onShellPrompt'](0, process.cwd()); + instance.app['onShellPrompt'](0, FIXTURE_CWD); instance.app['editor'].insert('git status'); instance.app['render'](); } @@ -102,7 +109,7 @@ for (const layout of ['twoLine', 'oneLine'] as const) { test('Prompt None history: submissions store no snapshot and render no prompt; switching back restores the prompt', () => { const {app, cleanup} = harness({provider: 'none', onboardingComplete: true}); try { - app['onShellPrompt'](0, process.cwd()); + app['onShellPrompt'](0, FIXTURE_CWD); app['effectivePromptProvider'] = 'none'; const context = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); assert.equal(context.prompt, undefined, 'no prompt snapshot'); diff --git a/tests/providersOverview.test.ts b/tests/providersOverview.test.ts index a9ee1bc4..0e24716d 100644 --- a/tests/providersOverview.test.ts +++ b/tests/providersOverview.test.ts @@ -1,4 +1,7 @@ import test from 'node:test'; +import {mkdtempSync, writeFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {delimiter, join} from 'node:path'; import assert from 'node:assert/strict'; import {TerminalApp} from '../src/app/TerminalApp.js'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; @@ -37,7 +40,18 @@ test('/providers overview lists every family inline; modern status words; select assert.match(shown, /Atuin\s+✓ Selected · fallback → NMSh Native/u); }); -test('/providers keys: inline expand, select at once, inline install confirm defaulting No, Esc collapses first', () => { +test('/providers keys: inline expand, select at once, inline install confirm defaulting No, Esc collapses first', t => { + // Test an available curated installer without depending on host Homebrew. + // Key handling must only open the review, never execute this fixture. + const root = mkdtempSync(join(tmpdir(), 'provider-installer-')); + const previousPath = process.env.PATH; + t.after(() => { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + rmSync(root, {recursive: true, force: true}); + }); + writeFileSync(join(root, 'brew'), '#!/bin/sh\nexit 99\n', {mode: 0o700}); + process.env.PATH = [root, previousPath].filter(Boolean).join(delimiter); const statuses = new Map([['fzf', {state: 'installed', binary: '/x/fzf'}], ['television', {state: 'missing'}]]); const f = {configuration: config(), statuses}; const state = createProvidersOverview(); diff --git a/tests/queryOrder.test.ts b/tests/queryOrder.test.ts index 963925ae..fbd0f12e 100644 --- a/tests/queryOrder.test.ts +++ b/tests/queryOrder.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {once} from 'node:events'; import {existsSync, mkdtempSync, rmSync} from 'node:fs'; import {join} from 'node:path'; +import {tmpdir} from 'node:os'; import {QueryOrder} from '../src/shell/QueryOrder.js'; import {ShellSession} from '../src/shell/ShellSession.js'; @@ -112,7 +113,7 @@ test('after NMSh answers an editor itself, input waits until the editor has draw const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); test('fish\'s read is answered like its prompt: its queries never reach the host, and it reads at once', {skip: fish ? false : 'fish not installed', timeout: 30_000}, async () => { - const home = mkdtempSync('/tmp/nt-qo-'); + const home = mkdtempSync(join(tmpdir(), 'nt-qo-')); const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, 'data'), PATH: `${fish!.replace(/\/fish$/u, '')}:${process.env.PATH}`, TERM: 'xterm-256color'}, 'fish'); let output = ''; @@ -125,6 +126,7 @@ test('fish\'s read is answered like its prompt: its queries never reach the host const started = Date.now(); while (!output.includes('Name: ') && Date.now() - started < 10_000) await new Promise(done => setTimeout(done, 5)); assert.ok(output.includes('Name: '), `fish ${secret}read drew its prompt`); + assert.equal(shell.inputWatch.state.terminalOwner, true, `reader event precedes the prompt: ${JSON.stringify(output)}`); // Nothing answers on the host side here; before, fish waited for the host's answer and dropped keys meanwhile. const prompt = once(shell, 'prompt'); shell.write('abc\r'); diff --git a/tests/sessionLifecycle.test.ts b/tests/sessionLifecycle.test.ts index 0c59fe40..2cf0d234 100644 --- a/tests/sessionLifecycle.test.ts +++ b/tests/sessionLifecycle.test.ts @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {existsSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; +import {fullscreenFixture} from './helpers/fullscreenFixture.js'; +import {fullscreenFailure} from './helpers/fullscreenFailure.js'; import {SocketSessionClient} from '../src/session/SocketSessionClient.js'; import {attachSession} from '../src/session/connectSession.js'; import {socketPathFor} from '../src/session/runtimeDir.js'; @@ -85,10 +87,16 @@ test('SIGHUP detaches; Ctrl+D ends the session; /zsh detaches it for the ordinar await until(async () => (await sandbox.sessions()).length === 0, 15000, 'Ctrl+D to end the session'); await until(() => !processAlive(pid), 15000, 'zsh to exit'); + const release = join(sandbox.home, 'third-startup-release'); + writeFileSync(join(sandbox.home, '.zshrc'), 'until test -f "$HOME/third-startup-release"; do sleep 0.05; done\n'); const third = sandbox.launch(); await third.waitFor(/❯/); const {pid: thirdPid} = await onlySession(sandbox); - // The composer appears before the shell's first prompt; /zsh is refused while it starts, so wait for readiness. + assert.equal((await sandbox.sessions()).find(session => session.pid === thirdPid)?.lastExit, undefined, + 'a visible composer does not prove the gated shell is ready'); + writeFileSync(release, ''); + await until(async () => (await sandbox.sessions()).some(session => session.pid === thirdPid && session.lastExit !== undefined), + 20_000, 'the third shell authenticated prompt'); await third.run('echo READY', /READY/); third.pty.write('/zsh\r'); // A deliberate handoff keeps the session: detached, its shell still alive, waiting for `nmsh`. @@ -202,24 +210,24 @@ test('reattach into a fullscreen app resumes passthrough and makes it repaint at try { const fixture = join(sandbox.home, 'fullscreen.zsh'); const stop = join(sandbox.home, 'stop'); - writeFileSync(fixture, [ - `printf '\\e[?1049h'`, - `trap 'printf "\\e[H\\e[2JREDRAW %s\\n" "$(stty size)"' WINCH`, - `printf 'TRAP-READY\\n'`, - `while [ ! -f ${stop} ]; do sleep 0.05; done`, - `printf '\\e[?1049l'`, - ].join('\n')); + writeFileSync(fixture, fullscreenFixture(stop)); const first = sandbox.launch(); await first.waitFor(/❯/); first.pty.write(`zsh ${fixture}\r`); // Detach only once the fixture's WINCH trap is installed, or the reattach resize can arrive before it. await first.waitFor(/TRAP-READY/); - const {id} = (await sandbox.sessions())[0]!; + const {id, pid} = (await sandbox.sessions())[0]!; first.pty.kill('SIGKILL'); await waitState(sandbox, 'detached'); const second = sandbox.launch(['--attach', id], {cols: 90, rows: 33}); - await second.waitFor(/REDRAW 33 90/); + try { + await second.waitFor(/REDRAW 33 90/); + } catch (error) { + const observation = await fullscreenFailure(sandbox, id, pid, second); + if (error instanceof Error) error.message += `; fullscreen failure=${JSON.stringify(observation)}`; + throw error; + } writeFileSync(stop, ''); await second.waitFor(/❯/, second.output.lastIndexOf('REDRAW')); await second.run('echo AFTER-FULLSCREEN', /AFTER-FULLSCREEN/); @@ -233,9 +241,14 @@ test('vim and less survive detach and repaint after reattach', async () => { try { writeFileSync(join(sandbox.home, 'notes.txt'), 'VIM-FIXTURE-LINE\n'); writeFileSync(join(sandbox.home, 'pager.txt'), Array.from({length: 200}, (_, i) => `LESS-LINE-${i}`).join('\n')); - for (const [command, visible, quit] of [ - ['vim -u NONE -N notes.txt', /VIM-FIXTURE-LINE/, '\u001b:q!\r'], - ['less pager.txt', /LESS-LINE-0/, 'q'], + const attachmentSize = {cols: 110, rows: 35}; + const pagerPrompt = 'NMSH-PAGER-READY'; + // First text can come from the attach nudge. Wait for the full-size frame + // before quitting: native less can discard a key when WINCH interrupts its read. + const pagerFrame = new RegExp(`LESS-LINE-0[\\s\\S]*LESS-LINE-${attachmentSize.rows - 2}[\\r\\n]+${pagerPrompt}`); + for (const [command, visible, quit, repaint] of [ + ['vim -u NONE -N notes.txt', /VIM-FIXTURE-LINE/, '\u001b:q!\r', /VIM-FIXTURE-LINE/], + [`less -P${pagerPrompt} pager.txt`, /LESS-LINE-0/, 'q', pagerFrame], ] as const) { const first = sandbox.launch(); await first.waitFor(/❯/); @@ -243,13 +256,21 @@ test('vim and less survive detach and repaint after reattach', async () => { const mark = first.mark; first.pty.write(`${command}\r`); await first.waitFor(visible, mark); - const {id} = (await sandbox.sessions())[0]!; + const {id, pid} = (await sandbox.sessions())[0]!; first.pty.kill('SIGKILL'); await waitState(sandbox, 'detached'); - const second = sandbox.launch(['--attach', id], {cols: 110, rows: 35}); - await second.waitFor(visible); - second.pty.write(quit); - await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15000, `${command} to quit`); + const second = sandbox.launch(['--attach', id], attachmentSize); + let stage = 'reattach repaint'; + try { + await second.waitFor(repaint); + stage = 'quit completion'; + second.pty.write(quit); + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15000, `${command} to quit`); + } catch (error) { + const observation = await fullscreenFailure(sandbox, id, pid, second); + if (error instanceof Error) error.message += `; stage=${stage}; fullscreen failure=${JSON.stringify(observation)}; output tail=${JSON.stringify(strip(second.output).slice(-512))}`; + throw error; + } await second.run('echo QUIT-OK', /QUIT-OK/); second.pty.write('exit\r'); await second.waitExit(); diff --git a/tests/sessionPeerSecurity.test.ts b/tests/sessionPeerSecurity.test.ts new file mode 100644 index 00000000..23d80f14 --- /dev/null +++ b/tests/sessionPeerSecurity.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync} from 'node:fs'; +import {createServer, type Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {dismissSessionNotice, listLiveSessions, listSessionNotices} from '../src/session/connectSession.js'; +import {killSession, renameSession} from '../src/session/SocketSessionClient.js'; +import {encodeMessage, FrameDecoder, PROTOCOL_VERSION} from '../src/session/SessionProtocol.js'; +import {socketPathFor} from '../src/session/runtimeDir.js'; + +// A reachable impostor must not gain a connection, even for display-only or +// best-effort requests. Removing request's runtime validation breaks this test. +for (const unsafe of ['public directory', 'symlink'] as const) { + test(`session helpers refuse a reachable socket in a ${unsafe}`, async t => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-peer-'))); + const linked = join(root, 'alias'); + const sockets = new Set(); + const requests: string[] = []; + const forged = {id: 'forged', pid: 123, cwd: '/forged', createdAt: 1, state: 'detached' as const}; + const server = createServer(socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + const decoder = new FrameDecoder(); + socket.on('data', chunk => { + for (const result of decoder.push(chunk.toString())) { + if (!result.ok) continue; + const message = result.message; + requests.push(message.type); + if (message.type === 'hello') socket.write(encodeMessage({type: 'welcome', version: PROTOCOL_VERSION, service: 'nmshd', startupSafety: 1})); + else if (message.type === 'dismiss') socket.write(encodeMessage({type: 'dismissed', sessionId: 'forged'})); + else if (message.type === 'kill') socket.write(encodeMessage({type: 'killed', sessionId: 'forged'})); + else socket.write(encodeMessage({type: 'sessions', sessions: [forged], ended: []})); + } + }); + }); + await new Promise(resolve => server.listen(socketPathFor(root), resolve)); + const runtimeDir = unsafe === 'symlink' ? linked : root; + if (unsafe === 'symlink') symlinkSync(root, linked); + else chmodSync(root, 0o777); + try { + await t.test('live listing cannot trust forged identities', async () => { + await assert.rejects(listLiveSessions({runtimeDir}), /not private|not a directory/u); + }); + await t.test('notice listing fails closed', async () => { + assert.deepEqual(await listSessionNotices({runtimeDir}), {sessions: [], ended: []}); + }); + await t.test('dismissal sends no session identifier', async () => { + const before = requests.length; + await dismissSessionNotice('forged', {runtimeDir}); + assert.equal(requests.length, before, 'no bytes sent to an unverified peer'); + }); + await t.test('rename sends no session name', async () => { + await assert.rejects(renameSession(socketPathFor(runtimeDir), 'forged', 'private-name'), /not private|not a directory/u); + }); + await t.test('termination cannot reach an unverified peer', async () => { + await assert.rejects(killSession(socketPathFor(runtimeDir), 'forged'), /not private|not a directory/u); + }); + assert.deepEqual(requests, [], 'no handshake or operation reaches the unsafe socket'); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(root, {recursive: true, force: true}); + } + }); +} diff --git a/tests/sessionReattachGeometry.test.ts b/tests/sessionReattachGeometry.test.ts new file mode 100644 index 00000000..83a59ded --- /dev/null +++ b/tests/sessionReattachGeometry.test.ts @@ -0,0 +1,387 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {SessionClient, SessionClientEvents} from '../src/session/SessionClient.js'; +import {LiveSandbox} from './helpers/liveFrontend.js'; +import {HOST_QUERY} from '../src/host/probe.js'; +import type {ShellId} from '../src/shell/adapters/ShellAdapter.js'; + +class ReattachedSession extends EventEmitter implements SessionClient { + readonly features = new Set(); + readonly resizes: {columns: number; rows: number}[] = []; + readonly writes: string[] = []; + readonly acknowledgements: number[] = []; + detaches = 0; + constructor(private readonly replayOnStart = true) { super(); } + start() { if (this.replayOnStart) this.emit('replayed', {truncatedBytes: 0}); } + submit() {} interrupt() {} endInput() {} kill() {} detach() { this.detaches += 1; } queue() {} + write(data: string) { this.writes.push(data); } + resize(columns: number, rows: number) { this.resizes.push({columns, rows}); } + ack(seq: number) { this.acknowledgements.push(seq); } + async switchShell(shell: ShellId) { return {shell, pid: 123}; } +} + +// Compare listener identity without treating the editor's decoded-string handler as a raw Buffer handler. +const hasFrontendInput = (app: TerminalApp) => process.stdin.listeners('data').some(listener => Object.is(listener, app['onInput'])); + +async function heldJournalApp(fullscreen: boolean, {replayOnStart = true, reattach = fullscreen, modes = '', restoredCommand = ''} = {}) { + const sandbox = new LiveSandbox(); + const keys = ['HOME', 'XDG_CONFIG_HOME', 'NMSH_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP']; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + for (const key of keys) process.env[key] = sandbox.env[key]; + const client = new ReattachedSession(replayOnStart); + const app = new TerminalApp({client, mode: 'service', sessionId: 'fullscreen-test', + ...(reattach ? {attached: {sessionId: 'fullscreen-test', pid: 123, cwd: process.cwd(), + fullscreen: fullscreen ? 1 : 0, modes, running: fullscreen ? 'zsh fullscreen.zsh' : 'sleep 999', ackedSeq: 0}} : {})}); + // Model the journal's restored active command before its unacknowledged exec replay. + if (restoredCommand) app['onShellExec'](restoredCommand); + // Observe run's handoff; construction used the non-TTY test host's default dimensions. + client.resizes.length = 0; + const phases: string[] = []; + const restoredModes: string[] = []; + app['dimensions'] = () => ({columns: 90, rows: 33}); + app['render'] = () => {}; + app['refreshContext'] = async () => {}; + app['renderer'].enter = () => { phases.push('enter'); }; + app['renderer'].suspendForPassthrough = (restore = '') => { phases.push('handoff'); restoredModes.push(restore); }; + app['renderer'].leave = () => {}; + let releaseSave!: () => void; + const held = new Promise(resolve => { releaseSave = resolve; }); + let saveStarted!: () => void; + const saving = new Promise(resolve => { saveStarted = resolve; }); + app['transcriptStore'].save = async () => { saveStarted(); await held; }; + if (reattach) { + app['continuedJournal'] = app['transcriptStore'].create({startCwd: process.cwd(), finalCwd: process.cwd(), + transcript: app['output'].transcript(), journaled: true}); + } + // Control the save boundary without writing a journal or exercising filesystem locks. + app['transcriptStore'].withLock = async (_id, task) => task(); + const exitBefore = new Set(process.listeners('exit')); + const running = app.run(); + const ownedExit = process.listeners('exit').filter(listener => !exitBefore.has(listener)); + let runFinished = false; + void running.then(() => { runFinished = true; }, () => { runFinished = true; }); + return {app, client, phases, restoredModes, saving, releaseSave, running, finished: () => runFinished, + async cleanup() { + releaseSave(); + await new Promise(resolve => setImmediate(resolve)); + // A failing startup can install input after stop; reclaim only this fixture's resources. + if (app['stopped'] && hasFrontendInput(app)) app['stopped'] = false; + app['stop'](0); + client.kill(); + try { await running; } finally { + for (const listener of ownedExit) process.off('exit', listener); + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + await sandbox.dispose(); + } + }, + }; +} + +test('stopped frontends release their exit cleanup across repeated session switches and preserve other owners', async () => { + const otherOwner = () => {}; + process.once('exit', otherOwner); + const baseline = process.listeners('exit'); + try { + for (let session = 0; session < 3; session += 1) { + const fixture = await heldJournalApp(false); + try { + await fixture.saving; + assert.ok(process.listeners('exit').length > baseline.length, + 'an active frontend owns its terminal exit cleanup'); + fixture.app['stop'](0); + assert.deepEqual(process.listeners('exit'), baseline, + 'stop must release the old frontend before the next one opens, including while journaling waits'); + assert.equal(fixture.finished(), false, 'exit cleanup ownership does not remove the durable save barrier'); + } finally { + await fixture.cleanup(); + } + } + } finally { + process.off('exit', otherOwner); + } +}); + +test('fullscreen reattachment hands off and resizes before its first journal save completes', async () => { + const fixture = await heldJournalApp(true); + const {app, client, phases} = fixture; + try { + await fixture.saving; + assert.deepEqual([...phases], ['enter', 'handoff'], 'clear and visible handoff precede the program repaint'); + assert.deepEqual(client.resizes, [{columns: 90, rows: 33}], 'journal IO cannot retain the attach nudge size'); + assert.ok(hasFrontendInput(app), 'raw input is installed before visible handoff'); + app['onInput']('FOREGROUND-ONLY'); + assert.deepEqual(client.writes, ['FOREGROUND-ONLY']); + assert.deepEqual(client.acknowledgements, [], 'held save is not acknowledged as durable'); + app['stop'](0); + assert.equal(hasFrontendInput(app), false, 'stop releases input immediately'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(fixture.finished(), false, 'shutdown still waits for the held durable checkpoint'); + } finally { await fixture.cleanup(); } + assert.ok(client.acknowledgements.length > 0, 'durable save still acknowledges stream state'); +}); + +test('late fullscreen replay releases startup without awaiting its held journal save', async () => { + const fixture = await heldJournalApp(true, {replayOnStart: false}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + assert.deepEqual([...phases], [], 'startup has reached the held save before replay settles'); + assert.equal(hasFrontendInput(app), false); + client.emit('replayed', {truncatedBytes: 0}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter', 'handoff'], 'late terminal ownership cannot remain behind journal IO'); + assert.deepEqual(client.resizes, [{columns: 90, rows: 33}]); + assert.ok(hasFrontendInput(app)); + app['onInput']('FOREGROUND-LATE-REPLAY'); + assert.deepEqual(client.writes, ['FOREGROUND-LATE-REPLAY']); + assert.deepEqual(client.acknowledgements, [], 'unfinished persistence is not acknowledged'); + app['stop'](0); + assert.equal(hasFrontendInput(app), false); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(fixture.finished(), false, 'shutdown still awaits the outstanding save'); + } finally { await fixture.cleanup(); } + assert.ok(client.acknowledgements.length > 0); +}); + +test('shutdown during journal startup cannot expose input when late replay or persistence completes', async () => { + const fixture = await heldJournalApp(true, {replayOnStart: false}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + app['stop'](0); + client.emit('replayed', {truncatedBytes: 0}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'late replay cannot restart a stopped frontend'); + assert.deepEqual(client.acknowledgements, [], 'the held journal remains undurable'); + fixture.releaseSave(); + await fixture.running; + assert.deepEqual([...phases], [], 'persistence completion cannot restart frontend input or rendering'); + assert.deepEqual(client.resizes, []); + assert.equal(hasFrontendInput(app), false); + assert.ok(client.acknowledgements.length > 0, 'shutdown still completes its durable checkpoint'); + } finally { await fixture.cleanup(); } +}); + +test('ordinary reattachment replay retains the journal barrier before exposing input', async () => { + const fixture = await heldJournalApp(false, {replayOnStart: false, reattach: true}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + client.emit('replayed', {truncatedBytes: 0}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'ordinary replay does not authorize an early terminal handoff'); + assert.deepEqual(client.resizes, []); + assert.equal(hasFrontendInput(app), false); + assert.deepEqual(client.acknowledgements, []); + fixture.releaseSave(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter']); + assert.ok(hasFrontendInput(app)); + assert.ok(client.acknowledgements.length > 0); + } finally { await fixture.cleanup(); } +}); + +test('replayed exec cannot grant historical fullscreen ownership before replay settles', async () => { + const fixture = await heldJournalApp(false, {reattach: true, replayOnStart: false, restoredCommand: 'vim notes.txt'}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + client.emit('exec', 'vim notes.txt', {seq: 1, at: 1234}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'an unfinished replay cannot grant its historical program current input'); + assert.deepEqual(client.resizes, []); + assert.equal(hasFrontendInput(app), false); + client.emit('prompt', {cwd: app['shellCwd'], exitCode: 0}, {seq: 2, at: 2345}); + client.emit('replayed', {truncatedBytes: 0}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'completed replay retains the ordinary journal barrier'); + assert.equal(hasFrontendInput(app), false); + assert.deepEqual(client.acknowledgements, []); + fixture.releaseSave(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter']); + } finally { await fixture.cleanup(); } +}); + +test('withdrawn fullscreen replay retains ordinary startup durability before exposing input', async () => { + const fixture = await heldJournalApp(true, {replayOnStart: false}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + client.emit('replayed', {truncatedBytes: 0}); + // Both frames can be delivered in one socket batch, before startup's continuation. + client.emit('prompt', {cwd: app['shellCwd'], exitCode: 0}, {seq: 1, at: 1234}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'withdrawn ownership cannot bypass ordinary journal durability'); + assert.equal(hasFrontendInput(app), false); + assert.deepEqual(client.acknowledgements, []); + fixture.releaseSave(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter']); + assert.ok(hasFrontendInput(app)); + } finally { await fixture.cleanup(); } +}); + +test('renewed foreground ownership wakes startup without restoring the completed owner modes', async () => { + const oldModes = '\u001b[?1000h'; + const fixture = await heldJournalApp(true, {replayOnStart: false, modes: oldModes}); + const {app, client, phases, restoredModes} = fixture; + try { + await fixture.saving; + client.emit('replayed', {truncatedBytes: 0}); + client.emit('prompt', {cwd: app['shellCwd'], exitCode: 0}, {seq: 1, at: 1234}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], [], 'completed ownership leaves ordinary startup waiting for durability'); + // A prepared next command can start in the persistent session while that save is still held. + client.emit('exec', 'vim notes.txt', {seq: 2, at: 2345}); + const currentModes = '\u001b[?1002h'; + client.emit('data', currentModes, {seq: 3, at: 2346}); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(hasFrontendInput(app), 'the current foreground owner cannot remain behind persistence'); + assert.deepEqual([...phases], ['enter', 'handoff'], 'renderer entry must precede the only handoff and repaint resize'); + assert.deepEqual(client.resizes, [{columns: 90, rows: 33}]); + assert.equal(restoredModes.includes(oldModes), false, 'completed program modes cannot be installed for the new owner'); + assert.ok(restoredModes.at(-1)?.includes(currentModes), 'the current owner modes survive renderer entry'); + assert.deepEqual(client.acknowledgements, []); + app['onInput']('CURRENT-FOREGROUND'); + assert.deepEqual(client.writes, ['CURRENT-FOREGROUND']); + } finally { await fixture.cleanup(); } +}); + +test('mode-detected foreground ownership defers its only repaint resize until renderer entry', async () => { + const fixture = await heldJournalApp(true, {replayOnStart: false}); + const {app, client, phases, restoredModes} = fixture; + try { + await fixture.saving; + client.emit('replayed', {truncatedBytes: 0}); + client.emit('prompt', {cwd: app['shellCwd'], exitCode: 0}, {seq: 1, at: 1234}); + await new Promise(resolve => setImmediate(resolve)); + client.emit('exec', 'custom-tui', {seq: 2, at: 2345}); + const currentModes = '\u001b[?1002h'; + client.emit('data', '\u001b[?1049h' + currentModes, {seq: 3, at: 2346}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter', 'handoff']); + assert.deepEqual(client.resizes, [{columns: 90, rows: 33}]); + assert.ok(restoredModes.at(-1)?.includes(currentModes)); + assert.ok(hasFrontendInput(app)); + assert.deepEqual(client.acknowledgements, []); + app['onInput']('MODE-OWNER'); + assert.deepEqual(client.writes, ['MODE-OWNER']); + } finally { await fixture.cleanup(); } +}); + +test('ordinary startup waits for its durable journal identity before exposing frontend input', async () => { + const fixture = await heldJournalApp(false); + const {app, client, phases} = fixture; + try { + await fixture.saving; + assert.deepEqual([...phases], [], 'ordinary startup retains its initial durable checkpoint barrier'); + assert.deepEqual(client.resizes, []); + assert.equal(hasFrontendInput(app), false); + assert.deepEqual(client.acknowledgements, []); + fixture.releaseSave(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual([...phases], ['enter']); + assert.ok(hasFrontendInput(app)); + assert.ok(client.acknowledgements.length > 0); + } finally { await fixture.cleanup(); } +}); + + +for (const signal of ['SIGTERM', 'SIGHUP'] as const) { + test(`catchable startup ${signal} stops during a held journal save without restoring input`, async () => { + const fixture = await heldJournalApp(false, {replayOnStart: false, reattach: true}); + const {app, client, phases} = fixture; + try { + await fixture.saving; + process.emit(signal); + assert.equal(app['stopped'], true, 'the startup signal must reach normal shutdown'); + assert.equal(client.detaches, 1, 'the service-backed shell is detached'); + assert.deepEqual(phases, []); + assert.equal(hasFrontendInput(app), false); + assert.deepEqual(client.acknowledgements, [], 'held persistence remains undurable'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(fixture.finished(), false, 'normal shutdown still awaits journal durability'); + fixture.releaseSave(); + assert.equal(await fixture.running, 0); + assert.deepEqual(phases, [], 'save completion cannot re-enter a stopped frontend'); + assert.equal(hasFrontendInput(app), false); + assert.ok(client.acknowledgements.length > 0); + } finally { await fixture.cleanup(); } + }); + + test(`catchable startup ${signal} during the host probe cannot create a later journal`, async () => { + const sandbox = new LiveSandbox(); + const keys = ['HOME', 'XDG_CONFIG_HOME', 'NMSH_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP']; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + for (const key of keys) process.env[key] = sandbox.env[key]; + const client = new ReattachedSession(false); + const app = new TerminalApp({client, mode: 'service', sessionId: 'probe-signal-test'}); + app['render'] = () => {}; + app['refreshContext'] = async () => {}; + app['renderer'].enter = () => { throw new Error('stopped probe cannot enter the renderer'); }; + app['renderer'].leave = () => {}; + let created = 0; + const create = app['transcriptStore'].create.bind(app['transcriptStore']); + app['transcriptStore'].create = (...args) => { created += 1; return create(...args); }; + const stdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const stdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const rawMode = Object.getOwnPropertyDescriptor(process.stdin, 'setRawMode'); + const rawState = Object.getOwnPropertyDescriptor(process.stdin, 'isRaw'); + const exitBefore = new Set(process.listeners('exit')); + let ownedExit = process.listeners('exit').slice(0, 0); + const write = process.stdout.write; + const rawChanges: boolean[] = []; + let observed = false; + let running: Promise | undefined; + try { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdin, 'isRaw', {configurable: true, writable: true, value: false}); + Object.defineProperty(process.stdin, 'setRawMode', {configurable: true, value: (raw: boolean) => { + rawChanges.push(raw); process.stdin.isRaw = raw; return process.stdin; + }}); + process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { + if (typeof chunk === 'string' && chunk.includes(HOST_QUERY)) { + observed = true; + process.emit(signal); // Exact probe boundary, with its unchanged bounded timer. + return true; + } + return Reflect.apply(write, process.stdout, [chunk, ...args]); + }) as typeof process.stdout.write; + running = app.run(); + ownedExit = process.listeners('exit').filter(listener => !exitBefore.has(listener)); + assert.equal(observed, true, 'the real host probe issued its query'); + assert.equal(app['stopped'], true, 'the probe signal must reach normal shutdown'); + assert.equal(client.detaches, 1); + assert.equal(await running, 0); + assert.equal(created, 0, 'probe completion must not begin a journal after shutdown'); + assert.equal(hasFrontendInput(app), false); + assert.equal(rawChanges.at(-1), false, 'normal shutdown restores the host raw mode'); + } finally { + process.stdout.write = write; + app['stop'](0); + client.kill(); + try { if (running) await running; } finally { + for (const listener of ownedExit) process.off('exit', listener); + for (const [target, key, descriptor] of [ + [process.stdin, 'isTTY', stdinTTY], [process.stdout, 'isTTY', stdoutTTY], + [process.stdin, 'setRawMode', rawMode], [process.stdin, 'isRaw', rawState], + ] as const) { + if (descriptor) Object.defineProperty(target, key, descriptor); + else Reflect.deleteProperty(target, key); + } + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + await sandbox.dispose(); + } + } + }); +} diff --git a/tests/sessionViewRefresh.test.ts b/tests/sessionViewRefresh.test.ts new file mode 100644 index 00000000..365fb462 --- /dev/null +++ b/tests/sessionViewRefresh.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {createServer, type Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {encodeMessage, FrameDecoder, PROTOCOL_VERSION, type ClientMessage, type SessionInfo} from '../src/session/SessionProtocol.js'; +import {RUNTIME_DIR_ENV, socketPathFor} from '../src/session/runtimeDir.js'; +import type {SessionNotice} from '../src/session/SessionNotices.js'; +import {resumeSelection} from '../src/sessions/ResumeBrowser.js'; +import {stripAnsi} from '../src/util/text.js'; + +for (const notices of [true, false]) { + test(`live session refresh recovers waiting state and keeps selection, query, draft and focus (notices ${notices ? 'on' : 'off'})`, async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-live-view-'))); + const previous = process.env[RUNTIME_DIR_ENV]; + process.env[RUNTIME_DIR_ENV] = root; + const peers = new Set(); + const own: SessionInfo = {id: 'own', pid: 4241, state: 'attached', cwd: '/fixture', createdAt: 1}; + const target: SessionInfo = {id: 'target', pid: 4242, state: 'attached', cwd: '/fixture/project', createdAt: 20, running: 'python3 askyn.py'}; + let sessions = [own, target]; + let ended: SessionNotice[] = []; + let failList = false; + const server = createServer(socket => { + peers.add(socket); + socket.on('close', () => peers.delete(socket)); + socket.on('error', () => {}); + const decoder = new FrameDecoder(); + socket.on('data', chunk => { + for (const result of decoder.push(String(chunk))) { + if (!result.ok) continue; + const message = result.message as ClientMessage; + if (message.type === 'hello') socket.write(encodeMessage({type: 'welcome', version: PROTOCOL_VERSION, service: 'nmshd'})); + if (message.type === 'list') { + if (failList) socket.destroy(); + else socket.end(encodeMessage({type: 'sessions', sessions, ended})); + } + } + }); + }); + await new Promise(resolve => server.listen(socketPathFor(root), resolve)); + let app: TerminalApp | undefined; + try { + app = new TerminalApp(); + Object.defineProperty(app, 'sessionMode', {value: 'service'}); + app['sessionId'] = own.id; + app['configuration'] = {...app['configuration'], sessionNotices: notices}; + let renders = 0; + Object.defineProperty(app, 'render', {value: () => { renders += 1; }}); + await app['openSessionsView'](); + const browser = app['resumeBrowser']!; + browser.query = 'python3'; + browser.selectedIndex = 0; + app['editor'].insert('preserved draft'); + app['focusedCommandIndex'] = 4; + const rows = () => app!['sessionsViewRows'](browser, 200).map(stripAnsi).join('\n'); + assert.equal(resumeSelection(browser)?.session.id, target.id); + assert.match(rows(), /Running/u, 'the opening snapshot contains a temporary non-waiting state'); + sessions = [own, {...target, inputSince: Date.now() - 5_000, inputConfidence: 'confirmed', inputMode: 'key', inputPrompt: 'First question. Fix?'}, + {...target, id: 'earlier', pid: 4243, createdAt: 10}]; + const before = renders; + await app['refreshNotices'](); + assert.match(rows(), /Waiting for input[\s\S]*First question\. Fix\?/u, + 'a later authenticated list recovers the wait without reopening the view'); + assert.equal(app['resumeBrowser'], browser); + assert.equal(resumeSelection(browser)?.session.id, target.id, 'selection follows identity when another row appears before it'); + assert.equal(browser.selectedIndex, 1); + assert.equal(browser.query, 'python3'); + assert.equal(app['editor'].text, 'preserved draft'); + assert.equal(app['focusedCommandIndex'], 4); + assert.ok(renders > before, 'live data repaints even when no notice changes'); + const validated = browser.live; + failList = true; + await app['refreshNotices'](); + assert.equal(browser.live, validated, 'a failed request is not an authenticated empty registry'); + assert.equal(resumeSelection(browser)?.session.id, target.id, 'transient failures retain selected identity'); + assert.equal(browser.query, 'python3'); + assert.equal(app['editor'].text, 'preserved draft'); + failList = false; + sessions = [own, {...target, state: 'detached'}]; + await app['refreshNotices'](); + browser.confirmKill = target.id; + await app['refreshNotices'](); + assert.equal(browser.confirmKill, target.id, 'a still-detached target keeps its explicit confirmation'); + sessions = [own, target]; + await app['refreshNotices'](); + assert.equal(browser.confirmKill, undefined, 'attaching elsewhere withdraws the stale kill confirmation'); + browser.confirmKill = target.id; + sessions = [own]; + ended = [{sessionId: 'ended', kind: 'ended', at: Date.now(), exitCode: 1}]; + await app['refreshNotices'](); + assert.equal(browser.confirmKill, undefined, 'a vanished target cannot retain confirmation'); + assert.equal(browser.selectedIndex, 0); + assert.equal(resumeSelection(browser), undefined); + assert.equal(app['noticeView'].notices.length, notices ? 1 : 0, 'live viewing respects the notice preference'); + if (!notices) assert.equal(app['noticeExpiry'], undefined, 'suppressed notices schedule no expiry wake-up'); + sessions = []; + await app['refreshNotices'](); + assert.deepEqual(browser.live, [], 'a successfully validated empty registry does remove the old snapshot'); + assert.equal(resumeSelection(browser), undefined); + assert.equal(app['editor'].text, 'preserved draft'); + assert.equal(app['focusedCommandIndex'], 4); + } finally { + app?.['stop'](0); + app?.['session'].kill(); + for (const peer of peers) peer.destroy(); + await new Promise(resolve => server.close(() => resolve())); + if (previous === undefined) delete process.env[RUNTIME_DIR_ENV]; else process.env[RUNTIME_DIR_ENV] = previous; + rmSync(root, {recursive: true, force: true}); + } + }); +} + +test('live session view uses the existing poll with notices off, then releases it when closed or raw-owned', () => { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'sessionMode', {value: 'service'}); + let polls = 0; + Object.defineProperty(app, 'refreshNotices', {value: async () => { polls += 1; }}); + try { + app['configuration'] = {...app['configuration'], sessionNotices: false}; + app['presentationStarted'] = true; + app['resumeBrowser'] = {live: [], sessions: [], commandText: new Map(), query: '', week: 0, selectedIndex: 0, indexing: false, liveOnly: true}; + app['syncNotices'](); + assert.ok(app['noticeTimer'], 'explicit live viewing remains current without enabling notices'); + assert.equal(polls, 1); + app['resumeBrowser'] = undefined; + app['syncNotices'](); + assert.equal(app['noticeTimer'], undefined, 'closed views do not keep polling with notices off'); + app['resumeBrowser'] = {live: [], sessions: [], commandText: new Map(), query: '', week: 0, selectedIndex: 0, indexing: false, liveOnly: true}; + app['syncNotices'](); + app['passthrough'] = true; + app['syncNotices'](); + assert.equal(app['noticeTimer'], undefined, 'raw foreground ownership suspends the poll'); + } finally { + app['passthrough'] = false; + app['stop'](0); + app['session'].kill(); + } +}); diff --git a/tests/setupCat.test.ts b/tests/setupCat.test.ts index f8fe97f6..ee754da8 100644 --- a/tests/setupCat.test.ts +++ b/tests/setupCat.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {mkdtemp, readFile, rm, writeFile, mkdir} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; import {TerminalApp} from '../src/app/TerminalApp.js'; import type {Key} from '../src/terminal/keys.js'; import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; @@ -192,6 +193,12 @@ async function withApp(run: (app: TerminalApp, configPath: string) => Promise {}; + // /setup is normally entered from the ready composer. Calling runSlash + // directly during startup bypasses that boundary; a slow shell's recovery + // panel correctly owns Enter until its first authenticated prompt. + const deadline = Date.now() + 20_000; + while (app['startupPending'] && Date.now() < deadline) await delay(10); + assert.equal(app['startupPending'], false, 'the shell reached its first prompt before panel interaction'); await run(app, path); } finally { app['stop'](0); @@ -202,6 +209,21 @@ async function withApp(run: (app: TerminalApp, configPath: string) => Promise withApp(async (app, path) => { + const before = await readFile(path, 'utf8'); + await app['runSlash']('/setup', {kind: 'setup'}); + const state = app['setupState']!; + state.section = SETUP_SECTIONS.length - 1; + app['startupPending'] = true; + app['startupPanel'] = {since: Date.now(), tail: 'waiting for startup'}; + app['handleKey']({kind: 'enter'}); + assert.equal(app['setupState'], state, 'startup recovery retains ownership of Enter'); + assert.equal(await readFile(path, 'utf8'), before, 'nothing is applied while startup is blocked'); + app['endStartupWatch'](); + app['handleKey']({kind: 'enter'}); + assert.equal(app['setupState'], undefined, 'the ready shell releases panel interaction'); +}, {onboardingComplete: true, glyphChoiceComplete: true, toolsSetupComplete: true})); + test('app: /setup never writes on open or cancel; Apply persists through the normal path and keeps unknown fields', () => withApp(async (app, path) => { const before = await readFile(path, 'utf8'); await app['runSlash']('/setup', {kind: 'setup'}); diff --git a/tests/shellProtocol.test.ts b/tests/shellProtocol.test.ts index 2649f0bd..e65fd7f7 100644 --- a/tests/shellProtocol.test.ts +++ b/tests/shellProtocol.test.ts @@ -2,6 +2,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {MAX_MARKER_PAYLOAD, ShellProtocolDecoder} from '../src/shell/ShellProtocol.js'; +test('only an authenticated reader marker transfers terminal ownership, including split markers', () => { + const decoder = new ShellProtocolDecoder('token'); + const foreign = '\u001b]777;nmsh;other;reader\u0007'; + assert.deepEqual(decoder.push(foreign), [{kind: 'data', data: foreign}]); + assert.deepEqual(decoder.push('\u001b]777;nmsh;token;rea'), []); + assert.deepEqual(decoder.push('der\u0007Name: '), [{kind: 'reader'}, {kind: 'data', data: 'Name: '}]); +}); + test('extracts split shell markers without leaking them into output', () => { const decoder = new ShellProtocolDecoder('token'); assert.deepEqual(decoder.push('hello\u001B]777;nmsh;tok'), [{kind: 'data', data: 'hello'}]); diff --git a/tests/socketClientLifecycle.test.ts b/tests/socketClientLifecycle.test.ts new file mode 100644 index 00000000..422da75e --- /dev/null +++ b/tests/socketClientLifecycle.test.ts @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {createServer, type Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {SocketSessionClient} from '../src/session/SocketSessionClient.js'; +import {encodeMessage, FrameDecoder, PROTOCOL_VERSION, type ClientMessage} from '../src/session/SessionProtocol.js'; +import {socketPathFor} from '../src/session/runtimeDir.js'; + +for (const [started, operation] of [[false, 'kill'], [false, 'detach'], [true, 'kill'], [true, 'detach']] as const) { + test(`${operation} closes a ${started ? 'started' : 'held'} client after peer shutdown, preserving output ownership`, async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-socket-close-'))); + const peers = new Set(); + const received: ClientMessage[] = []; + let completed!: () => void; + const requestReceived = new Promise(resolve => { completed = resolve; }); + const server = createServer(socket => { + peers.add(socket); + socket.on('close', () => peers.delete(socket)); + socket.on('error', () => {}); + const decoder = new FrameDecoder(); + socket.on('data', data => { + for (const result of decoder.push(data.toString())) { + if (!result.ok) continue; + const message = result.message as ClientMessage; + received.push(message); + if (message.type === 'hello') socket.write(encodeMessage({type: 'welcome', version: PROTOCOL_VERSION, service: 'nmshd', startupSafety: 1})); + else if (message.type === 'create') socket.write(encodeMessage({type: 'created', sessionId: 'held', pid: 4242}) + encodeMessage({type: 'output', data: 'PRESTART'})); + else if (message.type === 'terminate' || message.type === 'detach') { + // A final output frame arrives only after connect has paused input. + socket.end(encodeMessage({type: 'output', data: 'HELD-OUTPUT'})); + completed(); + } + } + }); + }); + await new Promise(resolve => server.listen(socketPathFor(root), resolve)); + let client: SocketSessionClient | undefined; + try { + client = await SocketSessionClient.connect({socketPath: socketPathFor(root), cwd: root, columns: 80, rows: 24, env: {}}); + const output: string[] = []; + client.on('data', data => { output.push(data); }); + const socket = client['socket']; + let closed!: () => void; + const closure = new Promise(resolve => { closed = resolve; }); + socket.once('close', closed); + if (started) client.start(); + client[operation](); + await requestReceived; + await new Promise(resolve => server.close(() => resolve())); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([closure, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`client retained after peer close: ${JSON.stringify({ + destroyed: socket.destroyed, readableFlowing: socket.readableFlowing, readableEnded: socket.readableEnded, + readableLength: socket.readableLength, writableEnded: socket.writableEnded, + })}`)), 1000); + })]); + } finally { if (timer) clearTimeout(timer); } + assert.ok(received.some(message => message.type === (operation === 'kill' ? 'terminate' : 'detach')), + 'the lifecycle request reaches the peer before local shutdown'); + assert.deepEqual(output, started ? ['PRESTART', 'HELD-OUTPUT'] : [], + 'started clients keep output order; cancelled pre-start output stays withheld'); + assert.equal(socket.destroyed, true, 'the readable half does not retain a worker'); + } finally { + client?.['socket'].destroy(); + for (const peer of peers) peer.destroy(); + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + rmSync(root, {recursive: true, force: true}); + } + }); +} diff --git a/tests/startupBlocked.test.ts b/tests/startupBlocked.test.ts index b01a590c..37d0f9d9 100644 --- a/tests/startupBlocked.test.ts +++ b/tests/startupBlocked.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {join, resolve} from 'node:path'; import {once} from 'node:events'; import {setTimeout as sleep} from 'node:timers/promises'; import {ShellSession} from '../src/shell/ShellSession.js'; @@ -110,6 +110,16 @@ import {LiveSandbox, until, processAlive, strip} from './helpers/liveFrontend.js const NOTICE = {NMSH_STARTUP_NOTICE_MS: '300'}; const zshrc = (sandbox: LiveSandbox, body: string) => writeFileSync(join(sandbox.home, '.zshrc'), body); +/** Release only the fixture gate, then retain strict ownership cleanup and both failure causes. */ +async function disposeStartupGate(sandbox: LiveSandbox, gate: string, primary: unknown): Promise { + const errors: unknown[] = []; + try { writeFileSync(gate, ''); } catch (error) { errors.push(error); } + try { await sandbox.dispose(); } catch (error) { errors.push(error); } + if (errors.length) throw new AggregateError(primary === undefined ? errors : [primary, ...errors], + 'startup fixture cleanup failed'); +} + + test('frontend: a blocked startup read shows an explicit state with its prompt, swallows no answer, and Ctrl+C aborts', async () => { const sandbox = new LiveSandbox({}, NOTICE); try { @@ -136,25 +146,34 @@ test('frontend: a blocked startup read shows an explicit state with its prompt, } finally { await sandbox.dispose(); } }); -test('frontend: slow startup shows the state, keeps typed text, then becomes an ordinary ready composer', async () => { - const sandbox = new LiveSandbox({}, NOTICE); - try { +for (const delayedJournal of [false, true]) { + test(`frontend: slow startup shows the state, keeps typed text, then becomes an ordinary ready composer${delayedJournal ? ' when the notice overtakes journal startup' : ''}`, async () => { + const sandbox = new LiveSandbox({}, NOTICE); const gate = join(sandbox.home, 'gate'); - zshrc(sandbox, `while [ ! -f ${gate} ]; do sleep 0.05; done\n`); - const app = sandbox.launch(); - await app.waitFor(/Shell startup is still running/); - app.pty.write('echo KEPT-$((20+22))'); - app.pty.write('\r'); // held: Enter does nothing while startup is pending - await new Promise(resolve => setTimeout(resolve, 400)); - assert.doesNotMatch(strip(app.output), /KEPT-42/); - writeFileSync(gate, ''); - await app.waitFor(/❯ echo KEPT-\$\(\(20\+22\)\)/, app.output.indexOf('Shell startup')); - app.pty.write('\r'); - await app.waitFor(/KEPT-42/, app.output.indexOf('Shell startup')); - app.pty.write('\u0004'); - await app.waitExit(); - } finally { await sandbox.dispose(); } -}); + let primary: unknown; + try { + zshrc(sandbox, `if [[ -t 0 ]]; then while [ ! -f ${gate} ]; do sleep 0.05; done; fi\n`); + const entry = delayedJournal ? resolve('tests/fixtures/slowJournalAttach.ts') : undefined; + const app = sandbox.launch([], {cols: 100, rows: 30}, {}, entry); + await app.waitFor(/Shell startup is still running/); + if (delayedJournal) { + assert.match(strip(app.output), /TEST-JOURNAL-WAIT/); + assert.doesNotMatch(strip(app.output), /❯/, 'a delayed first frame must show the blocked state'); + } + app.pty.write('echo KEPT-$((20+22))'); + app.pty.write('\r'); // held: Enter does nothing while startup is pending + await new Promise(resolve => setTimeout(resolve, 400)); + assert.doesNotMatch(strip(app.output), /KEPT-42/); + writeFileSync(gate, ''); + await app.waitFor(/❯ echo KEPT-\$\(\(20\+22\)\)/, app.output.indexOf('Shell startup')); + app.pty.write('\r'); + await app.waitFor(/KEPT-42/, app.output.indexOf('Shell startup')); + app.pty.write('\u0004'); + await app.waitExit(); + } catch (error) { primary = error; throw error; } + finally { await disposeStartupGate(sandbox, gate, primary); } + }); +} test('frontend: clean startup never shows the startup state', async () => { const sandbox = new LiveSandbox(); @@ -183,14 +202,16 @@ test('frontend: a command submitted early during a slow bootstrap still runs exa test('service: detach and reattach during a blocked startup keep the state, the held input and the prompt text', async () => { const sandbox = new LiveSandbox({}, {NMSH_STARTUP_NOTICE_MS: '1500'}); + const gate = join(sandbox.home, 'gate'); + let primary: unknown; try { - const gate = join(sandbox.home, 'gate'); - zshrc(sandbox, `print -n "Waiting for gate> "\nwhile [ ! -f ${gate} ]; do sleep 0.05; done\n`); - const first = sandbox.launch(); - await first.waitFor(/❯/); + zshrc(sandbox, `if [[ -t 0 ]]; then print -n "Waiting for gate> "; while [ ! -f ${gate} ]; do sleep 0.05; done; fi\n`); + // Actual editor/submit pipeline, deterministically before the notice; + // physical early typing remains covered by the separate frontend case. + const first = sandbox.launch([], {cols: 100, rows: 30}, {}, resolve('tests/fixtures/earlyStartupSubmission.ts')); + await first.waitFor(/TEST-EARLY-SUBMISSION/); await until(async () => (await sandbox.sessions()).length === 1, 15000, 'live session listed'); const {id, pid} = (await sandbox.sessions())[0]!; - first.pty.write('echo HELD-$((1+1))\r'); // submitted before notice, held by service queue await first.waitFor(/Shell startup is still running/); await until(async () => (await sandbox.transcripts().list()).some(j => j.live?.running?.command === 'echo HELD-$((1+1))'), 15000, 'submission checkpoint'); first.pty.kill('SIGHUP'); @@ -208,7 +229,8 @@ test('service: detach and reattach during a blocked startup keep the state, the second.pty.write('\u0004'); await second.waitExit(); await until(async () => (await sandbox.sessions()).length === 0, 15000, 'session ended'); - } finally { await sandbox.dispose(); } + } catch (error) { primary = error; throw error; } + finally { await disposeStartupGate(sandbox, gate, primary); } }); test('service: shutting down the shell during a blocked startup leaves no session or process behind', async () => { diff --git a/tests/startupSignalLifecycle.test.ts b/tests/startupSignalLifecycle.test.ts new file mode 100644 index 00000000..81954eec --- /dev/null +++ b/tests/startupSignalLifecycle.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {LiveSandbox, processAlive, until} from './helpers/liveFrontend.js'; + +for (const signal of ['SIGTERM', 'SIGHUP'] as const) { + test(`real startup ${signal} reaps a blocked semantic helper while preserving the service shell`, {timeout: 60_000}, async () => { + const sandbox = new LiveSandbox(); + const configGate = join(sandbox.home, 'config-gate'); + const journalGate = join(sandbox.home, 'journal-gate'); + const marker = join(sandbox.home, 'semantic-pid'); + let primary: unknown; + try { + writeFileSync(join(sandbox.home, '.zshrc'), + `if [[ ! -t 0 ]]; then print -r -- $$ > '${marker}'; fi\nwhile [ ! -f '${configGate}' ]; do sleep 0.05; done\n`); + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {NMSH_TEST_JOURNAL_GATE: journalGate}, + resolve('tests/fixtures/heldStartupJournal.ts')); + await frontend.waitFor(/TEST-STARTUP-JOURNAL-WAIT/); + await until(() => existsSync(marker) && /^\d+\s*$/u.test(readFileSync(marker, 'utf8')), 15000, 'owned semantic PID marker'); + const semanticPid = Number(readFileSync(marker, 'utf8').trim()); + assert.ok(Number.isSafeInteger(semanticPid) && semanticPid > 1 && processAlive(semanticPid)); + await until(async () => (await sandbox.sessions()).length === 1, 15000, 'managed shell listed'); + const {id, pid} = (await sandbox.sessions())[0]!; + assert.notEqual(semanticPid, pid, 'the detached helper and managed shell have different owners'); + frontend.pty.kill(signal); + await until(() => !processAlive(semanticPid), 10000, + () => `semantic helper ${semanticPid} to end after ${signal}; frontend exit=${frontend.exitCode}, signal=${frontend.exitSignal}`); + assert.equal(frontend.exitCode, undefined, 'shutdown still waits for the held journal'); + await until(async () => (await sandbox.sessions()).some(session => session.id === id && session.state === 'detached'), 15000, 'shell detached'); + assert.ok(processAlive(pid), 'normal frontend termination preserves the real service shell'); + writeFileSync(journalGate, ''); + assert.equal(await frontend.waitExit(), 0); + assert.equal(frontend.exitSignal ?? 0, 0, 'normal shutdown has no unhandled exit signal'); + assert.ok(processAlive(pid)); + } catch (error) { primary = error; throw error; } + finally { + const errors: unknown[] = []; + for (const gate of [configGate, journalGate]) { + try { writeFileSync(gate, ''); } catch (error) { errors.push(error); } + } + try { await sandbox.dispose(); } catch (error) { errors.push(error); } + if (errors.length) throw new AggregateError(primary === undefined ? errors : [primary, ...errors], 'startup signal fixture cleanup failed'); + } + }); +} diff --git a/tests/stoppedSessionPrompt.test.ts b/tests/stoppedSessionPrompt.test.ts new file mode 100644 index 00000000..4aae4193 --- /dev/null +++ b/tests/stoppedSessionPrompt.test.ts @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {SessionClient, SessionClientEvents} from '../src/session/SessionClient.js'; +import {isolateConfig} from './support/isolatedConfig.js'; + +/** No PTY or semantic helper: only the real frontend's subscribed session boundary. */ +class FixtureClient extends EventEmitter implements SessionClient { + readonly features = new Set(); + constructor(private readonly exitOnStart?: number) { super(); } + start(): void { if (this.exitOnStart !== undefined) this.emit('exit', {exitCode: this.exitOnStart}); } + submit(): void {} + write(): void {} + interrupt(): void {} + endInput(): void {} + resize(): void {} + kill(): void {} + detach(): void {} + queue(): void {} + ack(): void {} + async switchShell(): Promise { throw new Error('not used'); } +} + +for (const mode of ['in-process', 'service'] as const) { + test(`stopped ${mode} frontend ignores late prompt and exec events without restarting work or changing cwd`, () => { + const isolation = isolateConfig(); + const client = new FixtureClient(); + const app = new TerminalApp({client, mode, shell: 'fish'}); + let collections = 0; + let projections = 0; + let executions = 0; + try { + app['render'] = () => {}; + app['refreshContext'] = async () => { collections += 1; }; + app['hostSemantics'].prompt = () => { projections += 1; }; + app['hostSemantics'].exec = () => { executions += 1; }; + const stamp = (seq: number) => mode === 'service' ? {seq, at: 1234} : {at: 1234}; + client.emit('prompt', {cwd: '/fixture/active', exitCode: 0}, stamp(1)); + assert.equal(collections, 1, 'an active prompt still requests context'); + assert.equal(projections, 1, 'an active prompt still reaches host semantics'); + assert.equal(app['shellCwd'], '/fixture/active'); + assert.equal(app['context'].cwd, '/fixture/active'); + + app['stop'](0); + client.kill(); + const before = {cwd: app['shellCwd'], context: app['context'], seq: app['streamSeq']}; + client.emit('prompt', {cwd: '/fixture/late', exitCode: 7}, stamp(2)); + assert.equal(collections, 1, 'shutdown must not start another collector'); + assert.equal(projections, 1, 'shutdown must not project another prompt'); + assert.equal(app['shellCwd'], before.cwd); + assert.equal(app['context'], before.context); + assert.equal(app['context'].cwd, '/fixture/active'); + assert.equal(app['streamSeq'], before.seq, 'a discarded prompt is not acknowledged'); + client.emit('exec', 'vim notes.txt', stamp(3)); + assert.equal(executions, 0, 'shutdown must not project another exec'); + assert.equal(app['running'], undefined, 'shutdown must not claim another foreground command'); + assert.equal(app['passthrough'], false, 'shutdown must not hand the terminal to a new owner'); + assert.equal(app['streamSeq'], before.seq, 'a discarded exec is not acknowledged'); + } finally { + app['stop'](0); + client.kill(); + isolation.restore(); + } + }); +} + +for (const boundary of ['exit-before-run', 'late-passthrough-after-stop'] as const) { + test(`stopped startup cannot re-enable raw input at ${boundary}`, async () => { + const isolation = isolateConfig(); + const input = process.stdin; + const tty = Object.getOwnPropertyDescriptor(input, 'isTTY'); + const raw = Object.getOwnPropertyDescriptor(input, 'setRawMode'); + const modes: boolean[] = []; + Object.defineProperty(input, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(input, 'setRawMode', {configurable: true, value: (mode: boolean) => { modes.push(mode); return input; }}); + const client = new FixtureClient(boundary === 'exit-before-run' ? 17 : undefined); + let app: TerminalApp | undefined; + try { + app = new TerminalApp({client, mode: 'service', shell: 'fish', sessionId: 'stopped-startup', + ...(boundary === 'late-passthrough-after-stop' ? {attached: { + sessionId: 'stopped-startup', pid: 123, cwd: '/fixture', fullscreen: 1, running: 'zsh fullscreen.zsh', ackedSeq: 0, + }} : {})}); + if (boundary === 'late-passthrough-after-stop') { + client.emit('replayed', {truncatedBytes: 0}); + app['stop'](17); + } + assert.equal(app['stopped'], true); + modes.length = 0; // Observe only attempted input ownership after shutdown. + let saves = 0; + app['transcriptStore'].save = async () => { saves += 1; }; + let observations = 0; + app['renderer'].observePassthrough = () => { observations += 1; }; + if (boundary === 'late-passthrough-after-stop') client.emit('data', '', {}); + assert.equal(observations, 0, 'shutdown must not accept more foreground terminal state'); + assert.equal(await app.run(), 17, 'the original session exit remains authoritative'); + assert.deepEqual(modes, [], 'shutdown cannot be followed by a new raw-mode transition'); + assert.equal(saves, 0, 'an already stopped launch starts no journal'); + assert.equal(input.listeners('data').includes(app['onInput']), false); + } finally { + app?.['stop'](17); + client.kill(); + if (tty) Object.defineProperty(input, 'isTTY', tty); else Reflect.deleteProperty(input, 'isTTY'); + if (raw) Object.defineProperty(input, 'setRawMode', raw); else Reflect.deleteProperty(input, 'setRawMode'); + isolation.restore(); + } + }); +} diff --git a/tests/terminalModeReconciliation.test.ts b/tests/terminalModeReconciliation.test.ts index 55b50df7..8327b630 100644 --- a/tests/terminalModeReconciliation.test.ts +++ b/tests/terminalModeReconciliation.test.ts @@ -103,3 +103,36 @@ for (const leaveSuspended of [false, true]) { assert.deepEqual(model.snapshot().kittyStack, {main: ['9'], alternate: ['7']}); }); } + +for (const leaveSuspended of [false, true]) { + test(`Kitty pushes forwarded before renderer entry preserve inherited stacks (suspended exit=${leaveSuspended})`, () => { + const model = new TerminalModeModel(); + model.feed('\u001b[>9u\u001b[?1049h\u001b[>7u\u001b[?1049l'); + const renderer = new TerminalRenderer(data => model.feed(data), resolveHostCapabilities({TERM_PROGRAM: 'ghostty'})); + const child = '\u001b[>5u'; + renderer.observePassthrough(child); + model.feed(child); // live output before startup can enter the renderer, on MAIN + assert.deepEqual(model.snapshot().kittyStack, {main: ['9', '5'], alternate: ['7']}); + renderer.enter(); + renderer.suspendForPassthrough(child); // startup restores the current owner on ALTERNATE + if (!leaveSuspended) { + renderer.resumeAfterPassthrough(); + assert.deepEqual(model.snapshot().kittyStack, {main: ['9'], alternate: ['7', '1']}); + } + renderer.leave(); + assert.equal(model.snapshot().screen, 'main'); + assert.deepEqual(model.snapshot().kittyStack, {main: ['9'], alternate: ['7']}); + }); +} + +test('shutdown before renderer entry releases only the forwarded child keyboard pushes', () => { + const model = new TerminalModeModel(); + model.feed('\u001b[>9u\u001b[?1049h\u001b[>7u\u001b[?1049l'); + const renderer = new TerminalRenderer(data => model.feed(data), resolveHostCapabilities({TERM_PROGRAM: 'ghostty'})); + const child = '\u001b[>5u'; + renderer.observePassthrough(child); + model.feed(child); + renderer.leave(); + assert.equal(model.snapshot().screen, 'main'); + assert.deepEqual(model.snapshot().kittyStack, {main: ['9'], alternate: ['7']}); +}); diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts index 427915b9..660f384f 100644 --- a/tests/themeBridge.test.ts +++ b/tests/themeBridge.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; import {existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {dirname, join} from 'node:path'; import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; import {addTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; @@ -480,9 +480,10 @@ test('reports: factual statuses; bat needs a reviewed setup; delta is detected o test('reloadTmux reaches only the tmux server of its own environment (an isolated test server here), never the person\'s', async t => { const box = sandbox(); - const tmux = resolveCommand('tmux', box.env.PATH); + const tmux = resolveCommand('tmux', process.env.PATH); if (!tmux) { box.done(); t.skip('tmux is not installed'); return; } - const env = {...box.env, TMUX: ''}; + // Resolve the installed tool portably, but retain the isolated socket directory. + const env = {...box.env, PATH: dirname(tmux), TMUX: ''}; const run = (...args: string[]) => execFileSync(tmux, ['-f', '/dev/null', ...args], {env: {...env, PATH: '/usr/bin:/bin'}, encoding: 'utf8', timeout: 5000}); try { run('new-session', '-d', '-s', 'nmsh-bridge-test'); diff --git a/tests/tmpdirHygiene.test.ts b/tests/tmpdirHygiene.test.ts index c00cd9cc..4a4c808e 100644 --- a/tests/tmpdirHygiene.test.ts +++ b/tests/tmpdirHygiene.test.ts @@ -1,5 +1,6 @@ import test, {mock} from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; import {existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import {spawn} from 'node:child_process'; @@ -158,3 +159,40 @@ test('disposal observes an idle service that starts listening after its initial child.kill(); await exited; await sandbox.dispose(); } }); + +test('suite runner honors explicit temporary storage without silently falling back', async () => { + const previous = process.env.NMSH_TEST_TMPDIR; + const requested = join(tmpdir(), 'external'); + process.env.NMSH_TEST_TMPDIR = requested; + const sentinel = new Error('stop before filesystem writes'); + const probe = mock.method(fs, 'mkdtempSync', (prefix: string) => { + assert.equal(prefix, join(requested, 'nt-')); + throw sentinel; + }); + syncBuiltinESMExports(); + try { + await assert.rejects(runTestFiles(['unused.test.mjs']), error => error === sentinel); + } finally { + probe.mock.restore(); syncBuiltinESMExports(); + if (previous === undefined) delete process.env.NMSH_TEST_TMPDIR; else process.env.NMSH_TEST_TMPDIR = previous; + } +}); + + +test('suite runner confines home-directory caches to its private temporary storage', async () => { + const fixture = mkdtempSync(join(tmpdir(), 'runner-home-')); + const file = join(fixture, 'home.test.mjs'); + try { + writeFileSync(file, [ + "import assert from 'node:assert/strict';", + "import {existsSync, writeFileSync} from 'node:fs';", + "import {join} from 'node:path';", + "assert.equal(process.env.HOME, join(process.env.TMPDIR, 'home'));", + "assert.ok(existsSync(process.env.HOME));", + "writeFileSync(join(process.env.HOME, '.owned-test-cache'), 'fixture');", + ].join('\n')); + const result = await runTestFiles([file], [], {stdio: 'ignore'}); + assert.equal(result.code, 0, 'home caches stay inside the owned suite root'); + assert.ok(!existsSync(result.root), 'home caches are removed with that root'); + } finally { rmSync(fixture, {recursive: true, force: true}); } +}); diff --git a/tests/transcriptPresenter.test.ts b/tests/transcriptPresenter.test.ts index 5f000694..357e6b9d 100644 --- a/tests/transcriptPresenter.test.ts +++ b/tests/transcriptPresenter.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {AnsiOutputParser} from '../src/output/AnsiOutputParser.js'; import {TranscriptPresenter, type TranscriptView} from '../src/output/TranscriptPresenter.js'; -import type {CompletedCommand} from '../src/output/OutputBuffer.js'; +import {OutputBuffer, type CompletedCommand} from '../src/output/OutputBuffer.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE} from '../src/prompt/configuration.js'; /** A synthetic view built straight from records, with no OutputBuffer involved. */ @@ -84,3 +84,33 @@ test('decorate paints command surface, hover and focus without touching stored t assert.match(presenter.decorate(hint, undefined, {now: 0, focusedCommandIndex: 0}), /^\u001B\[48;2;60;60;80m/u); assert.match(presenter.stickyHeaderSurface('cmd'), /^\u001B\[48;2;38;38;48mcmd\u001B\[K/u); }); + +test('NO_COLOR reattach suppresses saved authored styles while preserving raw program styles and transcript bytes', () => { + const buffer = new OutputBuffer(); + buffer.beginCommand('echo colored', ['\u001b[38;2;166;124;243mecho colored\u001b[0m']); + buffer.write('\u001b[31mRAW-RED\u001b[0m\n'); + buffer.complete(0); + buffer.addHistoryLine('\u001b[38;2;116;181;154mCompleted\u001b[0m'); + const saved = buffer.transcript(); + const restored = new OutputBuffer(); + restored.restoreTranscript(saved); + const previous = {NO_COLOR: process.env.NO_COLOR, NMSH_COLOR: process.env.NMSH_COLOR}; + process.env.NO_COLOR = '1'; + delete process.env.NMSH_COLOR; + try { + const rows = restored.wrapped(12); + const authored = rows.filter(row => row.lineIndex !== undefined && restored.lineTypes.has(row.lineIndex)); + assert.ok(authored.some(row => row.plain.includes('echo colored'))); + assert.ok(authored.some(row => row.plain.includes('Completed'))); + for (const row of authored) assert.doesNotMatch(row.ansi, /\u001b\[(?:38|48);/u); + const raw = rows.find(row => row.plain === 'RAW-RED'); + assert.ok(raw); + assert.match(raw.ansi, /\u001b\[31m/u, 'program SGR remains raw'); + assert.doesNotMatch(restored.presenter.stickyHeaderRow(restored.view(), 0, 40)!, /\u001b\[38;/u); + assert.deepEqual(restored.transcript(), saved, 'presentation never rewrites stored styles'); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } +}); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 8ce946e6..6daa58ab 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -29,9 +29,10 @@ function answerQueries(frontend: Frontend): () => void { } const READ = { - fish: (secret: boolean) => `read ${secret ? '-s ' : ''}-P 'Name: ' x; echo got=$x`, - zsh: (secret: boolean) => `read ${secret ? '-s ' : ''}'x?Name: '; echo got=$x`, - bash: (secret: boolean) => `read ${secret ? '-s ' : ''}-p 'Name: ' x; echo got=$x`, + // The prompt is built by the shell, so "Name: " is not in the command text that NMSh itself echoes and shows while it runs. + fish: (secret: boolean, tag: string) => `read ${secret ? '-s ' : ''}-P (printf 'Name-%s: ' ${tag}) x; echo got=$x`, + zsh: (secret: boolean, tag: string) => `printf 'Name-%s: ' ${tag}; read ${secret ? '-s ' : ''}x; echo got=$x`, + bash: (secret: boolean, tag: string) => `printf 'Name-%s: ' ${tag}; read ${secret ? '-s ' : ''}x; echo got=$x`, }; for (const shell of ['fish', 'zsh', 'bash'] as const) { @@ -48,10 +49,14 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { for (let run = 0; run < RUNS; run += 1) { const tag = `${mode}${run}`; const mark = frontend.mark; - frontend.pty.write(`${READ[shell](run % 2 === 1)}\r`); - await until(() => frontend.output.slice(mark).includes('Name: '), 15_000, `${shell} draws the read prompt`); - // zsh and Bash reads are composer-owned: let NMSh see the question before answering it. - if (shell !== 'fish') await frontend.waitFor(/Waiting for input|Name: /u, mark); + frontend.pty.write(`${READ[shell](run % 2 === 1, tag)}\r`); + // zsh and Bash print their prompt from the command text; wait for it past the echo and NMSh's own "Running" line. + // Each prompt is unique and assembled at runtime: historical redraws and the echoed command cannot satisfy it. + await frontend.waitFor(new RegExp(`Name-${tag}: `, 'u'), mark); + // zsh and Bash reads are composer-owned: let NMSh see the question before answering it. While a command runs + // with no visible question, Enter queues the next command (that is the queue); an answer is only an answer + // once the question is open. + if (shell !== 'fish') await frontend.waitFor(new RegExp(`[Ww]aiting for input[^\\n]*[\\s\\S]*Name-${tag}:`, 'u'), mark); frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); frontend.pty.write(`echo NEXT-${tag}\r`); diff --git a/tests/understanding.test.ts b/tests/understanding.test.ts index cfae3c7c..caefb6e5 100644 --- a/tests/understanding.test.ts +++ b/tests/understanding.test.ts @@ -40,7 +40,7 @@ class FakeRuntime implements ModelRuntime { } async function serviceFixture(options: Partial[0]> = {}) { - const dir = mkdtempSync(join('/tmp', 'nm-')); + const dir = mkdtempSync(join(tmpdir(), 'nm-')); let exited = 0; const service = new ModelService({runtimeFor: model => new FakeRuntime(model.label), idleMs: 150, graceMs: 100, onExit: () => { exited += 1; }, ...options}); const server = (await serveModelService(modelSocketPath(dir), service))!;