diff --git a/AGENTS.md b/AGENTS.md index d58bc27c..01ae1874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,9 @@ Released (0.16.0): a real ShellAdapter with zsh, Fish and Bash 4.4+ backends ([d ## Current surfaces (released in v0.18.0) Theme Studio (`/theme`), Theme Bridge (`/theme-bridge`), `/providers`, `/tools` with filesystem-detected shell frameworks, `/configure`, `/tmux` Config Studio, `/integrations`, `/dotfiles`, the Oh My Posh provider, and Keep Awake (`/caffeinate`, `/awake`, `/zoomies`, with composer-edge, Status Strip, idle-reminder and screensaver presentation). Context Engine fact metadata, native module routing and Context Rail (v0.17.0), plus capability scheduling, the first-party module catalog, Context Packs (`nmsh packs`), Claude Code agent context (`nmsh agent-status`), `/modules` and Status Strip 2.0 (`/strip`) in v0.18.0. See CHANGELOG.md → 0.18.0. +## Release candidate surfaces (v0.19.0, not published) +Input awareness (waiting-for-input state, hidden and single-key input routed to the program), the command queue (`/queue`, Ctrl+Q, Ctrl+S, queue conditions), `/copy` selections, `/copy ui` and Smart Folding, Copy as Report, `/compare`, output references and F8/Shift+F8 failure navigation, paste review and `/ps`, `/clips`, `/pins` and recipes, Expressive/Classic activity wording, module explain diagnostics, Ruby/PHP modules and Starship theme import. Physical terminal QA is outstanding ([checklist](docs/testing/v019-physical-qa.md)). + ## Testing / verification During implementation, run focused affected tests. Use `npm run verify:fast` for ordinary iteration (build, an explicit core test subset, and diff checks); it is diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d436fb..2d2409e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,94 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.19.0] - Unreleased (release candidate) + +**Terminal workflow.** One command's result becomes useful for the next without NMSh taking execution away from the real shell. NMSh notices when a running command is waiting for you, lets you prepare the next commands while one runs, copies exactly what you choose, turns output into reviewed reports and comparisons, and keeps the commands you reuse. Nothing derived from output, the clipboard, a pin or a recipe runs without an explicit action, and every queued command still runs in your one persistent shell. + +Verification: automated CI (unit and real-PTY suites on macOS and Ubuntu, Node 22 and 26, a Fedora portability subset and the timing smoke) on the integrated head, plus the per-feature canonical, Linux/Fish 3.7 and report/compare/clipboard security gates recorded in [the frontend freeze handoff](https://github.com/raiseCatError/notMyShell/blob/ca26b9be62f2d7eb7f083e05bd6a2fcff9334811/docs/development/workflow-frontend-freeze.md). **Physical-terminal QA: not yet performed** — this section stays a release candidate until it is ([v0.19 physical QA checklist](docs/testing/v019-physical-qa.md)). + +### Added + +#### Input awareness ([#348](https://github.com/raiseCatError/notMyShell/pull/348)) +- **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`. +- Fish's own `read` and other builtin readers are recognized from authenticated terminal ownership, and input typed during startup, before the shell is ready, or while a hidden read is open goes only to the program that currently owns the terminal. + +#### Command queue ([#357](https://github.com/raiseCatError/notMyShell/pull/357)) +- Commands typed while another runs are **queued in the same shell** and run after it, one at a time, only when the shell reports it is ready, keeping the directory, variables and functions. Multiline entries keep their meaning (Fish `begin`/`end`, zsh and Bash brace groups). `/queue` shows, edits, reorders, pauses, resumes or clears them; queued command text is drawn, never interpreted. +- When the running program is waiting for an answer, Enter answers it and **Ctrl+Q** composes a queue entry instead. While a program reads keys directly, Ctrl+Q takes the next command for the queue and **Ctrl+S** sends the composer's text to the program. +- A failure or interrupt **pauses** the rest of the queue. Detach and reattach never submit an entry twice; switching shells pauses the queue, and a shell that exits drops its entries instead of replaying them in another shell. Silence is never taken as completion. + +#### Copy and Smart Folding ([#358](https://github.com/raiseCatError/notMyShell/pull/358), [#350](https://github.com/raiseCatError/notMyShell/issues/350)) +- `/copy N`, `/copy -N` (the latest N), ranges `A-B` and lists `1,3,5`; `--status` adds the completion row; `/cp` is the same command in every form. +- `/copy ui` opens an interactive picker; `/copy settings` chooses Quick Copy or the picker by default and whether copies include status. +- A sticky Copy control keeps block actions reachable while a long block's header is scrolled away. +- **Smart Folding** keeps long diagnostics readable; the full output stays stored, and copies include folded lines. + +#### Copy as Report ([#360](https://github.com/raiseCatError/notMyShell/pull/360), [#351](https://github.com/raiseCatError/notMyShell/issues/351)) +- One or several command blocks as a Markdown or plain-text report with command, output, directory and exit status, always shown for review first. Enter copies exactly the reviewed text; cancel leaves the clipboard unchanged. +- Likely secrets and private paths are **redacted** in the review before any Markdown is added; edited text is rescanned, changed lines are marked, and an unredacted choice is labelled. Redaction is best-effort (see Known limitations). + +#### Compare output ([#361](https://github.com/raiseCatError/notMyShell/pull/361), [#352](https://github.com/raiseCatError/notMyShell/issues/352)) +- `/compare` (the latest output with its previous run), `/compare N`, `/compare N M` (numbers as for `/copy`), or Actions → Compare output…: a line diff with counts, hunks and collapsed unchanged regions, side by side from 140 columns and unified below, with labelled whitespace normalization. Output lost while detached is marked incomplete. +- In the comparison, `r` opens the diff as a reviewed, redacted report. `c` copies the sanitized plain diff **directly, without redaction** (it shows a caution when the detector flags a likely secret). + +#### References, paste review, clips, pins and recipes ([#368](https://github.com/raiseCatError/notMyShell/pull/368), [#353](https://github.com/raiseCatError/notMyShell/issues/353)–[#355](https://github.com/raiseCatError/notMyShell/issues/355)) +- Files, links, commits and device names in a block's output can be opened, copied or staged from its Actions; nothing runs by itself. +- **F8 / Shift+F8** walk failed commands (by exit status, not text matching) and keep your draft; the palette offers Previous/Next failed command too. +- **Paste review:** a pasted zsh, Bash or Fish sequence can be split into commands and parse-checked without running anything; confirming queues the reviewed list. `/ps` reads the clipboard only when you run it. +- **Queue conditions:** an entry can wait for its own approval (withdrawn if the entry is edited) or be marked `always` to run even after a plain failure, never after an interrupt. +- `/clips`: text deliberately kept in memory for this window, reviewed before insertion and gone when the window closes. Optional clipboard-change hints on macOS (`/clips suggestions on`, off by default) read only the pasteboard's change counter, never its contents. +- `/pins`: pinned commands with their directory (palette → Pin selected history command) and recipes with quoted placeholders, staged or reviewed and queued, never run automatically. + +#### Activity wording ([#367](https://github.com/raiseCatError/notMyShell/pull/367), [#362](https://github.com/raiseCatError/notMyShell/issues/362)) +- **Expressive** (the new default) gives each running command one stable phrase from a curated 663-pair vocabulary (`✦ Cooking… · 18s`, then `✔ Cooked for 18.0s`), kept across detach and reattach; **Classic** keeps plain Running/Completed wording (Settings → Live activity → Activity wording, and Setup). Failures, interrupts, agents and queued runs keep factual wording, and copies, reports and restored records always store the factual form. + +#### Modules and themes +- Module details explain where a fact came from, its freshness, cache retention and measured collection time, and say when a fact is uncollected or unknown rather than implying it; `/doctor` reports Context Engine health ([#365](https://github.com/raiseCatError/notMyShell/issues/365)). +- Ruby and PHP version modules read the project's own files and the install location, never running an interpreter. The Gemfile `ruby` declaration is parsed in linear time. +- `/theme` imports **Starship** configurations as Native themes from their static colors only; format strings and custom commands are never evaluated, and the preview lists what was not carried over. + +### 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. +- Startup and termination: a quit or termination signal at any point during startup cleans up the frontend; a prompt that arrives after the window has closed can no longer restart context collection or queue work. +- Reattaching to a fullscreen program gives it the terminal and its size at once, before the session journal is written. +- Native completion accepts readiness that arrives in fragments (LF or CRLF) within its existing bounds. +- The command directory recorded with a command is the shell's authenticated directory at submission, even when asynchronous context collection still describes the previous prompt. +- Restored command and status rows follow the attaching window's NO_COLOR setting. +- `/sessions` refreshes live session facts without losing the selection, query, draft or focus. +- A clipboard write is reported as complete only when the whole payload was delivered and the clipboard tool exited successfully. + +### Security +- Every session socket request (listings, notices, dismiss, rename, kill), not only create and attach, verifies the private runtime directory's ownership before connecting. +- A client cancelled before it started discards output withheld for it instead of exposing it. +- Report and comparison text is redacted before Markdown is added; hostile command, status and directory text (line breaks, backticks) stays enclosed, with bounded delimiter sizing for backtick-rich data. Report review stays readable at extreme terminal widths. +- Comparison chooser headings and pasted-text review show control, bidi and zero-width characters visibly instead of sending them to the terminal. +- The pins panel shows control and bidi characters in labels and prompts instead of writing them to the terminal. + +### Compatibility +- Node.js 22 or newer, as before. CI covers Node 22 and 26 on Ubuntu, Node 26 on macOS, and a Linux portability subset on Fedora. +- zsh, Bash 4.4+ and Fish. Fish 3.7 on Linux is covered by the frontend freeze verification; automated Bash coverage is 5.x, so the 4.4 minimum was not exercised directly. +- The command queue and queue conditions need a session service from this release: a service started by an older NMSh reports the queue as unavailable until it restarts. +- Clipboard-change hints need macOS; Linux has no supported metadata-only clipboard counter. + +### Upgrade notes +- Activity wording becomes **Expressive** by default, including for existing settings. Choose Classic in Settings → Live activity → Activity wording to keep the previous wording. +- The command queue is on by default (Settings can turn it off). Existing session services keep running; restart them (or start a new session) to use the queue. +- Settings, themes, modules and history carry over unchanged. Clipboard suggestions stay off until turned on. + +### Known limitations +- **Physical-terminal QA has not been performed.** Every workflow feature is covered by automated tests, including real-PTY suites; none has had a hands-on pass in Ghostty, Terminal.app or a physical Linux terminal. This release is published only after that pass. +- Redaction is pattern-based and can miss custom, encoded or unknown secret forms and private paths; review every report. Comparison `c` and plain `/copy` copy without redaction by design. +- A clipboard tool that fails after it started writing may already have changed the clipboard; NMSh cannot roll it back. +- Fish can drop type-ahead entered while its own `read` is still running, as in any terminal ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). +- Project and branch facts can lag a fast directory change while collection is pending; a failed `/sessions` poll keeps the last validated view. +- A concurrent resize and quit key in native `less` can lose the quit key; synchronized automated tests do not prove arbitrary physical resize timing. +- A forced SIGKILL of the frontend can bypass helper cleanup; normal stop, SIGTERM and SIGHUP clean up owned helpers. +- Exact last-frame module width-compaction explanations and dedicated external source previews are not included ([#365](https://github.com/raiseCatError/notMyShell/issues/365)). +- Ruby and PHP are the only new runtimes here; the full native runtime set (.NET, Swift, Zig, Deno, Elixir, Dart, Flutter, build systems) is planned for v0.20. + + ## [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/README.md b/README.md index d90f76f0..3740d961 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ **notMyShell (NMSh)** runs your real zsh, Bash or Fish in a persistent session and gives it a better front end: a composer that stays put, semantic highlighting, a readable transcript, live command feedback, sessions that survive closing the window, and themes that can reach the tools you use. -Latest published stable release: [v0.18.0 — Context Engine, Managed Agents & Workspaces](https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0). The `master` branch includes this released work. See the [changelog](CHANGELOG.md#0180---2026-10-13) for its scope and known limitations; physical-terminal QA for this release is still pending. +Latest published stable release: [v0.18.0 — Context Engine, Managed Agents & Workspaces](https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0). The `master` branch includes this released work. See the [changelog](CHANGELOG.md#0180---2026-10-13) for its scope and known limitations; physical-terminal QA for this release is still pending. v0.19.0 (terminal workflow: input awareness, command queue, reviewed reports and comparisons) is a release candidate on `release/v0.19.0` and is not published until its physical-terminal QA passes. ## See notMyShell in motion diff --git a/ROADMAP.md b/ROADMAP.md index 249a49ed..d9b154e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,6 +5,7 @@ | | | |---|---| | **Current release** | [v0.18.0 — Context Engine, Managed Agents & Workspaces](https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0) — released; physical QA pending ([checklist](docs/testing/v018-physical-qa.md)) | +| **Release candidate** | v0.19.0 — Terminal workflow (input awareness, command queue, copy and Smart Folding, Copy as Report, Compare output, references, paste review, pins and recipes, activity wording) on `release/v0.19.0` — **not published**; publication waits for physical terminal QA ([checklist](docs/testing/v019-physical-qa.md)) | | **macOS distribution** | Homebrew: `brew install raiseCatError/tap/nmsh`; source installation remains available | | **Current engineering focus** | [#305 — Native modules / Context Engine](https://github.com/raiseCatError/notMyShell/issues/305) and [#304 — Theme Bridge / semantic terminal](https://github.com/raiseCatError/notMyShell/issues/304); foundation released in v0.17.0, the module platform and further Theme Bridge work released in v0.18.0 | | **Included in v0.18.0** | Context Engine platform (capability scheduling, first-party catalog, Context Packs, Claude Code agent context), `/modules`, Status Strip 2.0 and `/strip`, managed Claude Code targets and `/mods` (#329), `/worktrees` (first slice of #337), read-only `/github` (first slice of #338), terminal title ownership, tmux/passthrough/cursor fixes; merged through [PR #345](https://github.com/raiseCatError/notMyShell/pull/345) (Status Strip 2.0 and the Ghostty physical-QA fixes reached master earlier through [PR #328](https://github.com/raiseCatError/notMyShell/pull/328)), see CHANGELOG → 0.18.0 | @@ -156,6 +157,10 @@ The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumul | [#15](https://github.com/raiseCatError/notMyShell/issues/15) | Moved to Future / Backlog; NMSh already degrades by capability in unknown embedded hosts | | [#18](https://github.com/raiseCatError/notMyShell/issues/18) | Code and CI in v0.17; physical Linux/WSL pass moved to a follow-up | +## Release candidate — v0.19 Terminal workflow + +Integrated on `release/v0.19.0` from `v0.18.0` with merge commits, preserving history: the frozen workflow stack ([#348](https://github.com/raiseCatError/notMyShell/pull/348) → [#357](https://github.com/raiseCatError/notMyShell/pull/357) → [#358](https://github.com/raiseCatError/notMyShell/pull/358) → [#360](https://github.com/raiseCatError/notMyShell/pull/360) → [#361](https://github.com/raiseCatError/notMyShell/pull/361) → [#368](https://github.com/raiseCatError/notMyShell/pull/368)) and its independent sibling [#367](https://github.com/raiseCatError/notMyShell/pull/367), tracked by [#359](https://github.com/raiseCatError/notMyShell/issues/359). Automated gates are recorded in the release PR; physical QA in Ghostty, Terminal.app and a physical Linux/Fish terminal has not been performed, so the release is not published. Deferred to v0.20 or later: exact last-frame module compaction explanations and dedicated external source previews ([#365](https://github.com/raiseCatError/notMyShell/issues/365)), and the full native runtime set ([#364](https://github.com/raiseCatError/notMyShell/issues/364)). + ## Released — v0.18 Context Engine, Managed Agents & Workspaces Released on 2026-10-13 through [PR #345](https://github.com/raiseCatError/notMyShell/pull/345). Physical QA was not performed before publication and remains a follow-up ([checklist](docs/testing/v018-physical-qa.md)). Brings together the module ecosystem previously planned for v0.19.0 ([#305](https://github.com/raiseCatError/notMyShell/issues/305), [#316](https://github.com/raiseCatError/notMyShell/issues/316)–[#321](https://github.com/raiseCatError/notMyShell/issues/321)) together with managed Claude Code targets and `/mods` ([PR #329](https://github.com/raiseCatError/notMyShell/pull/329)), the first slice of the worktree manager ([#337](https://github.com/raiseCatError/notMyShell/issues/337), [PR #342](https://github.com/raiseCatError/notMyShell/pull/342)), the read-only GitHub workspace (first slice of [#338](https://github.com/raiseCatError/notMyShell/issues/338), [PR #343](https://github.com/raiseCatError/notMyShell/pull/343)) and the Theme Bridge work planned for v0.18.0 ([#304](https://github.com/raiseCatError/notMyShell/issues/304)): terminal title ownership (OSC 0/2), host-marker lifecycle refinements, Open in pager, truthful delta status, 16/8-color correctness and Bash/Fish typo correction. Still deferred from #304: delta diff styles (git config), terminal emulator and editor base-theme takeover; physical host QA. See CHANGELOG → 0.18.0. diff --git a/docs/architecture/context-modules.md b/docs/architecture/context-modules.md index 45ff167a..c4431988 100644 --- a/docs/architecture/context-modules.md +++ b/docs/architecture/context-modules.md @@ -8,8 +8,8 @@ without taking over shell execution or running anything the workspace chose. > **Release status.** v0.17.0 shipped the foundation: typed fact metadata, > native module routing and the Context Rail. Everything else described here — > capability scheduling, the first-party module catalog, declarative Context -> Packs, Status Strip routing and Status Strip 2.0, `/modules` and Claude Code agent context — shipped in -> v0.18.0. +> Packs, Status Strip routing and Status Strip 2.0, `/modules` and Claude Code agent context — is in +> the v0.18 development stack, pending acceptance and release. ## From capability to presentation @@ -169,9 +169,28 @@ fullscreen programs hide it, and it is never written to the transcript. - **Packs** — bundled and installed Context Packs with their verified state; installed packs can be enabled, disabled or removed (with confirmation). +Short detail panels keep the module title visible and scroll through existing rows +with Up/Down or PageUp/PageDown. Resize clamps that temporary view; scrolling +does not edit settings or demand data. + A module's details show what it reads, whether its value may be kept with commands in history, and each fact's current state ("fresh · 3s ago", "last -known, refreshing", "unavailable (timed out after 1500 ms)", "not checked"). +known, refresh waits for demand", "unavailable (timeout)", "not checked"). Trusted source IDs, +scope, freshness TTL, event invalidation and demand-driven refresh policy come +from the capability definition. Failure text excludes raw exception paths and +values. A failed refresh can retain a previous value; the view distinguishes +that from a failure with no cached value, without claiming that new data arrived. + +"Took" measures the last collection's wall time, starting when the scheduler +dispatches it; it excludes time waiting in the queue. An unmeasured collection +has no invented zero. Opening details reads existing snapshots and declarations, +and does not demand collectors. `/doctor` summarizes bounded engine counters. +Routing, provider availability, conditions and priority explain presentation +policy. Enabled means the saved setting, not proof that the module is on screen. +The live condition check is shared with Native rendering; it does not infer +visibility from collection success. Width fitting can compact or omit lower +priority modules; the detail view does not claim to record the last frame's +exact compaction decisions. `/shell` edits the current-shell module's visibility and side. ## Context Packs 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/captures/sticky-1-beginning.png b/docs/captures/sticky-1-beginning.png new file mode 100644 index 00000000..0c7a12c6 Binary files /dev/null and b/docs/captures/sticky-1-beginning.png differ diff --git a/docs/captures/sticky-2-middle.png b/docs/captures/sticky-2-middle.png new file mode 100644 index 00000000..ed247979 Binary files /dev/null and b/docs/captures/sticky-2-middle.png differ diff --git a/docs/captures/sticky-3-end.png b/docs/captures/sticky-3-end.png new file mode 100644 index 00000000..7274d8dc Binary files /dev/null and b/docs/captures/sticky-3-end.png differ diff --git a/docs/captures/sticky-4-transition.png b/docs/captures/sticky-4-transition.png new file mode 100644 index 00000000..7274d8dc Binary files /dev/null and b/docs/captures/sticky-4-transition.png differ diff --git a/docs/captures/sticky-5-next-block.png b/docs/captures/sticky-5-next-block.png new file mode 100644 index 00000000..1e5b994e Binary files /dev/null and b/docs/captures/sticky-5-next-block.png differ diff --git a/docs/captures/sticky-controls.gif b/docs/captures/sticky-controls.gif new file mode 100644 index 00000000..783321c5 Binary files /dev/null and b/docs/captures/sticky-controls.gif differ diff --git a/docs/design/activity-vocabulary.md b/docs/design/activity-vocabulary.md new file mode 100644 index 00000000..0787ddb8 --- /dev/null +++ b/docs/design/activity-vocabulary.md @@ -0,0 +1,24 @@ +# Expressive activity wording + +Tracked by [#362](https://github.com/raiseCatError/notMyShell/issues/362) and +[#367](https://github.com/raiseCatError/notMyShell/pull/367). + +Expressive is the default. Classic remains available in Activity settings and Setup; +both persist and transfer with the existing `idle` settings category. + +The dictionary contains paired active/completed phrases. Quality checks reject technical +outcome claims, NMSh feature names, repeated phrases, incorrect conjugation and excessive +width. The phrases describe decoration; shell exit codes, interruption and measured +timing decide the actual lifecycle. Waiting-input and failure displays remain factual. +Stored lifecycle text, reports and clipboard copies use factual wording. + +A command selects its pair before rendering. Selection uses its text and start time, +avoiding the preceding pair. The live journal stores dictionary indices for the running +and preceding choices, so redraws and reattachment with that journal retain the choice. +These indices have no effect on execution or status. Invalid indices are ignored. An old +or missing journal falls back to deterministic selection; it cannot reconstruct a +previously displaced choice. Archived completed transcripts display factual wording. + +Automated coverage includes forced adjacent-choice collisions, settings persistence and +transfer, live detach/reattach, factual clipboard bytes, failures, interruption, dictionary +quality and narrow widths. Physical terminal appearance still requires human QA. diff --git a/docs/design/claude-remote-control-compatibility.md b/docs/design/claude-remote-control-compatibility.md new file mode 100644 index 00000000..83a90ebe --- /dev/null +++ b/docs/design/claude-remote-control-compatibility.md @@ -0,0 +1,62 @@ +# Claude Remote Control: compatibility findings + +Issue [#340](https://github.com/raiseCatError/notMyShell/issues/340) (research). Primary source: Anthropic's Claude Code +documentation page *Continue local sessions from any device with Remote Control* +(`https://code.claude.com/docs/en/remote-control`), read on 2026-10-10. Findings are what the page states or does not +state; **nothing here was probed against a live account**, which needs an eligible claude.ai login and a human to +approve it. Re-verify before any implementation: the feature is new and its flags change. + +## What the documentation says + +| Topic | Documented fact | +| --- | --- | +| Where it runs | The session keeps running on the person's machine; the remote surface (claude.ai/code, mobile apps) drives it | +| Invocation modes | `claude remote-control` (server mode, creates sessions on demand), `claude --remote-control` / `--rc` (a normal interactive session that is also reachable remotely), `/remote-control` or `/rc` inside an existing session; the Desktop app and VS Code extension use `/remote-control` | +| Eligibility | Pro, Max, Team and Enterprise; **API keys are not supported**; Team and Enterprise need an Owner to enable the toggle in admin settings; a claude.ai login is required | +| One session per process | Each interactive process registers one remote session; several instances give several sessions. Server mode runs several sessions in one process (`--capacity`, default 32; `--spawn same-dir` or `worktree`) | +| Flags | Global `claude` flags placed before `remote-control` (or added by a wrapper) are **not** carried into the sessions the server creates; `--permission-mode` and sandboxing are set explicitly | +| Trust | In an untrusted directory `claude remote-control` asks `Trust ? [y/N]` before starting | +| Terminal indicator | An interactive session shows an `/rc active` indicator (hidden in terminals that are too narrow) and a failure reason when the connection drops | +| Security model | Outbound HTTPS only, no inbound ports; traffic through the Anthropic API over TLS; optional Trusted Devices enrolment, enforced per organization | +| Resume | `claude remote-control --continue` / `--session-id` for about four hours after the server stops; interactive sessions resume with `claude --continue` / `--resume` | +| Not documented | Any support for `--print` / stream-json (headless or SDK-style) sessions, for a third-party UI driving the session, or for exposing the remote channel to such a UI | + +## What this means for NMSh + +NMSh has two relationships with Claude Code: + +1. **Raw (native TUI)**: Claude runs in NMSh's shell as any program. `claude --remote-control` and `/remote-control` are + Claude's own features, work exactly as documented, and need nothing from NMSh. NMSh only has to keep passthrough + correct (it does). +2. **Managed**: NMSh drives Claude through `--print --input-format stream-json --output-format stream-json` (see + `src/agents/sessions/claudeAdapter.ts`). **The documentation does not describe Remote Control for that mode.** The + modes it does describe are interactive sessions and its own server. + +| Question | Finding | +| --- | --- | +| Remote Control for a Raw session | **Supported by Claude itself**; NMSh must not interfere or claim it | +| Remote Control for a Managed (stream-json) session | **Unknown / undocumented.** Do not advertise it | +| NMSh starting `claude remote-control` as a managed target | **Unsupported**: server mode creates its own sessions with its own permission handling; NMSh could not own the approval path or the transcript | +| Flag inheritance for per-profile launch policy | **Documented limitation**: flags are not inherited by server-created sessions, so a per-profile `--permission-mode` would have to be passed to the server itself | +| Account/device boundaries | Remote Control needs the person's claude.ai login (no API keys) and may be gated by an organization toggle and Trusted Devices; NMSh must never read, store or forward those credentials, and must show the organization's refusal as Claude reports it | +| Privacy | A remote session sends the conversation through Anthropic's API to the person's other devices; that is a data-sharing choice the person makes inside Claude, not an NMSh feature | + +## Go / no-go + +**No-go for a Never / Ask / Always Remote Control setting in NMSh at this time.** The only supported combination (Raw + +Claude's own commands) needs no NMSh feature, and the Managed combination is undocumented. A setting would imply support +NMSh cannot demonstrate and would blur who owns approvals. + +Revisit when: (a) Anthropic documents Remote Control for headless or SDK-driven sessions, or (b) a human-run probe +shows a supported way to attach the remote channel to a stream-json session. + +## Probe plan (not run; needs a person with an eligible login) + +1. With an eligible account on a test machine, run Claude in Raw mode under NMSh, enable `/remote-control`, and confirm + the `/rc active` indicator survives NMSh's passthrough, resize and detach/reattach. Record the Claude Code version. +2. Start a Managed target and check whether any documented flag attaches Remote Control to a stream-json session. Record + the exact help text and error output; treat silence as "unsupported". +3. Confirm that environment variables and arguments NMSh sets for a managed target do not change the login Claude uses. +4. Record each result with the Claude Code version and date in this file. + +Any implementation is a separate reviewed scope with physical validation, as the issue requires. diff --git a/docs/design/external-tool-decisions.md b/docs/design/external-tool-decisions.md new file mode 100644 index 00000000..a0388863 --- /dev/null +++ b/docs/design/external-tool-decisions.md @@ -0,0 +1,58 @@ +# External tool integrations: decisions + +Issue [#366](https://github.com/raiseCatError/notMyShell/issues/366), under [#359](https://github.com/raiseCatError/notMyShell/issues/359). +Every candidate was checked against what NMSh already does (the curated `/tools` catalog in `src/tools/catalog.ts`, the +provider registry behind `/providers`, `/dirs`, `/open`, Theme Bridge, the clipboard writer and reader, the same-shell +command queue, native Compare). A decision is one of **adopt** (already supported or built), **defer** (a plausible +slice, not now) or **reject**. + +## Principles that decide every row + +- Optional and absent-safe: NMSh works fully without any of these. Nothing is installed, configured or enabled without + a deliberate action and a preview. +- The native same-shell **queue** stays the way to prepare commands for the person's own shell. It keeps the shell's + state (directory, variables, functions) and its input ownership. A detached job runner is a different tool for a + different job, never a replacement. +- Native **Compare** stays the comparison of two stored command outputs. An external diff renderer adds colour, not + meaning. +- No tool receives clipboard or output contents unless the person invoked that tool on that content. +- No second picker, file manager or form framework inside NMSh. + +## Decisions + +| Tool | Already in NMSh | Decision | Why | +| --- | --- | --- | --- | +| **[Atuin](https://github.com/atuinsh/atuin/blob/main/docs/docs/configuration/config.md)** | Curated in `/tools`; usable as the explicit history provider (`/providers`); NMSh reads local commands and metadata for suggestions | **Adopt as is** | NMSh keeps shell history authority and uses Atuin as an optional source. Project/session-aware ranking is already NMSh's own (directory affinity, frecency); do not add a second history store | +| **[fzf](https://github.com/junegunn/fzf#usage)** | Curated in `/tools`; selection provider; shell keybindings remain external configuration | **Adopt as is** | The existing picker provider covers selection. No new picker subsystem | +| **[Television](https://github.com/alexpasmantier/television#readme)** | Curated in `/tools`; picker provider choice | **Adopt as is** | Same provider slot as fzf. Preview/select actions for output references (#353) already use NMSh's own palette, which is enough | +| **[zoxide](https://github.com/ajeetdsouza/zoxide#readme)** | Curated in `/tools`; directory provider behind `/dirs`, queried through a private database copy | **Adopt as is** | zoxide can write while querying. NMSh protects the original database and falls back to Native when it cannot obtain a bounded snapshot | +| **[bat](https://github.com/sharkdp/bat#adding-new-themes)** | Curated in `/tools`; Ask knows it; Theme Bridge offers an owned theme and explicitly reviewed cache build | **Adopt as is** | No dedicated source-preview launcher is implemented. Users can invoke bat in their shell; Compare and reports use native plain text | +| **[delta](https://github.com/dandavison/delta#readme)** | Curated in `/tools`; Theme Bridge offers optional Git diff colours and bat-backed syntax theme support | **Adopt as is; do not use for Compare** | Native Compare uses a bounded edit distance, reviewed reports and a sanitized plain diff copy. Sending output to an external renderer creates another presentation/copy boundary without addressing the accepted workflow needs | +| **[Pueue](https://github.com/Nukesor/pueue#readme)** | No Pueue adapter | **Defer** | Its separate daemon provides durable jobs, but it owns independent execution and persisted output. A reviewed handoff would need output capture and transcript provenance. The same-shell queue stays native. Workspace background-task work (#383/#384) belongs to the other track; adding a second adapter before that handoff is premature | +| **[Yazi](https://yazi-rs.github.io/docs/tips/)** | No chooser adapter | **Defer** | Returning paths through `--chooser-file` is plausible, but needs a bounded private result file, validated shell quoting, passthrough handoff and explicit insertion. No accepted implementation slice supersedes the existing deferral | +| **[Gum](https://github.com/charmbracelet/gum#readme)** | No Gum-specific adapter | **Reject** | Gum provides script prompts. NMSh retains ordinary passthrough and input ownership rather than adding a forms framework. Gum-specific physical interoperability has not been validated | + +## What stays native, and why + +- **Command queue** (same shell, once-only, pauses on failure or interrupt, optional approval): needed because the next + command depends on the state the previous one left in the person's shell. +- **Compare** and **Copy as Report**: exact, reviewed, redactable text; the properties that make them safe to share would + be lost behind an external renderer. +- **Output references and failure navigation**: use the existing editor/browser bridges instead of a picker tool. + +## Follow-ups worth a ticket each + +1. Yazi chooser handoff (isolated, high value). +2. Pueue *send to background* with status (needs the output-capture design first). + +## Implementation evidence and limits + +Re-audited 2026-10-11. Existing optional-provider behavior is implemented in +[`historyProviders.ts`](../../src/shell/historyProviders.ts), +[`HistoryService.ts`](../../src/shell/HistoryService.ts), +[`Picker.ts`](../../src/pickers/Picker.ts), +[`DirectoryService.ts`](../../src/shell/DirectoryService.ts) and +[`Theme Bridge runtime`](../../src/themeBridge/runtime.ts). +Provider and bridge tests cover bounded reads, absent dependencies, native fallbacks and owned configuration; +live queue, alternate-screen and report/diff acceptance covers the retained native paths. No new CLI adapter is +claimed by this audit. Host-specific picker visuals and physical interoperability remain human QA. 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/design/native-module-coverage.md b/docs/design/native-module-coverage.md new file mode 100644 index 00000000..484bf832 --- /dev/null +++ b/docs/design/native-module-coverage.md @@ -0,0 +1,57 @@ +# Native module coverage: Starship and Oh My Posh + +Issue [#364](https://github.com/raiseCatError/notMyShell/issues/364), under [#305](https://github.com/raiseCatError/notMyShell/issues/305). +The goal is useful coverage, not a module count: NMSh had **10 built-in modules and 25 bundled Context Pack modules** (27 now that Ruby and PHP are added) +(`nmsh.agents`, `cloud`, `environment`, `infrastructure`, `project`, `system`, `vcs`). Anything below that is missing is +either already covered by a generic mechanism, available through the external prompt providers NMSh already supports +(Starship, Oh My Posh, Powerlevel10k), deliberately skipped, or a candidate. + +Catalogs read on 2026-10-10: Starship's configuration reference (`starship.rs/config`) and Oh My Posh's +`website/docs/segments` tree (agents, cli, cloud, health, languages, music, scm, system, web). Nothing here claims a +module behaves identically to its upstream namesake. + +## Rules a native module must meet + +- Facts come from declarative files or the executable's install location, through the Context Engine's capabilities + (scoped cache, timeout, cancellation, provenance, privacy). **No module runs repository-controlled code** or a tool + from the workspace to learn a version; this is why several Starship modules that shell out are not ported as is. +- Demand-driven: collected only while a surface that shows it is visible. +- One module per question a person asks about a directory, not one per file extension. + +## Matrix + +| Upstream area | Examples (Starship / Oh My Posh) | NMSh today | Decision | +| --- | --- | --- | --- | +| Directory, shell, OS, user, time, status, duration, jobs, battery, memory | `directory`, `os`, `username`, `time`, `status`, `cmd_duration`, `jobs`, `battery`, `memory_usage` / `path`, `os`, `session`, `time`, `executiontime`, `battery`, `sysinfo` | Native: built-ins `cwd`, `shell`, `exitStatus`; pack `nmsh.system` (`os`, `user`, `jobs`, `duration`, `time`, `battery`, `memory`) | Covered | +| Git | `git_branch`, `git_status`, `git_commit`, `git_state`, `git_metrics` / `git` | Native: `gitBranch`, `gitStatus`; pack `nmsh.vcs` (`stash`, `upstream`) | Covered; commit/state/metrics are candidates only if requested | +| Other VCS | `hg_branch`, `fossil_*`, `pijul_channel`, `vcsh` / `mercurial`, `fossil`, `jujutsu`, `sapling`, `svn`, `plastic`, `dvc` | None | **Candidate: Jujutsu and Mercurial** (declarative `.jj` / `.hg` metadata); the rest defer to a provider | +| Core runtimes | `nodejs`, `python`, `golang`, `rust`, `java`, `package` / `node`, `python`, `golang`, `rust`, `java`, `project` | Native: pack `nmsh.project` (`package`, `node`, `python`, `go`, `rust`, `java`) with requested vs. active versions | Covered | +| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP (this branch). Next: .NET, Swift, Deno/Bun, Zig, Elixir, Dart/Flutter.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | +| Build systems | `cmake`, `meson`, `gradle`, `maven`, `buf`, `xmake`, `bazel` / `cmake`, `gradle`, `mvn`, `bazel`, `nx` | Gradle/Maven appear as the `java` module's build tool; others only as discovered tools | Covered for Java; **candidate: a single `build` module** (CMake/Meson/Bazel/Make detection) rather than one module each | +| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; **candidates: mise, Nix shell, devcontainer/container** | +| Cloud and infrastructure | `aws`, `gcloud`, `azure`, `kubernetes`, `terraform`, `helm`, `pulumi`, `docker_context`, `openstack`, `nats`, `opa` / `aws`, `az`, `gcp`, `kubectl`, `terraform`, `helm`, `pulumi`, `docker`, `argocd`, `cf`, `firebase` | Native: built-ins `kubeContext`, `dockerContext`; packs `nmsh.cloud` (`aws`, `gcp`, `azure`), `nmsh.infrastructure` (`terraform`, `helm`, `pulumi`) | Covered for the common set; ArgoCD, Firebase, Cloud Foundry, OpenStack, NATS, OPA: provider | +| Frameworks and app tooling | `angular`, `react`, `svelte`, `tauri`, `flutter`, `unity`, `umbraco` (Oh My Posh) | None | **Do not duplicate.** These are project-type labels derived from the same files the runtimes read; they add chrome, not information | +| Package managers | `npm`, `pnpm`, `yarn`, `bun` (OMP) | `package` module reports the package and its manager | Covered | +| Agents | `claude`, `copilot`, `antigravity` (OMP) | Native: pack `nmsh.agents` (`claude`, `claude-limits`) through Claude Code's status-line interface | Covered for Claude; other agents only when they publish a comparable official interface | +| Prompt structure | `character`, `line_break`, `fill`, `shell`, `shlvl`, `sudo`, `text`, `vimode` | NMSh owns prompt layout and its own composer | Not applicable | +| Network and web | `localip`, `ipify`, `http`, `owm`, `todoist`, `wakatime`, `lastfm`, `spotify`, `strava` | None | **Unsafe or impractical by design**: they need network calls or credentials in a prompt. Available through an external provider if a person wants them | +| Health, music, misc. | `nightscout`, `ramadan`, `withings`, `nba`, `taskwarrior` | None | Out of scope | +| Custom commands | `custom` / `command`-style segments | None, on purpose | **Not supported natively**: it runs arbitrary commands. A person who needs it uses a provider (they own that trust decision) or a data-only Context Pack | + +## Recommended order of work + +1. **~~Ruby, PHP~~ (done), .NET, Swift**: highest daily-use gap, all have declarative version files (`.ruby-version`/`Gemfile`, + `composer.json`, `global.json`/`*.csproj`, `Package.swift`/`.swift-version`). +2. **Deno/Bun, Zig, Elixir, Dart**: same pattern. +3. **`build` and `env` summary modules** (CMake/Meson/Bazel; mise/Nix/devcontainer) instead of one module per tool. +4. **Jujutsu / Mercurial** state in `nmsh.vcs`. + +Each addition ships with: the capability, its pack module, provenance and privacy metadata, tests against fixture +directories (including hostile and symlinked files), and the explain/timings view from +[#365](https://github.com/raiseCatError/notMyShell/issues/365). + +## Compatibility + +Starship, Oh My Posh and Powerlevel10k keep working as external prompt providers; nothing here replaces them. Importing +their *visual* configuration into NMSh themes is a separate problem owned by +[#304](https://github.com/raiseCatError/notMyShell/issues/304); translating their segment *logic* is not attempted. diff --git a/docs/design/terminal-workflow.md b/docs/design/terminal-workflow.md new file mode 100644 index 00000000..193ce846 --- /dev/null +++ b/docs/design/terminal-workflow.md @@ -0,0 +1,72 @@ +# Terminal workflow: making one command's result useful for the next + +Status: living design for the workflow initiative. Ledger: [docs/development/workflow-initiative-ledger.md](../development/workflow-initiative-ledger.md). + +NMSh already owns what most of these features need: a structured record per command (command text, stored +output, exit code, duration, waits, lifecycle row, stable `startId`), its own viewport and hit-testing, a +session service that owns the shell, the paste guard, and provider/tool integration. The work below builds on +those records instead of the rendered screen. + +## What other terminals teach (and what NMSh takes) + +| System | Proven idea | Cumbersome / not for NMSh | NMSh decision | +| --- | --- | --- | --- | +| Warp | Blocks as the unit of copy/share; per-block actions; "copy output" without selecting | Blocks require Warp's own shell integration and editor model; sharing defaults toward its cloud | Already block-based. Copy/report/compare act on block records; nothing leaves the machine. | +| Wave Terminal | Rich block widgets, file previews in place | Heavy UI per block; a second app model | Rejected: NMSh stays text-first; previews go to pager/editor handoffs. | +| iTerm2 | Triggers (regex → action), semantic history (cmd-click paths), "copy output of last command" via shell integration | Regex triggers run automatically on output; easy to make unsafe | References are recognized lazily, offered on explicit invocation, never auto-run. | +| Ghostty / WezTerm / Kitty | Hyperlinks (OSC 8), hints/quick-select modes that pick URLs/paths/hashes by keyboard | Mode-heavy key schemes | Keyboard "references in this block" list from the Actions menu; OSC 8 links already honoured. | +| Zellij / tmux | Session persistence, panes; copy mode | NMSh is not a multiplexer | Keep the session service; integrate with tmux, do not replace it. | +| Atuin | Context-rich history (cwd, exit, duration, session), fuzzy search | Separate history database and sync | NMSh already has a history provider for Atuin; pins/recipes reuse history and presets. | +| Pueue | A daemon queue for background jobs, separate shells, logs | Loses the interactive shell's state (cwd, env, functions) | Distinct concept. NMSh's queue runs in the *same* shell; Pueue is an optional integration for detached jobs, not the queue. | +| fzf / Television | Fast fuzzy pickers with preview | External process handoff | Native picker first; fzf/Television stay optional providers. | +| Yazi | File manager with previews | A whole TUI | Handoff only (open a directory/file with it when installed). | +| delta / bat | Readable diffs and highlighted sources | External binaries | Compare renders its own unified view; delta is an optional renderer when installed. | +| Nushell | Structured data between commands | A different shell language | Out of scope: NMSh preserves the user's shell. | +| Gum | Prompts for scripts (choose, confirm, input) | — | Must keep working: input awareness and passthrough already route keys to such prompts. | + +## Decisions and defaults + +| Capability | Default | Why | +| --- | --- | --- | +| `/copy` (Quick Copy), output only | Always available | Existing behaviour; fastest path. | +| Completion status in copies | Off; `--status` per copy | The output is what people paste; status is opt-in context. | +| Interactive copy picker | `/copy ui`, or Settings → Copy | Progressive disclosure. | +| Copy as Report (Markdown, plain) | Explicit action (`--report`, picker, Actions) | Reports carry commands, paths, exit codes: shared deliberately. | +| Report redaction | Offered in the review, never claimed complete | Heuristic; the person inspects the result. | +| Compare output | Explicit action ("Compare output…", `/compare`) | Computed lazily, never per command. | +| Reference recognition | Lazy, on Actions/hover; no decoration | Avoids noise and false confidence. | +| Error navigation | On demand (`/find` filters, Actions) | Exit status is authoritative; text matches are hints. | +| Pins and recipes | Explicit; nothing persisted otherwise | Never auto-run. | +| Queue after failure/interrupt | Paused | Dependent commands must not run blindly. | +| Paste | Never executes; batches are reviewed | Pasting is not execution. | +| Clipboard-change suggestion | Opt-in where detection is cheap and private | No polling of contents; no history. | +| Sticky block controls | Contextual: only while a block's header is scrolled away | No permanent buttons on every block. | +| Animation | Minimal; obeys reduced motion | Restraint. | + +## Rejected or deferred + +- Automatic execution of clipboard, AI or output-derived commands: never. +- Background AI interpretation of output; any AI dependency in these features. +- Clipboard history or persistence; transmitting output anywhere. +- A workflow engine: recipes are reviewed sequences fed to the existing queue. +- A competing log analyzer: error navigation extends `/find`. +- Embedded file manager or diff engine of our own beyond a small line diff for Compare (an LCS line diff + is a few dozen lines; delta renders when present). + +## Shared foundations + +- **Block identity:** every action names a block by `startId`; records that fail ownership checks after clears or + legacy restores are dropped (see transcript repair), so actions never land on recycled line positions. +- **Provenance:** `output` is what the command wrote (NMSh lines inside a running block excluded); + `lifecycleText` is NMSh's completion row, kept separate; reports and copies compose from these. +- **One action path:** mouse, keyboard, slash commands and menus call the same handlers (`copyRecords`, + `runBlockAction`, the queue's `QueueOp`s). +- **Input ownership:** a program waiting for input owns Enter; NMSh never turns a reply into a queued command. +- **Ephemeral by default:** pickers, staging and reviews hold data in memory only. + +## Limits stated plainly + +- Sticky controls exist in NMSh's viewport only; the terminal's own scrollback cannot be decorated. +- Redaction is pattern-based; it will miss things. The review exists for that reason. +- Clipboard-change detection depends on the platform. Reading the clipboard's contents to notice a change is + not acceptable; only a change signal that does not expose the text qualifies (decided in the clipboard slice). 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/docs/development/workflow-initiative-ledger.md b/docs/development/workflow-initiative-ledger.md new file mode 100644 index 00000000..456cfb76 --- /dev/null +++ b/docs/development/workflow-initiative-ledger.md @@ -0,0 +1,251 @@ +# Workflow initiative ledger + +Canonical tracker: [#359](https://github.com/raiseCatError/notMyShell/issues/359). Updated 2026-10-12. + +## Current stabilization checkpoint + +Fresh-session recovery: [workflow stabilization handoff](workflow-stabilization-handoff.md). +Development is stopped at a preservation checkpoint. Cedar/Birch host canonical passed; +Amber full verification failed three compatibility cases. Elm commit 5447eb9 preserves +incomplete acceptance tests (15 focused passed, 4 failed). Input 48a0ded Actions +38157687343 failed macOS flowComposer PTY spawning; Ubuntu shards passed. +No frontend freeze or final integration is claimed. Exact heads/commands/failures are +in the handoff above. + +The shared frontend is **not frozen**. This record supersedes the historical status claims below. No workflow PR +has been merged to master or the release branch. Physical terminal QA remains outstanding. + +| PR | Published head | Local additive candidate | Gate | +| --- | --- | --- | --- | +| #345 | 7122468 | unchanged | stack base | +| #348 | 48a0ded | fix/workflow-cedar / 48a0ded | host canonical passed; current Actions failed macOS PTY spawn | +| #357 | 7fb6b5a | fix/workflow-birch / 3adc451 | host canonical passed; final CI pending | +| #358 | 790fd74 | fix/workflow-amber / 000736d | host canonical failed three compatibility cases; final CI pending | +| #360 | afe7f69 | fix/workflow-coral / 80bd0cf | final canonical/CI pending | +| #361 | b1cdfbe | fix/workflow-indigo / 0117a34 | final canonical/CI pending | +| #367 | 66e164d | fix/workflow-willow / be037a0 | independent sibling; final canonical/CI pending | +| #368 | 5a795ed | fix/workflow-maple / this checkpoint | final canonical/CI pending | + +All candidates preserve their published ancestors through additive merges. Owned +worktrees use neutral names: nmsh-cedar-input, nmsh-birch-queue, nmsh-amber-copy, +nmsh-coral-report, nmsh-indigo-compare, nmsh-willow-vocabulary and nmsh-maple-workflow. +They are on external scratch storage. Existing worktrees and modifications were preserved. + +Temporary resources disappeared during continuations and interrupted two full +runs. Those runs are not passing evidence. Committed work survived and was restored. +Verification logs and the Linux VM now use durable external storage. +The test runner supports NMSH_TEST_TMPDIR; fixture and key-diagnostic writers +respect the selected temporary directory. Invalid storage fails instead of falling +back. Source typecheck, focused lifecycle/privacy/platform tests and verify:fast +passed for storage correction 9465eb5. The runner also isolates child HOME in the chosen root. + +## Confirmed findings and work + +- **Fish 3.7 reader ownership:** the native read editor deliberately does not enable bracketed paste. Waiting answers + remained in the composer/queue. The authenticated `fish_read` marker fixes handover before the prompt, including + hidden readers inside functions and detached readers. `answersByDefault` was not weakened. Details and exact + baseline evidence: [#356 comment](https://github.com/raiseCatError/notMyShell/issues/356#issuecomment-6106773236), + `docs/development/fish-typeahead.md`. +- **Fish multiline submission:** use native `begin`/`end`; zsh/Bash use brace groups. Composer, dispatcher and parse + checks must use the real shell ID. Queue regressions verify persistence of shell variables and one logical record. +- **Fixture synchronization:** unique runtime-built read prompts prevent historical redraws authorizing a reply; + the Ctrl+C survivor waits for trap installation, uses a timed-read-capable shell and an explicit release file. +- **Attach privacy follow-up:** a final canonical run caught a password echoed by the outer PTY during journal I/O. + A slow-journal real-PTY fixture reproduces it. Raw input is now protected before passthrough output and throughout + probing/journaling; the later fixture follow-ups require their own final gate. Key diagnostics now exclude all running/startup input. +- **Gemfile security:** `18e94d9` applies the bounded single-line Ruby declaration parser to the duplicate in #368. + Malicious-whitespace and cross-line declaration regressions pass. #370 remains the canonical runtime owner. +- **Workflow additions (a16d556, 3097cbb):** clips deliberately kept in bounded frontend memory, reviewed through the + existing Paste Review/palette, history selection pinning with recorded cwd, and F8/Shift+F8 failure navigation. + Copy/folding remain independent. Clip and reference actions never submit commands automatically. +- **Clipboard suggestions (a16d556):** off by default and frontend-only. macOS uses only NSPasteboard changeCount + while idle; contents are read only by explicit `/ps`. Linux has no supported metadata-only counter and fails closed. + The fixed native metadata query and lifecycle/privacy unit regressions passed. +- **Guide/fixture corrections:** paste batches and pins are included in the guide; app fixtures pause the queue before + asserting accepted entries, preventing asynchronous parse checks from racing first-entry dispatch. +- **Diagnostics/import audit:** module explanations distinguish uncollected/unknown/stale facts from known data; + collection metadata does not prove conditional visibility. Starship/Oh My Posh previews disclose loss, save only + after a second explicit action, and preserve source files. External-tool decisions describe bat's actual Theme + Bridge support; dedicated source-preview launching is not implemented. +- **Fish parse-only transport:** Fish 3.7 on Linux rejects Node's socket-backed stdin with “Unable to read input + file: Is a directory”. Paste verification supplies Fish's script through `--no-execute -c` instead; failed/oversized + argv submission refuses the split. Regression checks prove neither commands nor substitutions execute. +- **Diagnostic privacy:** module details show safe collection failure states, rather than raw exception paths or + values. Measured durations remain available; no new collector is invoked by opening details. + +- **Comparison UI trust boundary (5570d34):** restored command labels in the chooser header bypassed display sanitization. Both chooser and diff now use safe labels; format and separator controls become visible in authored review UI. Adversarial tests cover narrow layouts and actual clipboard bytes. +- **Report Markdown status (25af5ba):** restored lifecycle text was a raw Markdown fact. It now uses the existing delimiter-sized inline-code representation, so HTML, links and backticks remain literal. +- **Vocabulary (d4619e4):** selected activity pairs survive live detach/reattach, including previous-choice collision avoidance. Optional journal indices are validated; factual status still derives from lifecycle and exit code. +- **Module diagnostics (1a372fb, 06433f1):** details show capability/source, freshness TTL, invalidation and demand-refresh policy, retained-cache availability and measured collection wall time excluding queue delay. Details reuse fact snapshots and renderer conditions; they add no collectors. Exact last-frame width-compaction decisions are not recorded. +- **Cross-feature queue fixture (4636672):** the combined workflow's optional batch action changes the paste-preview footer. The fixture accepts that optional action while still testing whole-paste insertion and explicit once-only queueing. The original regex failure reproduced, and corrected real-PTY cases passed for zsh, Bash and Fish. + +- **Report fixture (ee5483e):** the existing live Copy as Report assertion now expects the literal Markdown status added by the security fix. Its name accurately states that every report is reviewed. Review, cancellation and actual clipboard-byte tests passed. +- **Linux prerequisites:** the disposable minimal Ubuntu VM initially lacked less and vim. Their command-not-found failures are recorded; both tools are now installed in that VM. This changes no application installer policy. +- **Metadata/installer fixtures (86fb4e4):** identical size and timestamps after an immediate mise config edit reproduced even at nanosecond precision. The stat-only cache test now supplies a definite timestamp change and documents explicit refresh for unchanged metadata. Provider review tests supply their own fake executable installer; they do not require host Homebrew or run an installer. + +- **Completion and home isolation (566c321):** the queue full run at f081c42 failed its first nested-path candidate assertion after about 1.5 seconds. Controlled slow initialization reproduces a bounded empty result and transport cleanup. Functional capture/cache fixtures now prepare their own completion cache; a new expiry/recovery test retains the production startup deadline. The suite runner also assigns a private home inside its selected temporary root, preventing generated shell caches from reaching the host home. The new isolation test failed before the correction; affected lifecycle/completion tests and verify:fast passed afterward. Final canonical/CI verification is pending. + +- **Fixture ownership follow-up (48a0ded, e2adc1e):** a visible composer preceded the third shell's authenticated startup prompt in the SIGHUP fixture; a controlled startup release gate now proves and waits for that transition. Same-shell queue tests use a release file rather than a fixed sleep, keeping all five entries pending until assertions finish. Interrupt typeahead waits for actual foreground process ownership before signalling. The focused SIGHUP case and all three shell queue cases plus Ctrl+C/Ctrl+Z passed. +- **Short module details (52db115):** diagnostic rows now honor the panel's height and remain reachable through arrows/PageUp/PageDown. The viewport is ephemeral; reading and resizing do not change settings or demand collectors. Focused unit and real-PTY tests in a 50-column NO_COLOR Safe terminal, direct typecheck and verify:fast passed. Exact last-frame module width-compaction explanations remain an acceptance-integration follow-up. + +## Verification recorded so far + +Newest exact-head evidence is in the handoff above. Historical statements below +apply to their recorded commits, not later head acceptance. + +- macOS canonical verification passed at 566c321 with the isolated child home. Its later startup-gate fixture commit needs a new exact-head gate. +- The macOS queue run at 2dd8b80 failed five cases: a transient five-entry assertion after the holding sleep expired, signalling before actual foreground ownership, two no-output frontend startup deadlines and the bounded zoxide query fixture. The first two assumptions are corrected above. At inspection this 8 GiB/8 CPU host had load averages 18.05/50.35/59.36 and unrelated Xcode/xctest work active. Their processes are preserved. Host verification now runs one test worker with this track's Linux VM stopped; resource pressure is evidence, not a blanket explanation for remaining failures. +- The Ubuntu run at 895aae5 passed ranking and multiplexer cases but failed SIGHUP's third-shell readiness. That exact run is failed evidence; the controlled startup correction must pass a new full gate. +- macOS canonical verification and direct typecheck passed at 86fb4e4. [Actions 38150534791](https://github.com/raiseCatError/notMyShell/actions/runs/38150534791) passed the Ubuntu/macOS shards, Quality, Node 22 and aggregate checks; timing/Fedora jobs were skipped on the PR event. The isolated-home follow-up needs its own final gates. +- Ubuntu canonical verification passed at 5e45e87 with Fish 3.7.0, including the unchanged ranking threshold. Multiplexer tests were skipped because the minimal VM lacked tmux/screen; those tools are now installed for the next run. +- The Ubuntu canonical run at 5756f7c failed the report assertion, two missing-tool fixtures, mise timestamp assertion, provider installer availability assertion and the unchanged suggestion timing threshold. The timing test passed an isolated rerun; the final canonical gate must pass before publication. +- Typecheck and canonical verification of published #348: passed; exact Actions head `684a3fbc837be97f185cc938f4fb13cbccf1533b`. +- Queue candidate canonical verification passed before the additional attach privacy finding; that later failed run + is retained and must be superseded by a clean final run before pushing #357. +- Ubuntu 24.04 arm64 / Fish 3.7.0 / Node 26.10.0: published queue baseline fails both original Fish read tests; fixed + snapshot passes `inputAwarenessLive`, `typingAfterRead`, `queryOrder`, `commandQueueLive` including detach, + interruption, same-shell grouping and narrow/NO_COLOR/Safe presentation. Ubuntu amd64 CI remains an independent gate. +- Focused report/diff sanitization, hostile pin rendering, clip/paste staging, recipe review, history pinning, failure + keys, clipboard failure/EPIPE/size bounds, and actual stand-in clipboard diff bytes passed. +- Live Report cancellation and actual clipboard bytes passed on macOS and in the Ubuntu canonical run at 5e45e87 with hostile controls, invisible Unicode, tokens and fence content. +- Early full local runs inherited disabled colors/dumb TERM and one hit ENOSPC. They are failed runs, not evidence + of passing behavior. Final fixture runs explicitly set their truecolor environment; explicit NO_COLOR tests remain. + +## Cross-track boundary and remaining work + +[Native/workspace handoff PR #385](https://github.com/raiseCatError/notMyShell/pull/385) documents the current +independent heads and remaining frontend wiring. It explicitly waits for the #359 stabilization checkpoint. +Do not modify its handoff or branches. Ruby/PHP duplication is security-patched locally; reconcile against canonical +#370 after its verified freeze, retaining module diagnostics, theme imports and independent workflow behavior. + +The overlap is localized and deliberately retained until the canonical native heads are frozen: + +| File | Integration decision | +| --- | --- | +| `src/context/capabilities/runtimes.ts` | #368's duplicate Ruby/PHP remains patched by `18e94d9`; take canonical #370's stronger runtime implementation at integration | +| `src/context/packs/builtin/nmsh.project.json` | take the highest frozen native stack's catalog once, not both variants | +| `tests/contextCapabilities.test.ts` | preserve workflow-independent tests; retain malicious-whitespace/declaration regressions and canonical runtime tests | +| `tests/moduleManager.test.ts` | reconcile expected catalog contents with the highest native stack, preserving workflow diagnostics | +| `docs/design/native-module-coverage.md` | retain #368's coverage research; update adoption status against the exact integrated native heads | + +No destructive revert of the published runtime commit is needed. Removing the duplicate before the native handoff +would remove currently tested functionality from the standalone workflow PR; resolve these known file overlaps on +the acceptance integration branch while preserving both security regressions. #370 owns additional install-layout +support in `services.ts`, which this track has not changed. + +Next: complete final canonical verification and exact-head GitHub CI for every owned PR, then publish the frontend freeze checkpoint in #359. Acceptance integration follows both freezes in a separate `integration/nmsh-acceptance-*` +branch/worktree; no master/release merge is authorized. + +Physical QA must cover Ghostty and Terminal.app, hidden input and immediate typing after completion/interrupt, +queue/paste approval, clipboard/report/diff rendering, long-output folding, references, narrow/Safe/NO_COLOR, +reattach, vocabulary and theme previews. Headless fixtures are not physical validation. + +--- + +## Historical records (superseded; retained for context) + +## Rules this work keeps + +- The real shell is authoritative; NMSh never executes clipboard, AI or output-derived text without an explicit + submit or confirmation, and never runs a queued entry twice. +- Copying reads stored command data; folding is presentation. Only "Auto-expand copied output" connects them. +- Nothing is persisted (clipboard, pins, recipes) unless the person asks. +- No merge to master, tag, release, deploy or change to real account configuration without explicit approval. +- Commits and PRs carry no AI attribution lines (repository rule). + +## Branch stack (bottom to top) + +| Branch | Worktree | PR | State | +| --- | --- | --- | --- | +| `release/v0.18.0` | `/private/tmp/nmsh-rel-fix` | #345 | base of the stack; CI green | +| `feature/input-awareness` | `../notMyShell-input-awareness` | #348 | timed-read test fixed (Ubuntu); lapsed-wait start fixed (macOS); CI re-running | +| `feature/command-queue` | `../notMyShell-command-queue` | #357 (draft, base #348) | complete; full verify green when the machine is not saturated | +| `feature/copy-selection` | `../notMyShell-copy` | #358 (draft, base #357) | copy selection, picker, settings, sticky controls, Smart folding fix | + +| `feature/copy-report` | `../notMyShell-report` | #360 (draft, base #358) | Copy as Report; security fixes below | +| `feature/compare-output` | `../notMyShell-compare` | #361 (draft, base #360) | Compare output; contains #360 by merge | + +| `feature/expressive-activity-vocabulary` | `../notMyShell-activity` | none yet (PR body ready; opening it was held) | #362: Expressive/Classic wording, 663 curated pairs; contains #361 by merge; full suite green | +| `feature/output-references` | `../notMyShell-references` | none yet | #353 references + failure navigation; #354 paste batches, `/ps`; #355 queue conditions, pins, recipes (separate commits); contains #361 | + +Other open PRs (independent, not part of this stack): #347 agent workspace, #349 notice word wrap, #346 mods. + +## Work completed + +| Slice | Commits | Evidence | +| --- | --- | --- | +| PR #348 Ubuntu failure: a timed `read -t` waits in poll/select, so Linux evidence is "probably" (test assumption) | `68ea9d7` | syscall unit test; live suite 30/30 | +| PR #348 macOS failure: a lapsed prompt resumed as a new wait (product bug) | `c4c891e` | unit test pins same start, time counted once | +| Command queue (session-owned, same shell, pause on failure/interrupt, detach-safe) | `b302cd9` | 13 live PTY tests zsh/Bash/Fish; unit + protocol tests | +| Queue: exec events carry "from queue" so late-attaching windows show it | in `b302cd9` | detach/reattach live test | +| Queue: a fresh prompt's reply is answered, never queued (security review) | in `b302cd9` | `read -s` live test | +| Queue: Ctrl+Q compose mode never leaks Enter to the program | in `b302cd9` | key-input live test | +| Queue: command text drawn safely (security review: escape injection) | `15147d1` | hostile-content unit test | +| Queue: type-ahead after Ctrl+C/Ctrl+Z bounded to 2 s (security review: input routing) | `a257438` | surviving-program live test | +| Live tests: a start that never becomes ready disposes its sandbox (was hanging the suite) | in `b302cd9` | — | +| Copy: `/copy` and `/cp` N, -N, A-B, lists, `latest`, `ui`, `--status/--no-status` | `7a94ce5` | unit + 5 live PTY tests | +| Copy settings (mode, include status, auto-expand); picker with search/preview | `7a94ce5` | unit, live, settings/setup coverage | +| Direct block Copy + sticky Copy/Actions on the sticky header | `7a94ce5` | app-level tests; captures `docs/captures/sticky-*.png` | +| Smart folding: long output folds whatever the command; important lines stay visible | `7a94ce5` | rewritten policy tests incl. 5/50/200/500/5000 lines, wrapped lines, errors mid-log | +| Copy never changes folding; opt-in auto-expand of copied blocks only | `7a94ce5` | `tests/copyFolding.test.ts` | + +## Security acceptance (#363), checked against current heads + +Verified fixed or not reproducible: parser/redaction differential (skeleton matching with span mapping), token +splitting by zero-width, combining and full-width characters, bidi controls inside key names, private paths, +URL credentials, auth headers, report fence sizing, editing (edited text is rescanned and labelled as not redacted). + +Found and fixed in this session (unit tests in `tests/compareSecurity.test.ts` and `tests/copyReport.test.ts`): + +| Finding | Fix | +| --- | --- | +| `c` copied the raw unified diff: escape bytes, bidi and invisible characters from program output reached the clipboard | `copyableDiff` applies the report character policy | +| `c` gave no hint that the copy was not reviewed or redacted | the note says what looks sensitive and points to `r` | +| Comparison report fence was sized before invisible characters were removed; hidden characters or U+2028 could close it | the diff is cleaned first, then the fence is sized | +| Side labels (command text) sat in single-backtick inline code | `inlineCode` sizes its delimiter and folds to one line | +| Report `Directory`/`Status` facts accepted line breaks and backticks | folded to one line, directory as `inlineCode` | + +Remaining limits: redaction is pattern based; secrets with no recognisable shape or context are not found. + +## Findings worth remembering + +- Smart folding root cause: the policy only folded output that looked noisy; varied diagnostics scored about 0 + against a threshold of 3, and one "error" word anywhere vetoed folding. Now length decides above 120 lines. +- `/copy` expanding a block could not be reproduced on release or this branch (app-level and PTY). Most likely + the block was never folded (the Smart bug). Regression tests now pin that no copy path changes fold state. +- Full-suite failures while other heavy work runs on the machine (VHS recordings, parallel suites) are load + timeouts in startup/service tests; the same files pass 65/65 when run alone. Run `npm run verify` on an idle + machine before trusting a red result. +- Known flake, still tracked: Fish can lose keys typed within ~1 s after a handed-over `read` returns + (docs/development/fish-typeahead.md). + +## Issues + +Epic #359. Slices: #350 folding/copy (fixed in #358), #351 report, #352 compare, #353 references and failure +navigation, #354 paste batches and clipboard, #355 pins/recipes/queue conditions, #356 Fish type-ahead flake. + +## Next + +1. (done) Stack pushed; draft PRs #357, #358; issues filed. +2. Phase B: Copy as Report (Markdown/plain, review + optional redaction), Compare output, contextual references, + error navigation. +3. Paste batches → queue (`/ps`, syntax-aware splitting), clipboard-change suggestion (opt-in where detection is + reliable). +4. Phase C: pins and recipes, queue conditions (manual approval). +5. Phase D: integrations where they add value (delta for compare, fzf/Television preview). +6. Integration branch for physical QA; prioritized QA checklist. + +## Physical QA needed (not yet done by a person) + +- Sticky Copy/Actions while scrolling long blocks; click targets in your terminal; "Copied" feedback. +- `/copy ui` picker keys, search and preview at your usual window sizes. +- Queue in daily use: Enter while a command runs, Ctrl+Q/Ctrl+S, a failing step pausing the rest. +- Smart folding on your adb/diagnostic commands; the important-lines preview. + +## Session 2 checkpoint (branches pushed, no PRs opened by this session) + +- `feature/expressive-activity-vocabulary` (#362): full suite green (2227 pass). PR body ready; `gh pr create` was held by the safety hook, so open it with base `feature/compare-output`. +- `feature/output-references`: full suite green after fixing three deterministic count/registry tests (see last commit). +- Security: the batch splitter got three review rounds; it is now gated by real-shell parse checks (`pasteBatchCheck.ts`) and a whole-versus-split execution differential test. `displaySafe` now shows bidi/format characters. +- Not done: clipboard-change suggestions and ephemeral clips (#354), pin from history selection (#355), #365 explain/timings, #304 import, #366 decisions, native runtime modules (matrix in `docs/design/native-module-coverage.md`), workspace work (#330), physical QA for everything. +- CI: Ubuntu Fish live tests (`read` hand-over, alt-screen passthrough) time out on #357/#358 runs; they pass locally on macOS. Tracked under #356; needs a Linux repro. diff --git a/docs/development/workflow-physical-qa.md b/docs/development/workflow-physical-qa.md new file mode 100644 index 00000000..370b05c6 --- /dev/null +++ b/docs/development/workflow-physical-qa.md @@ -0,0 +1,67 @@ +# Workflow physical QA + +Tracker: [#359](https://github.com/raiseCatError/notMyShell/issues/359). Automated PTY fixtures do not establish +physical terminal validation. Record the exact branch/commit, OS, shell version and terminal host with each result. +Use an isolated NMSh configuration and a temporary working directory; keep provider/account configs unchanged. + +## Input and queue + +Use Ghostty and Terminal.app where available, with zsh, Bash 4.4+ and Fish 3.7 or later. + +1. Ask for a demo hidden answer using the active shell's command below. Type `FAKE_ONLY`, then Enter. Only + `ANSWER-ACCEPTED` should appear; the answer must not echo or execute as a shell command. + - zsh: `read -s 'reply?Demo password: '; [[ $reply == FAKE_ONLY ]] && print ANSWER-ACCEPTED` + - Bash: `read -rs -p 'Demo password: ' reply; test "$reply" = FAKE_ONLY && echo ANSWER-ACCEPTED` + - Fish: `read --silent --prompt-str 'Demo password: ' reply; test "$reply" = FAKE_ONLY; and echo ANSWER-ACCEPTED` +2. Repeat after detaching the window while the question is open and explicitly reattaching from `/resume`. + Input must remain hidden, answer the original reader and return to the composer after completion. +3. Run `sleep 3`, prepare `echo QUEUED` with Ctrl+Q, and inspect `/queue`. The entry runs once, after the original + command. Repeat with failure and Ctrl+C: the default pauses remaining entries. Ctrl+Z must return control without + sending a prepared response into a new shell command. Answer a real `read` while another command is queued; + the response belongs to the reader. +4. Paste native multiline blocks (including a Bash/zsh heredoc and a Fish `begin`/`end` block). Review splitting, + quoting, substitutions and strings. Pasting, opening review and choosing a recipe must execute nothing. Explicit + queue confirmation submits the reviewed text in order; variables and cwd remain in the same shell. +5. Set an entry to “asks first”, approve it, then edit it before dispatch. The edit withdraws approval. “Runs even + after a failure” permits the reviewed follow-up after failure, but does not bypass an interrupt. Repeat in each shell, + detach with an entry awaiting approval, and reattach: it must keep waiting until you approve that exact entry. +6. Open `less` or another alternate-screen program; keys and terminal selection belong to it. Exit and immediately + type a command. Repeat after Ctrl+C, Ctrl+Z and resize. + +## Clipboard, output and reuse + +1. `/ps` explicitly reads the clipboard. Review, keep with C, cancel and open `/clips`. Selecting a clip reviews it + again; insertion and queue approval are separate actions. Closing the frontend discards its clips. On macOS, + `/clips suggestions on` offers a neutral change hint while idle; `/ps` remains the content read. Disable with + `/clips suggestions off`. Unsupported platforms should explain that metadata suggestions are unavailable. +2. Pin a selected history command from the palette. Check its recorded cwd, command text and recipe review. A + stale history selection must not pin a different command. Keep that selected history command as a clip and review + it without running it; after closing/reattaching, the saved pin remains and the clip is gone. Placeholder values must stay shell-quoted data. +3. Run a failure between successful commands. F8/Shift+F8 navigate actual failed records while idle, preserving + the draft. Program input and passthrough must retain their keys. Check files, URLs, commits and device references + through Actions; output-derived choices must not execute automatically. +4. Produce long output, fold it and copy it through the picker, Actions and selection. Copied bytes stay complete + unless a selection was explicitly chosen; folding alone does not change copy content. +5. Copy as Report: inspect long lines, Markdown fences, backticks, demo tokens/private paths and edited text. Cancel + must leave the clipboard unchanged. Explicit copy must match the reviewed sanitized text. Scan detection is + best-effort. Verify a clipboard backend failure is reported without claiming a successful copy. +6. Compare two outputs. `c` copies a sanitized plain diff without redaction; sensitive findings disclose that. + `r` opens report review with redaction. Check actual clipboard bytes in a plain text editor, including combining + characters, Unicode separators and bidi/zero-width content. + +## Appearance and diagnostics + +Repeat relevant steps at 40, 50, 80 and 120 columns, Safe glyphs and `NO_COLOR=1`, and inside tmux. + +- Expressive is the default; successes pair active/completed wording. Failed, interrupted and waiting states keep + factual status and timing. Classic persists through restart. Copies/reports and archived records stay factual. + Reattach a running command and check its phrase stays paired when its live journal is available. +- Import a local Starship/Oh My Posh theme in `/theme`. Preview must disclose unsupported/lossy mappings before + saving. Cancel stores nothing. Saving changes only the NMSh theme library; provider source files remain unchanged. +- `/modules` details explain reads, routing, collection state and measured time. In a short window, + use Up/Down and PageUp/PageDown to reach Source, Cache, Timing and Why; resize and confirm the view stays bounded. Opening details or `/doctor` must + not trigger unrelated expensive collection. Unknown/uncollected states must not claim available data or proven + visibility. + +Keep issues open until required physical results are recorded. Report failures with the exact action sequence, +capture and commit; do not substitute automated passage for these results. diff --git a/docs/development/workflow-stabilization-handoff.md b/docs/development/workflow-stabilization-handoff.md new file mode 100644 index 00000000..66f351a2 --- /dev/null +++ b/docs/development/workflow-stabilization-handoff.md @@ -0,0 +1,411 @@ +# Workflow stabilization handoff — 2026-10-12 + +Canonical initiative: [#359](https://github.com/raiseCatError/notMyShell/issues/359). +This is a preservation checkpoint, **not release acceptance or a frontend freeze**. +Development and verification were stopped at the user's request. No new full suite +was started for this handoff. No PR was merged, no shared history rewritten, and no +protected worktree modified. + +## Commands to run first + +Read this file, workflow-initiative-ledger.md and current issue/PR discussion. +GitHub heads can advance; these tables are checkpoint observations. +Recover this file from origin/fix/workflow-maple after fetching. + +~~~sh +git status +git branch -vv +git worktree list --porcelain +git remote -v +git fetch origin +git show origin/fix/workflow-maple:docs/development/workflow-stabilization-handoff.md +gh issue view 359 --repo raiseCatError/notMyShell --comments +gh issue view 356 --repo raiseCatError/notMyShell --comments +gh pr view 385 --repo raiseCatError/notMyShell +gh run view 38157687343 --repo raiseCatError/notMyShell --log-failed +~~~ + +Use an isolated worktree, never develop in the main checkout. On this host, temporary +files, caches, VM disks and new worktrees belong on T7 Shield. Use neutral tree/color/ +fruit names and project commits without authorship trailers. Preserve other owners' +worktrees, including their names and stale registrations. + +Main checkout is clean on feature/status-strip-2 at +7916f2e160e713878db9fbe52b2fbb126a3b698c (ahead one, preserved). + +## Owned checkpoint branches and worktrees + +All eight trees were clean after acceptance commit 5447eb9, before this documentation. +Each is published under the identical neutral remote name. The Maple SHA below is +its implementation base; the final branch tip additionally contains this documentation. +Use the #359 comment/remote ref for the documentation commit's SHA. + +| Branch | Implementation/checkpoint SHA | Worktree | +| --- | --- | --- | +| fix/workflow-amber | 000736ddc47e78d322d2ba6ff83fd4e205c7ca26 | /Volumes/nmsh-t7/nmsh-amber-copy | +| fix/workflow-birch | 3adc4515a10e9f7a3af958683b1f389522fc0119 | /Volumes/nmsh-t7/nmsh-birch-queue | +| fix/workflow-cedar | 48a0ded6a9eecaca6753300ea33785d05e08f4db | /Volumes/nmsh-t7/nmsh-cedar-input | +| fix/workflow-coral | 80bd0cfaf3cabd0b36229317372e16995cbd0505 | /Volumes/nmsh-t7/nmsh-coral-report | +| fix/workflow-elm | 5447eb9e8a33f4496bd2f06c175b0a5f1c390cb6 | /Volumes/nmsh-t7/nmsh-elm-acceptance | +| fix/workflow-indigo | 0117a3439b05b1e4dab933e9da31c4fb795ef24a | /Volumes/nmsh-t7/nmsh-indigo-compare | +| fix/workflow-maple | e942523f3f352cc766c94eddfcc61e775a60df3d | /Volumes/nmsh-t7/nmsh-maple-workflow | +| fix/workflow-willow | be037a0861546705687bc4d633d4281e50fedf46 | /Volumes/nmsh-t7/nmsh-willow-vocabulary | + +Elm contains **incomplete acceptance regressions** at +5447eb9e8a33f4496bd2f06c175b0a5f1c390cb6, based on Maple e942523. +It is not merged into Maple or any published feature PR. +Maple and Willow are independent siblings above Indigo. + +Older local fix/workflow-*-checkpoint and *-resume refs are recovery history, not +active branches. All workflow stabilization commits reachable from those old refs +must remain recoverable; check ancestry before cleaning anything. + +## Published workflow PRs + +| PR | Published branch | Exact published head | Base | +| --- | --- | --- | --- | +| #345 | release/v0.18.0 | 7122468f0550f415ab6041cce94abda3a9fb6fa1 | master | +| #348 | feature/input-awareness | 48a0ded6a9eecaca6753300ea33785d05e08f4db | release/v0.18.0 | +| #357 | feature/command-queue | 7fb6b5ac23aeecc21e7a0d7ba5b824af95f505aa | feature/input-awareness | +| #358 | feature/copy-selection | 790fd740cc607cbf6e834bd01501006fa9183f82 | feature/command-queue | +| #360 | feature/copy-report | afe7f695ef803d0de11f53afd540f0a24d992a01 | feature/copy-selection | +| #361 | feature/compare-output | b1cdfbe9c28e7af58f5a181a51922f103f520bf5 | feature/copy-report | +| #367 | feature/expressive-activity-vocabulary | 66e164d973fab278823f773036800d9c6b0fd6a3 | feature/compare-output | +| #368 | feature/output-references | 5a795ed6214bbbb43223e6625cd15d552b84466e | feature/compare-output | + +Only #348 received the current input candidate. Other feature PRs retain old published +heads; candidate work is preserved on the neutral checkpoint refs, without opening +new PRs or dispatching CI. Do not claim those feature PRs contain candidate fixes. + +Dependency order: #345 → #348 → #357 → #358 → #360 → #361; +**#367 and #368 are siblings** based on #361. Preserve additive merge history. + +Related issues/ownership: #348 input; #357 queue; #358 copy selection/picker/sticky/ +folding; #360 report; #361 comparison; #367/#362 vocabulary; #368/#353–#355 references, +failure navigation, staging/clips, pins/recipes/conditions; #356 Fish; #363 security; +#365 existing diagnostics; #304 theme imports; #366 integrations; #359 coordination. +Manual acceptance remains outstanding; do not close issues from headless results. + +## Implemented work and limits + +- **Input:** authenticated Fish fish_read markers hand over before native reader prompts, + including hidden/function/detached readers. answersByDefault was not weakened. + Same-shell grouping uses Fish begin/end or zsh/Bash braces and explicit shell identity. + Attach privacy protects input before passthrough/probe/journal delay; key diagnostics + exclude startup/running input. Real PTY regressions cover private input and reattach. +- **Fish 3.7 parsing:** no-config/no-execute script via -c avoids socket-backed stdin's + “Unable to read input file: Is a directory”; oversized argv refuses splitting. Sentinel + regressions prove no commands/substitutions execute. +- **Security:** authored report/diff/reference/pin UI boundaries cover hostile controls, + bidi/invisible format characters, restored labels and Markdown fence/link/status + injections. Final clipboard bytes use stand-in clipboard programs; review/cancellation + and copy failure cases have evidence. Report status uses delimiter-sized inline code. + Secret scanning remains best-effort. Raw program output remains raw. +- **Workflow:** bounded ephemeral clips (12 entries / 1 MiB), explicit review, history + pinning with recorded cwd, F8/Shift+F8 factual failure navigation. Opt-in macOS idle + suggestions query clipboard changeCount only; contents require explicit /ps. Linux + metadata counter unsupported/fails closed. No derived command auto-executes. +- **Vocabulary:** stable per-command pairs and optional validated journal indices survive + live reattach; previous-pair collision avoidance retained. Failure/interruption/wait + are factual. Expressive default and Classic persistence retained; archived/copy/report + wording stays factual. +- **Imports:** bounded static Starship TOML and Oh My Posh JSON/YAML/TOML mappings, + unsupported/lossy disclosure, preview and second explicit save; no provider-source + rewrites, template execution or parallel theme engine. +- **Diagnostics:** availability, source/trust, TTL/invalidation/demand/cache state, safe + failure and measured collector wall time excluding queue delay. Reading details adds + no collectors. Short panels scroll via arrows/PageUp/PageDown, preserving draft and + settings. **Exact last-frame width-compaction decisions remain unimplemented.** +- **Runtime duplicate:** workflow Gemfile parser bounded at 18e94d9, retaining hostile + whitespace/cross-line regressions. #370 owns canonical native additions. Old published + #368 still lacks candidate fixes; no native catalog expansion was performed here. +- **Verification infrastructure:** explicit external temp roots fail closed; private child + HOME. Completion expiry/recovery retains production deadlines. Fixtures wait for + authenticated startup and foreground process ownership, replacing fixed holding sleeps. + +See the canonical ledger, fish-typeahead.md, workflow-physical-qa.md and linked issues +for individual fixing commits/design. These features are implemented, but **the whole +stack lacks final exact-head canonical/CI acceptance**. + +## Exact commands and results + +Host: macOS arm64, Node 26; one worker with own Linux VM stopped. +The suite supplies private child HOME. Do not alter personal/provider config. + +~~~sh +export TMPDIR=/Volumes/nmsh-t7/t TMP=/Volumes/nmsh-t7/t TEMP=/Volumes/nmsh-t7/t +export NMSH_TEST_TMPDIR=/Volumes/nmsh-t7/t +export XDG_CACHE_HOME=/Volumes/nmsh-t7/cache/xdg +export npm_config_cache=/Volumes/nmsh-t7/cache/npm +export TERM=xterm-256color COLORTERM=truecolor NMSH_TEST_CONCURRENCY=1 +unset NO_COLOR +npm run verify +~~~ + +Canonical verification builds/typechecks source, runs canonical tests plus separate +ranking and checks diffs. Counts are historical evidence for these runs, not a fixed +project test total. + +| Exact source head | Command and result | +| --- | --- | +| Cedar 48a0ded6a9eecaca6753300ea33785d05e08f4db | npm run verify PASS; runtime 2149 passed, 0 failed/skipped; ranking 8 passed; cleanup gate passed; runtime 648807.530084 ms | +| Birch 3adc4515a10e9f7a3af958683b1f389522fc0119 | npm run verify PASS; runtime 2176 passed, 0 failed/skipped; ranking 8 passed; cleanup gate passed; runtime 769361.74225 ms | +| Amber 000736ddc47e78d322d2ba6ff83fd4e205c7ca26 | npm run verify FAIL; runtime 2200 passed / 3 failed / 0 skipped; ranking 8 passed; runtime 948723.419 ms | +| Coral 80bd0cf / Indigo 0117a34 / Willow be037a0 / Maple e942523 | Final host canonical NOT RUN: serial loop stopped at Amber | +| Elm 5447eb9 source | Focused command below FAIL: 15 passed / 4 failed / 0 skipped; 92825.112 ms. Run preceded commit; commit preserves the same files | +| Maple diagnostics 52db115 | npm run typecheck PASS; focused moduleManagerUx/moduleExplain/moduleManager/moduleDiagnosticsLive: 25 passed; npm run verify:fast PASS (67 focused tests) | + +Current verification was finished before preserving Elm. **No new suite was launched +for this handoff**. Exact last focused command with the environment above: + +~~~sh +node /Volumes/nmsh-t7/run-selected.mjs "$PWD" \ + tests/queueConditionOwnershipLive.test.ts tests/queueConditionsLive.test.ts \ + tests/outputReferencesLive.test.ts tests/historyReuseLive.test.ts \ + tests/compatibilityHarness.test.ts --test-reporter=spec +~~~ + +Local helper imports cwd's scripts/test.mjs, validates selected file existence, calls +runTestFiles and returns its exit code. Passing files to scripts/test.mjs directly +does not focus tests. Cloud equivalent (do not run until development resumes): + +~~~sh +node --input-type=module -e " +import {runTestFiles} from './scripts/test.mjs'; +process.exitCode = (await runTestFiles([ +'tests/queueConditionOwnershipLive.test.ts', 'tests/queueConditionsLive.test.ts', +'tests/outputReferencesLive.test.ts', 'tests/historyReuseLive.test.ts', +'tests/compatibilityHarness.test.ts' +], ['--test-reporter=spec'])).code;" +~~~ + +### Elm acceptance failures — preserved, not fixed + +1. outputReferencesLive.test.ts:72: “timed out waiting for the original composer draft + executes only on its explicit Enter”. Earlier restored navigation/reference copy + assertions passed; final draft submission timed out. Cause unestablished. +2. queueConditionOwnershipLive.test.ts:110, zsh/Bash/Fish: “AssertionError: NO_COLOR + stays plain”. Expected no ESC[38;[25]; in output slice; actual has + ESC[38;2;242;240;236m and other colors in restored command/status rows. + Approval/failure/interrupt checks reached this assertion; subsequent transcript/cwd + assertions were **not reached**. Establish restored ANSI vs live rendering/fixture + boundary before changing product or checks. +3. historyReuseLive passed; existing queueConditionsLive passed across all three shells. + Every compatibilityHarness case passed in this focused rerun. + +### Amber full-suite failures — remain failed evidence + +- Agent fixture clean-exit ownership: disposal timed out; four lsof ownership probes + ETIMEDOUT at 2120/2357/3002/2287 ms. Frontend PID 8730 already dead, sockets empty, + ownership still unproven. +- Nested fixture clean-exit ownership: same failure, five ETIMEDOUT probes at + 2007/2041/2059/2041/2047 ms; frontend PID 8978 dead, sockets empty. +- Canonical inline restoration: startup /❯/ deadline expired after 20184 ms; zero + frontend output bytes; PID 9277 alive. No prompt/test command executed. + +8 GiB / 8 CPU host had unrelated Xcode/xctest/Shortcuts/Docker work; load reached +34.51/16.34/11.29 during failure. Their processes were preserved. +**Scheduling/resource effects are suspected, not confirmed flakes.** A passing focused +rerun does not erase failed canonical evidence. Do not weaken deadlines or ownership +checks. Disposal may mask body assertions; preserve both errors during investigation. + +### Current input GitHub CI + +[Actions 38157687343](https://github.com/raiseCatError/notMyShell/actions/runs/38157687343) +at 48a0ded6a9eecaca6753300ea33785d05e08f4db: **FAIL**. + +- Ubuntu both shards, macOS shard 2/2, Quality Node 26 and Node 22 compatibility passed. +- macOS shard 1/2 failed tests/flowComposer.test.ts: “Bottom and Top keep their existing + behavior: typing does not change the scrolled view”, Error: posix_spawnp failed. + Stack: node-pty UnixTerminal → ShellSession → InProcessSessionClient → TerminalApp → + flowApp line 88 → test line 163. Cause not investigated at checkpoint. +- Command: npm test -- --shard=1/2 --test-timeout=120000, after npm run build. +- Aggregate CI failed. Verify 26 failed / Verify 22 cancelled at aggregate-success gate; + these wrappers are not independent test failures. +- Timing/Fedora skipped on PR event. +- Older 86fb4e4 had green [38150534791](https://github.com/raiseCatError/notMyShell/actions/runs/38150534791). + Historical green does not establish the current head. +- Remaining checkpoint refs lack final exact-head CI. Neutral checkpoint-only pushes + do not match master/dev push filters; no PR or dispatch was created. + +### Linux/Fish evidence + +Ubuntu 24.04 arm64 / Fish 3.7.0 / zsh 5.9 / Bash 5.2 / Node 26.10.0: +original queue baseline reproduced both Fish reader failures. Fixed input/read/query/ +queue PTY focused cases passed, including detach, interrupt and narrow/NO_COLOR/Safe. + +Full workflow 5e45e87 canonical PASS: runtime 2327 passed, 15 platform/missing-mux skips; +ranking 8 passed. Later 895aae5 full run failed third-shell SIGHUP startup readiness, +while ranking and tmux/screen passed. Controlled 48a0ded correction passed focused and +host canonical; **new full Linux verification remains pending**. + +Earlier 5756f7c failed literal report expectation, missing less/vim, unchanged mise +stat metadata, host installer fixture and unchanged timing threshold. Corrections/ +isolated timing rerun are recorded in the ledger. They are not current-head acceptance. +less/vim and tmux/screen are now installed in the owned VM. + +### Evidence remaining local on T7 + +Under /Volumes/nmsh-t7/verification/: + +- cedar-input-48a0ded6a9eecaca6753300ea33785d05e08f4db-canonical.log +- birch-queue-3adc4515a10e9f7a3af958683b1f389522fc0119-canonical.log +- amber-copy-000736ddc47e78d322d2ba6ff83fd4e205c7ca26-canonical.log +- elm-acceptance-checkpoint-focused.log +- checkpoint-input-ci-failed.log and checkpoint-input-ci-jobs.json +- host-stack-canonical-progress.log +- birch-ownership-lifecycle-navigation.log (34 focused passed) +- birch-queue-gates-focused.log (4 focused passed) +- cedar-session-startup-gate-focused.log (1 focused passed) +- maple-detail-typecheck.log, maple-detail-focused-final.log, maple-detail-fast.log. + +Raw logs, node_modules, selected runner and VM are **local only**. Important commands, +assertions/results and failures are copied here for GitHub/cloud recovery. Fresh +sessions must not depend on local caches or hidden conversation. + +## #356, #359 and shared frontend boundaries + +[#356](https://github.com/raiseCatError/notMyShell/issues/356) remains open. Confirmed +Fish reader ownership and parse transport fixed in checkpoint ancestry. Immediate +input after read/interrupt, fullscreen and privacy acceptance must remain green on +final heads. Current CI failure is macOS PTY spawning, not a Fish failure diagnosis. + +[#359](https://github.com/raiseCatError/notMyShell/issues/359) remains open. +TerminalApp.ts remains exclusively owned by workflow stabilization. **No frontend freeze**. +Native/workspace shared wiring awaits explicit verified #359 freeze; do not integrate +unfinished heads. + +Interfaces to preserve: authenticated Fish reader/terminalOwner; explicit shell ID in +grouping/parse/queue; privacy protection before passthrough/journal I/O; optional validated +journal activity indices; reserved F8/Shift+F8; diagnostics hasValue/renderer condition +metadata and ephemeral detail viewport. Inspect actual code before conflict resolution. + +## Protected native/workspace branches + +Use [PR #385](https://github.com/raiseCatError/notMyShell/pull/385)'s separate handoff. +Protected heads advanced during this checkpoint. These observed heads supersede our +old manifest, not that track's ownership documentation. No edits to protected worktrees/ +branches. Functional provider names in existing branch names are not authorship. + +| PR | Protected published branch | Exact observed head | Base | +| --- | --- | --- | --- | +| #369 | feature/action-controls | 85ab93026e564b4f763e63d63a4326487e118032 | release/v0.18.0 | +| #370 | feature/native-runtimes | 417d105e3f513e36bf35d0efb7c0b79574a31f9b | release/v0.18.0 | +| #372 | feature/runtimes-elixir-dart | 72775e6aea273f71cb2ddf402cdb7952c0740c95 | feature/native-runtimes | +| #373 | feature/project-build-module | 029fac47d0007336d6829e9f7c9aaead62e5670d | feature/runtimes-elixir-dart | +| #374 | feature/vcs-hg-jj | d7d348886d008a7a70ffac3df2fa46daea9cf3e1 | release/v0.18.0 | +| #375 | feature/workspace-foundation | 111af0daf15bce06ea10645b56eab50ca239ccab | release/v0.18.0 | +| #376 | feature/workspace-sidebar | 955d29f292d99494fa0acaa7db65c8eaa6bd807e | feature/workspace-foundation | +| #377 | feature/workspace-panel | f5ef4b2da679f6c56dfd3b3bc5df114f8fd00968 | feature/workspace-foundation | +| #378 | feature/semantic-keymap | 1dc8a24177ed8a2d643c3061d4e88257d4b76b19 | feature/workspace-foundation | +| #379 | feature/portable-mod-gate | fccf68eabc0aaacbd771a3cea9a17d5b13ad66b1 | release/v0.18.0 | +| #380 | feature/codex-app-server-adapter | 8854bb78629acdb1fbaa3e8390b6b9d366724857 | release/v0.18.0 | +| #381 | feature/worktree-pr-evidence | 8a40b76aa3317e85d1525bb6b506e2b6e2470892 | release/v0.18.0 | +| #383 | feature/background-tasks-core | 4b9685d37f6ffffa8a1dba8d8614b1e52d2374ef | release/v0.18.0 | +| #384 | feature/background-task-ui | 85ac4210faf6de804729a8e8b7dc724aa12b9b8e | feature/background-tasks-core | +| #385 | docs/context-workspace-handoff | c9a368663fc334304f6631f664bbdf5e5459dc59 | release/v0.18.0 | + +Protected worktree paths come from git worktree list --porcelain; their checked-out +heads may lag GitHub. Do not update them. PR #385's description calls published #368's +Gemfile parser quadratic; Maple's candidate already contains bounded fix 18e94d9. + +Known retained Ruby/PHP overlap: + +| File | Integration decision | +| --- | --- | +| src/context/capabilities/runtimes.ts | Prefer canonical #370 implementation/security fix, retain regression intent | +| src/context/packs/builtin/nmsh.project.json | Take highest frozen native catalog once | +| tests/contextCapabilities.test.ts | Preserve workflow tests and both malicious whitespace/declaration regressions | +| tests/moduleManager.test.ts | Reconcile catalog expectations, preserve diagnostics | +| docs/design/native-module-coverage.md | Preserve research and update adoption | + +#370 owns install-layout services.ts changes; workflow did not edit those. +Do not revert unrelated #368 diagnostics/imports/docs to remove duplication. +Shared frontend conflicts are resolved only after both freezes. + +## Recommended next development steps (separate authorization) + +1. Audit current heads/issues/ownership, read protected handoff. Use isolated neutral + external worktrees; leave main checkout and others' trees untouched. +2. Focused investigation of current input flowComposer spawn failure and Amber + startup/disposal. Preserve body and cleanup errors; no deadline/security weakening. +3. Review Elm 5447eb9: draft/palette context and restored NO_COLOR. Checkpoint intentionally + incomplete, not release-ready. +4. Complete remaining host exact-head canonical, exact-head Ubuntu/Fish full verification + including installed tmux/screen, source/benchmark typecheck and timing smoke. + Serialize own host tests with own VM stopped; preserve unrelated processes. +5. Propagate fixes additively up original stack and into both siblings independently. + Merge corrected Elm acceptance into Maple deliberately. Verify coherent final heads + then publish to original feature refs; report actual exact-head CI and update PRs. + No force-push/rebase. Timing/Fedora need applicable final verification event. +6. Publish explicit #359 freeze only after workflow gates, with exact heads/interfaces. + Native/workspace track must separately freeze and document final frontend wiring. +7. **After both freezes**, dedicated external integration/nmsh-acceptance-* branch/tree, + based on verified workflow tip. Merge Willow sibling; highest native ancestry once + (currently #373 includes #372/#370); independent #374/#369; highest workspace + foundation ancestry once then independent sidebar/panel/keymap; #379/#380/#381; + highest background UI ancestry once (#384 includes #383). Recheck ancestry and + protected handoff order/contracts at integration time; never merge ancestry twice. + Resolve runtime/shared-frontend conflicts preserving both security fixes. +8. Full cross-feature acceptance on integration branch. Never merge master/release or + tag/publish. Physical Ghostty/Terminal.app QA remains required; workflow-physical-qa.md. + +### External storage / VM recovery + +APFS image lives physically on external ExFAT T7: + +~~~sh +hdiutil info +# Only if owned image is absent: +hdiutil attach -nobrowse -owners off -mountpoint /Volumes/nmsh-t7 \ + "/Volumes/T7 Shield/nmsh-maple-scratch.sparsebundle" +~~~ + +Do not unmount active storage or delete others' files. Owned Lima cedar-fish37 confirmed +**Stopped**. Binary /Volumes/nmsh-t7/maple-linux/bin/bin/limactl; +LIMA_HOME=/Volumes/nmsh-t7/maple-linux/state. Guest temp/cache physically on T7. + +Prepared maple-source-readiness.git and verify-readiness.sh snapshot e942523, **not Elm** +or this doc commit; no new run performed. Refresh an immutable snapshot after changes +rather than resetting a running guest checkout. + +## Deferred / manual + +#366 retains optional supported Atuin/fzf/Television/zoxide/bat/delta paths. Pueue/Yazi +dedicated contracts deferred; native queue remains same-shell sequential. Embedded Gum +rejected; ordinary passthrough retained. Dedicated bat source-preview launching not +implemented. See recorded integration decisions. + +Physical QA outstanding: hidden input/immediate typing, Ctrl+C/Z, alternate-screen, +exact queue approval/detach, clip lifecycle/history pins, host clipboard success/failure, +hostile report/diff, narrow/NO_COLOR/Safe, long output/folding, vocabulary/resume, +theme preview/save and diagnostics scroll. No personal config changes or physical QA +were claimed. + +All implementation and incomplete acceptance source is recoverable from the eight +checkpoint refs. Final verification/CI/freeze/integration remain incomplete. + +## Retained local recovery refs + +These inactive refs have no active owned worktree. Every head below was verified to +be an ancestor of Maple, Willow or Elm, so pushing the active leaves preserves all +these commits on GitHub. The inactive branch names themselves remain local; do not +merge them again. No recovery ref has unique work absent from the published leaves. + +| Local recovery ref | Exact head | Preserved in active leaf ancestry | +| --- | --- | --- | +| fix/workflow-acceptance | 372bd5a4b58b5f010dee438f3c67b54927641b17 | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-acceptance-durable | 7d29d2b7371cf5914018ac96b385a30aa348b79a | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-acceptance-resume | 7d29d2b7371cf5914018ac96b385a30aa348b79a | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-compare-checkpoint | 82726ae9fb4edd171d57b3b2af10c31dc20a55b7 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-compare-resume | 1489b8f03ec8f4a2b9dcc4e8b21a288ab5c9453a | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-copy-checkpoint | 59e77a0050c8585cc1cd1046e978fb841babd946 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-copy-resume | 0af5116c6b4a9917422d9060412c38892a8c712d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-fish-routing | 2d24743c903e5e9e8c5c1afb1f3efca3bbb02f15 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-input-checkpoint | 2d96b25a38806029f5c9ea685bba2bed6c52d89d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-input-resume | 2d96b25a38806029f5c9ea685bba2bed6c52d89d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-queue-resume | 463667209b957f7f6465ff72ad948749ab9fec5f | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-report-checkpoint | 25af5ba6ff06e027c983f777c30d13704ee5e16a | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-report-resume | 7173b46f05953792d06865d91842b40bc7103897 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-vocabulary-checkpoint | d016545942c5558cf41a6be9b15abf9d43b072d8 | fix/workflow-willow | +| fix/workflow-vocabulary-resume | fdf6eb3baa14d4ac7a3f3be444731931fd69f6fb | fix/workflow-willow | diff --git a/docs/testing/v019-physical-qa.md b/docs/testing/v019-physical-qa.md new file mode 100644 index 00000000..52970900 --- /dev/null +++ b/docs/testing/v019-physical-qa.md @@ -0,0 +1,108 @@ +# v0.19 physical QA + +## Status + +- **Not run. v0.19.0 is not published until this pass is recorded.** Every automated gate (canonical local + verification, PR and `workflow_dispatch` CI with Fedora and both timing smokes, Node 22, report/compare/clipboard + security, and the frozen per-feature Linux/Fish 3.7 evidence) is evidence, not acceptance. +- **What does not count:** headless PTY fixtures, synthetic keys sent through tmux, and screenshots of a window driven + that way. Each item below needs a person typing at a real terminal on a real keyboard. +- **Build under test:** the exact `release/v0.19.0` commit named in the release PR. Build it from a clean checkout + (`npm ci && npm run build`) and record `node dist/index.js --version`, which prints the version and commit. +- **Isolation:** run with a throwaway `HOME` (or at least a throwaway `XDG_CONFIG_HOME`) and a temporary working + directory. Do not point it at real provider or account configuration. Every secret below is fake. + +Detailed per-feature steps, including the exact shell commands for hidden reads in each shell, are in +[workflow-physical-qa.md](../development/workflow-physical-qa.md). This page is the release gate: what has to pass, on +which hosts, and where results are recorded. + +## Results + +Record one row per host and shell actually used. `Result` is Pass, Fail (with the item number) or Not run. + +| Pass | Host and version | OS | Shell and version | Commit (`--version`) | Result | Notes | +|---|---|---|---|---|---|---| +| 1 Core workflow | Ghostty | macOS | zsh | | Not run | | +| 1 Core workflow | Ghostty | macOS | Fish 3.7+ or Bash 4.4+ | | Not run | | +| 1 Core workflow | Terminal.app | macOS | zsh | | Not run | | +| 1 Core workflow | physical Linux terminal | Linux | Fish 3.7 | | Not run | | +| 2 Fullscreen and sessions | Ghostty | macOS | zsh | | Not run | | +| 2 Fullscreen and sessions | Terminal.app | macOS | zsh | | Not run | | +| 3 Presentation | Ghostty | macOS | zsh | | Not run | | +| 3 Presentation | Terminal.app | macOS | zsh | | Not run | | + +**Minimum to publish v0.19.0:** pass 1 in Ghostty and Terminal.app with zsh plus at least one of Bash or Fish on +macOS, pass 1 on a physical Linux terminal with Fish 3.7, and passes 2 and 3 in both macOS hosts. Anything not run is +listed as outstanding in the release notes; it is never implied. + +## Pass 1 — core workflow (normal width, about 120×36) + +### Persistent shell +- [ ] 1.1 Launch. `cd /tmp && export DEMO=1 && f() { echo fn-ok; }` (Fish: `function f; echo fn-ok; end`), then + `pwd; echo $DEMO; f`. Directory, variable and function survive across commands: one real shell, not one per command. +- [ ] 1.2 An ordinary command's output appears raw and complete; the completion row shows its time. + +### Waiting for input +- [ ] 1.3 Hidden read (commands per shell in workflow-physical-qa.md, Input and queue step 1): the waiting state shows + beside the composer; typing `FAKE_ONLY` is not drawn anywhere; only `ANSWER-ACCEPTED` appears. +- [ ] 1.4 A single-key question (`read -k 1` in zsh, `read -n 1` in Bash): one key answers it, with no stray Enter + reaching the next prompt. +- [ ] 1.5 Type a command immediately after a read returns and immediately after Ctrl+C: it reaches the shell once, in + order. In Fish, note any dropped keys against [#356](https://github.com/raiseCatError/notMyShell/issues/356) + rather than counting them as a new failure. + +### Queue: exact once +- [ ] 1.6 Run `sleep 5`; while it runs, type `echo QUEUED-1` Enter and `echo QUEUED-2` Enter. `/queue` lists both. Each + prints exactly once, in order, after `sleep` ends. +- [ ] 1.7 While `read reply` waits, Enter answers the reader; Ctrl+Q instead composes a queue entry that runs after. +- [ ] 1.8 A queued `cd /tmp/x && export Q=1` (create the folder first) is followed by a queued `pwd; echo $Q`: same + shell, same state. +- [ ] 1.9 `false` with an entry queued behind it pauses the queue; resume runs it once. Ctrl+C on a running command + pauses the queue too. An entry marked `always` runs after a plain failure but not after Ctrl+C. +- [ ] 1.10 An entry set to ask first waits for approval; editing it after approval withdraws the approval. + +### Copy and clipboard bytes +- [ ] 1.11 `printf 'a\tb\n indented\nüñí\n'` then `/copy`; `pbpaste | od -c` (Linux: `xclip -o -selection clipboard | + od -c` or `wl-paste | od -c`) shows exactly those bytes: tab, two spaces, UTF-8, trailing newline as printed, and no + `✔ Completed` row. +- [ ] 1.12 `seq 1 400` then fold and `/copy`: all 400 lines are copied. `/copy ui` picker and `/copy 1-2` select what + they say. + +### Report with a planted secret +- [ ] 1.13 `echo 'token=ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE path=/Users/someone/private'` then Copy as Report from + the block's Actions. The review shows the token and private path redacted. Enter copies exactly the reviewed text + (check with `pbpaste`). Esc instead leaves the clipboard as it was. + +### Compare +- [ ] 1.14 Run `printf 'one\ntwo\n'` and then `printf 'one\nthree\n'`; `/compare` shows the changed line. At 140+ + columns it is side by side, below that unified. `r` opens a reviewed report; `c` copies the plain diff (a planted + fake token shows the caution and is **not** redacted, by design). + +### Reuse +- [ ] 1.15 F8 and Shift+F8 walk failed commands and keep the draft. Pin a history command from the palette; `/pins` + stages it without running it. + +## Pass 2 — fullscreen, resize and sessions + +- [ ] 2.1 `vim`, then resize the window several times; quit. The final size is repainted, and the composer comes + back with the cursor in place. Repeat with `less` on a long file, quitting immediately after a resize. +- [ ] 2.2 Run a command, detach (close the window), reattach from a new window with `/resume`: transcript, queue and + the running command's activity phrase are intact; nothing queued runs twice. +- [ ] 2.3 Detach while a hidden read is open and reattach: input stays hidden and answers the original reader. +- [ ] 2.4 Detach with `vim` open and reattach: vim gets the screen at the new window's size at once. + +## Pass 3 — presentation + +- [ ] 3.1 `NO_COLOR=1 nmsh`: NMSh's own rows have no color; a program's own colors (`ls --color=always`, `git diff + --color`) stay as the program wrote them. +- [ ] 3.2 At 40, 80 and 120 columns and a short height (about 15 rows): queue line, waiting indicator, report review + and comparison stay readable and reachable; nothing overlaps the composer. +- [ ] 3.3 Expressive wording is the default (a playful phrase while running, its pair on success); failures and + interrupts read factually. Settings → Live activity → Activity wording → Classic persists across restart. +- [ ] 3.4 FOLLOW and DETACHED scrolling while output streams; native selection with the mouse copies terminal text. + +## After the pass + +Fill the Results table, attach captures of any failure with the exact keys typed, and record the outcome on +[#359](https://github.com/raiseCatError/notMyShell/issues/359). A failure blocks publication until it is fixed on +`release/v0.19.0` and the affected items are rerun. diff --git a/package-lock.json b/package-lock.json index 6d21c2bc..adb1a99d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nmsh", - "version": "0.18.0", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nmsh", - "version": "0.18.0", + "version": "0.19.0", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index d0b1c471..e8a26fd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nmsh", - "version": "0.18.0", + "version": "0.19.0", "description": "A cleaner terminal frontend for zsh with persistent input, autocomplete, semantic highlighting, scrollable history, and better command feedback.", "private": true, "main": "dist/index.js", diff --git a/scripts/demos/sticky-controls.tape b/scripts/demos/sticky-controls.tape new file mode 100644 index 00000000..42c0a643 --- /dev/null +++ b/scripts/demos/sticky-controls.tape @@ -0,0 +1,33 @@ +# Sticky block controls: one long command block at its beginning, middle and end, then the hand-off to the next block. +# The controls ride on NMSh's own sticky header row (its viewport, not the terminal's native scrollback). +Output docs/captures/sticky-controls.gif +# demo-config: {"outputFolding": "never"} +# demo-still: docs/captures/sticky-1-beginning.png 3.4s +# demo-still: docs/captures/sticky-2-middle.png 6.4s +# demo-still: docs/captures/sticky-3-end.png 9.4s +# demo-still: docs/captures/sticky-4-transition.png 11.6s +# demo-still: docs/captures/sticky-5-next-block.png 14.6s + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Type "seq -f 'build step %g of 300' 1 300" +Enter +Sleep 1.5s +Type "seq -f 'test case %g passed' 1 40" +Enter +Sleep 1.5s +Show + +Sleep 0.5s +PageUp 11 +Sleep 2.4s +PageDown 4 +Sleep 2.6s +PageDown 4 +Sleep 2.6s +PageDown 2 +Sleep 3s +PageDown 1 +Sleep 4s 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..c0681d71 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'], }; @@ -24,11 +24,14 @@ const suites = { const runtimeWeights = { "bundledCatalog.test.ts": 5, "compatibilityHarness.test.ts": 28, + "commandQueueLive.test.ts": 68, + "copySelectionLive.test.ts": 13, "configuredCompletion.test.ts": 6, "ctrlZJobControl.test.ts": 12, "detachedOutput.test.ts": 22, "fullChromaQa.test.ts": 5, "hostProfiles.test.ts": 10, + "inputAwarenessLive.test.ts": 59, "interactiveCli.test.ts": 14, "liveHardening.test.ts": 45, "liveStatus.test.ts": 5, @@ -36,11 +39,14 @@ const runtimeWeights = { "nativeCaptureLifecycle.test.ts": 6, "sessionLifecycle.test.ts": 28, "sessionPresets.test.ts": 20, + "pasteBatchLive.test.ts": 21, + "queueConditionsLive.test.ts": 24, "shellAdapters.test.ts": 35, "shellSwitchApp.test.ts": 23, "startupBlocked.test.ts": 29, "startupDiscovery.test.ts": 22, "startupLaunch.test.ts": 8, + "typingAfterRead.test.ts": 17, "themeBridgeLive.test.ts": 12 }; 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..41e8974f 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1,3 +1,4 @@ +import {shellSubmission} from '../shell/submission.js'; import {railNeedsPromptConversion, railPreviewConfiguration} from '../prompt/railLayout.js'; import {prepareRail, paintRailComposition, railCompositionPreview, type PreparedRail} from '../prompt/railComposition.js'; import {routeModule} from '../context/surfaceRouter.js'; @@ -17,13 +18,15 @@ 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 {blockAffordance, blockControlsLayout, blockPaletteItems, stickyControlsWidth, type BlockActionId, type BlockControls} from '../ui/BlockActions.js'; import {paletteItems} from '../ui/CommandPalette.js'; import {createConfigurationPanel, configurationKey, renderConfigurationPanel, type ConfigurationPanel} from '../tools/ConfigurationPanel.js'; import {openSupportedConfiguration} from '../tools/SupportedConfiguration.js'; import {selectOpener} from '../host/desktop.js'; +import {findOutputReferences, referenceFollowUp} from '../output/references.js'; +import {referenceCopyText, referencePaletteItems} from '../ui/ReferenceMenu.js'; import {integrationActivation} from '../tools/Activation.js'; import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; @@ -45,7 +48,14 @@ import {resolveSemanticPalette} from '../appearance/semanticPalette.js'; import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; import {applyThemeBridge, bridgeColorLevel, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelAction, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; -import {renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; +import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; +import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection} from '../clipboard/copySelection.js'; +import {copyPickerKey, createCopyPicker, renderCopyPicker, type CopyPickerState} from '../ui/CopyPicker.js'; +import {DEFAULT_REPORT_OPTIONS, describeFindings, scanSensitive, type ReportFormat} from '../clipboard/report.js'; +import {applyReportEdit, createReportReview, createTextReview, renderReportReview, reportReviewKey, type ReportReviewState} from '../ui/ReportReview.js'; +import {configuredEditor} from '../host/HostActions.js'; +import {comparePanelKey, createComparePanel, renderComparePanel, type ComparePanelState} from '../ui/ComparePanel.js'; +import {buildComparisonReport, copyableDiff, previousRun} from '../output/compare.js'; import {createStripStudio, renderStripStudio, stripStudioKey, type StripStudioState} from '../status/StripStudio.js'; import {configureListKey, renderConfigureList, type ConfigureListState} from '../tools/config/ConfigureList.js'; import {registryFacts, toolConfigEntry} from '../tools/config/registry.js'; @@ -86,7 +96,7 @@ import { SETTINGS_ROWS, } from '../ui/SettingsPanel.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; -import {accessSync, appendFileSync, constants, existsSync, readFileSync, realpathSync, rmSync, statSync} from 'node:fs'; +import {accessSync, appendFileSync, constants, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {shouldProbeGraphics} from '../host/capabilities.js'; import {basename, delimiter, join, resolve as resolvePath} from 'node:path'; import {spawn} from 'node:child_process'; @@ -120,7 +130,7 @@ import {createProviderPanel, handleProviderPanelKey, providerPanelEnterAction, p type ProviderPanelState} from '../providers/ProviderPanel.js'; import {TapActivityObserver} from '../output/TapActivityObserver.js'; import {HistoryViewport, stickyHeaderFor, type StickyHeader, type WrappedRow} from '../output/viewport.js'; -import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, statusStripModules, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, statusStripModules, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, moduleConditionMet, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; import {foldingPreview, handleTranscriptPanelKey, renderTranscriptPanel, type TranscriptPanelState} from '../output/TranscriptPanel.js'; import {tabCompletionAction} from '../input/tabBehavior.js'; import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; @@ -160,7 +170,7 @@ import {KeyDecoder, type Key} from '../terminal/keys.js'; import {promptConfigurationPath} from '../configuration/paths.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import {isSlashInput, parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow, type ParsedSlashCommand} from '../commands/slashCommands.js'; -import {ClipboardUnavailableError, copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; +import {ClipboardUnavailableError, copyFeedback, copyStats, readClipboard, writeClipboard} from '../clipboard/clipboard.js'; import {beginSelection, extendSelection, isRowSelected, selectedText, type TranscriptSelection} from '../output/TranscriptSelection.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {QueryExtractor, ReplyRouter} from '../passthrough/TerminalQueries.js'; @@ -178,6 +188,15 @@ 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 {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; +import {OpenLine, promptShaped} from '../session/openLine.js'; +import {completionWaitFact, directInputPlaceholder, inputActivityRows, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {pauseReason, pausedGlyph, queueGlyph, queueRow} from '../status/queueStatus.js'; +import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; +import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; +import type {QueueNews} from '../session/SessionClient.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'; @@ -227,7 +246,17 @@ import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis} from '../input/pasteGuard.js'; +import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; +import {batchFromCommands, batchReviewKey, batchTexts, createBatchReview, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {QUEUE_LIMIT} from '../session/CommandQueue.js'; +import {addPin, addRecipe, changeItem, keepProblem, loadPins, pinsPath, removeItem, type PinData, type Recipe} from '../pins/PinStore.js'; +import {createPinsPanel, pinsPanelKey, renderPinsPanel, type PinsPanelState} from '../pins/PinsPanel.js'; +import {ClipStore, type Clip} from '../clipboard/ClipStore.js'; +import {clipboardCounter, ClipboardChangeWatch} from '../clipboard/ClipboardChanges.js'; +import {expandSteps, quotedPlaceholder} from '../pins/recipes.js'; +import {splitBatch} from '../input/pasteBatch.js'; +import {verifyCommands} from '../input/pasteBatchCheck.js'; import {WHY_FAILED} from '../ask/failure.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} from '../doctor/DoctorPanel.js'; @@ -338,6 +367,8 @@ const AGENT_DISCOVERY_MS = 15_000; const SHELF_IDLE_MS = 6000; const SUCCESS = lazyForeground(UI_COLORS.success); const ERROR = lazyForeground(UI_COLORS.failure); +/** How long after Ctrl+C or Ctrl+Z typing is taken as type-ahead for the shell prompt rather than queued. */ +const STOP_TYPE_AHEAD_MS = 2000; const clipboardFailure = (error: unknown): string => error instanceof ClipboardUnavailableError ? error.message : 'Clipboard copy failed'; /** Keys that edit or submit the composer; in Flow they bring a scrolled-back view back to it. */ const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'backspace', 'delete', 'deleteWord', @@ -703,7 +734,16 @@ export class TerminalApp { /** Terminal modes the running command has set, for handing the terminal to it mid-command. */ private readonly commandModes = new AlternateScreenTracker(); private settingsPanelState?: SettingsPanelState; - private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number; awaitingExec?: boolean}; + private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number; awaitingExec?: boolean; + /** The decorative phrase pair chosen for this command, once (Expressive activity wording). */ + activity?: ActivityVerbPair; + /** Started from the command queue rather than typed at the prompt. */ + queued?: boolean; + /** When you sent Ctrl+C or Ctrl+Z: for a moment the command is ending at your request, so what you type next is + * for the shell's prompt (type-ahead, as in any terminal), not a queue entry held behind the pause that stop + * causes. Only for STOP_TYPE_AHEAD_MS: a program that survives the signal (a REPL, a remote shell) gets the + * ordinary routing again, so later typing is never sent into it unasked. */ + stoppedAt?: number}; private hoveredLineIndex?: number; /** NMSh-owned transcript selection (plain drag); presentation only. */ private selection?: TranscriptSelection; @@ -719,6 +759,32 @@ 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}; + /** The previous command's phrase pair, so two commands in a row never get the same one. */ + private lastActivityPair?: ActivityVerbPair; + /** This session's command queue as its owner (the session service or in-process client) last reported it. */ + private queueState: QueueState = {entries: []}; + /** Ctrl+Q while a program reads keys directly: the composer takes typing for one queue entry, never the program. */ + private queueCompose = false; + /** The line the running command left the cursor on: a question there is answered before any probe confirms it. */ + private readonly commandLine = new OpenLine(); + /** A queued entry being edited in the composer: Enter saves it in place, Esc cancels. */ + private queueEdit?: {id: number; draft: string}; + private queuePanel?: QueuePanelState; + private pinsPanel?: PinsPanelState; + private readonly clips = new ClipStore(); + private readonly clipboardChangeCounter = clipboardCounter(); + private clipboardChangeWatch?: ClipboardChangeWatch; + private clipboardChangeSuggested = false; /** One pending timeout at a time drives the welcome cat's occasional blink. */ private welcomeBlinkTimer?: () => void; private welcomeBlinkCount = 0; @@ -740,6 +806,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 +842,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 +858,14 @@ 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, stamp.queued); } }); this.session.on('replayed', summary => this.finishReplay(summary)); + this.session.on('inputState', state => this.onInputState(state)); + this.session.on('queueState', (state, news) => this.onQueueState(state, news)); this.session.on('inputRejected', (data, submission) => this.onInputRejected(data, submission)); this.session.on('startup', tail => { this.startupTail = tail; @@ -874,9 +945,13 @@ export class TerminalApp { lostServiceConnection = false; /** Commands the replay completed, for the reattach summary. */ private replayedCompletions = 0; + /** Commands that completed during the current replay: marked incomplete if the replay lost output. */ + private replayedRecords: CompletedCommand[] = []; 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 { @@ -916,11 +991,13 @@ export class TerminalApp { this.presentationStartCwd = journal.startCwd; if (journal.live) { this.streamSeq = Math.max(this.streamSeq, journal.live.seq); + this.lastActivityPair = ACTIVITY_VERBS[journal.live.previousActivityIndex ?? -1]; const running = journal.live.running; if (running) { const {cleared} = this.output.resumeActive(running.command, running.startId, running.outputStartId, mode => this.onActiveModeChange(mode)); this.running = {command: running.command, startedAt: running.startedAt, interrupted: false, cleared, startId: running.startId, cwd: running.cwd, historyAllowed: running.historyAllowed, + activity: ACTIVITY_VERBS[running.activityIndex ?? -1], // A submission checkpoint can precede the very first shell event. // Its replayed readiness prompt must not complete the queued command. awaitingExec: this.streamSeq === 0}; @@ -930,37 +1007,39 @@ 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; if (completed > 0) parts.push(`${completed} command${completed === 1 ? '' : 's'} completed while detached`); this.output.addFrontendInteraction('session', `${parts.join(' · ')}.`, INFO); + // The spool keeps command boundaries but drops output past its limit: any command completed during this replay + // may be missing some, and says so wherever its output is used. + if (summary.truncatedBytes > 0) for (const record of this.replayedRecords) record.outputIncomplete = true; + this.replayedRecords = []; if (summary.truncatedBytes > 0) { this.output.addFrontendInteraction('session', `${formatBytes(summary.truncatedBytes)} of output produced while detached exceeded the retention limit and was not kept.`, ERROR); } // Without a journal the running command is known only from the service. - if (!this.running && attached.running) this.onShellExec(attached.running, attached.runningSince); + if (!this.running && attached.running) this.onShellExec(attached.running, attached.runningSince, undefined, attached.runningQueued); if (!this.startupPending && this.running && (attached.fullscreen !== 0 || shouldPassthrough(this.running.command))) { 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 +1057,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 { @@ -1000,12 +1086,13 @@ export class TerminalApp { * zsh started a command line this frontend did not submit (it began while * detached, or came from type-ahead): give it its own transcript block. */ - private onShellExec(command: string, at = Date.now(), historyAllowed?: number): void { + private onShellExec(command: string, at = Date.now(), historyAllowed?: number, queued?: string): void { this.effects.cancel(); if (this.running) { this.running.awaitingExec = false; this.running.historyAllowed = historyAllowed; - if (!this.startupPending && !this.passthrough && shouldPassthrough(command)) { + this.chooseActivityPair(this.running); + if (!this.replaying && !this.startupPending && !this.passthrough && shouldPassthrough(command)) { this.cancelPresentation(); this.passthrough = true; this.noteForeignScreen(); @@ -1014,10 +1101,22 @@ export class TerminalApp { return; } this.commandModes.reset(); + this.commandLine.reset(); + this.inputState = undefined; + // A queued entry runs under its own text (a multi-line entry reaches the shell wrapped as one block); the session's + // queue says so on the exec itself, so a window that attached later shows it the same way. + if (queued !== undefined) command = queued; 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}; + this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd, historyAllowed, ...(queued !== undefined ? {queued: true} : {})}; + if (!this.replaying && !this.startupPending && !this.passthrough && shouldPassthrough(command)) { + this.cancelPresentation(); + this.passthrough = true; + this.noteForeignScreen(); + this.handOverTerminal(); + } + this.chooseActivityPair(this.running); this.scheduleJournal(); if (!this.replaying) this.render(); } @@ -1032,17 +1131,33 @@ export class TerminalApp { if (!this.sessionId || this.shellEnded) return undefined; const running = this.running; const outputStartId = this.output.activeOutputStartId; + const previousActivityIndex = this.lastActivityPair ? ACTIVITY_VERBS.indexOf(this.lastActivityPair) : -1; + const activityIndex = running?.activity ? ACTIVITY_VERBS.indexOf(running.activity) : -1; return {sessionId: this.sessionId, seq: this.streamSeq, + ...(previousActivityIndex < 0 ? {} : {previousActivityIndex}), ...(running && outputStartId !== undefined ? {running: {command: running.command, startedAt: running.startedAt, - cwd: running.cwd, startId: this.output.activeStartId ?? running.startId, outputStartId, historyAllowed: running.historyAllowed}} : {})}; + cwd: running.cwd, startId: this.output.activeStartId ?? running.startId, outputStartId, historyAllowed: running.historyAllowed, + ...(activityIndex < 0 ? {} : {activityIndex})}} : {})}; + } + + /** 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 +1167,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 +1177,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 +1244,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 +1341,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 => { @@ -1332,6 +1470,50 @@ export class TerminalApp { void this.handleThemeBridgeKey(key, this.themeBridgePanel); return; } + if (this.copyPicker) { + const action = copyPickerKey(this.copyPicker, key); + const report = this.copyPickerReport; + if (action?.kind === 'close') { this.copyPicker = undefined; this.copyPickerReport = undefined; this.returnFromPanel(); } + else if (action?.kind === 'copy' || action?.kind === 'report') { + this.copyPicker = undefined; + this.copyPickerReport = undefined; + this.returnFromPanel(); + if (action.kind === 'report' || report) this.startReport(action.records, report ?? 'markdown'); + else void this.copyRecords(action.records, action.includeStatus); + } + this.render(); + return; + } + if (this.comparePanel) { + const {columns, rows} = this.dimensions(); + const action = comparePanelKey(this.comparePanel, key, columns, rows - 4); + if (action?.kind === 'close') { this.comparePanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'copy' && action.what === 'diff') { + const panel = this.comparePanel; + const diff = copyableDiff(action.comparison); + // The plain diff is copied as is, without the report review: say so when it looks like it holds secrets. + const sensitive = scanSensitive(diff, homedir()); + void this.copyText(diff, 'Copied diff', sensitive.length ? ` · not redacted: ${describeFindings(sensitive)} (r reviews)` : '').then(note => { if (note && this.comparePanel === panel) { panel.note = note; this.render(); } }); + } else if (action?.kind === 'copy') { + // A comparison report is a report: the same review (exact text, wrapped, redaction) before it is copied. + this.comparePanel = undefined; + const comparison = structuredClone(action.comparison); + this.reportReview = createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [comparison.a, comparison.b], + {...DEFAULT_REPORT_OPTIONS, home: homedir()}, Boolean(configuredEditor(process.env))); + } + this.render(); + return; + } + if (this.reportReview) { + const {columns, rows} = this.dimensions(); + const action = reportReviewKey(this.reportReview, key, rows - 4, columns); + const review = this.reportReview; + if (action?.kind === 'close') { this.reportReview = undefined; this.returnFromPanel(); } + else if (action?.kind === 'copy') { this.reportReview = undefined; this.returnFromPanel(); void this.copyReportText(action.text, review.records); } + else if (action?.kind === 'edit') void this.editReport(review); + this.render(); + return; + } if (this.rowPanel) { const action = rowPanelKey(this.rowPanel, key, this.promptConfiguration); if (action?.kind === 'close') { this.rowPanel = undefined; this.returnFromPanel(); } @@ -1542,6 +1724,16 @@ export class TerminalApp { return; } if (this.cursorPanel) { this.handleCursorPanelKey(key, this.cursorPanel); this.render(); return; } + if (this.pinsPanel) { void this.handlePinsKey(key); return; } + if (this.queuePanel) { + const action = queuePanelKey(this.queuePanel, key, this.queueState, this.queueConditionsAvailable()); + if (action?.kind === 'close') { this.queuePanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'change') this.session.queue(action.change); + else if (action?.kind === 'edit') { this.queuePanel = undefined; this.returnFromPanel(); this.beginQueueEdit(action.id); } + else if (action?.kind === 'saveRecipe') { this.queuePanel = undefined; this.openPins({naming: this.queueState.entries.map(entry => entry.text)}); } + this.render(); + return; + } if (this.watchPanel) { const panel = this.watchPanel; const action = watchPanelKey(panel, key, this.watches.active()); @@ -1598,10 +1790,37 @@ export class TerminalApp { if (this.worktreePanel) { void this.handleWorktreeKey(key); return; } if (this.githubPanel) { this.handleGithubKey(key); return; } if (this.agentView) { this.handleAgentViewKey(key); return; } + if (this.batchReview) { + const review = this.batchReview; + const action = batchReviewKey(review, key, this.dimensions().rows - 4); + const done = () => { this.batchReview = undefined; this.pasteReview = undefined; this.pastePreview = undefined; this.selectedSuggestion = 0; }; + if (action?.kind === 'queue') { + // Enter on the reviewed list is the confirmation: exactly these commands, in this order, into this session's own queue. + const texts = batchTexts(review); + if (!this.queueAvailable() || texts.length > QUEUE_LIMIT - this.queueState.entries.length) { + this.output.addFrontendInteraction('/ps', 'The queue cannot take these commands now; nothing was queued.', ERROR); + } else { + for (const text of texts) { + this.session.queue({op: 'add', text}); + this.sessionSubmissions.push({text, slash: false}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + } + this.output.addFrontendInteraction('/ps', `Queued ${texts.length} command${texts.length === 1 ? '' : 's'}. They run one at a time in this shell; /queue shows them.`, INFO); + } + done(); + } else if (action?.kind === 'edit') { this.editor.insertPaste(batchTexts(review).join('\n')); done(); } + else if (action?.kind === 'text') { this.editor.insertPaste(review.source); done(); } + else if (action?.kind === 'back') { this.batchReview = undefined; if (review.title) this.openPins(); } + else if (action?.kind === 'cancel') done(); + this.render(); + return; + } if (this.pasteReview && this.pastePreview) { // The bounded review surface: Enter inserts the original text (nothing runs), Esc returns to the compact preview. const outcome = pasteReviewKey(this.pasteReview, key, this.dimensions().rows - 4); - if (outcome === 'insert') { const text = this.pastePreview.text; this.pasteReview = undefined; this.pastePreview = undefined; this.editor.insertPaste(text); this.selectedSuggestion = 0; } + if (outcome === 'batch') void this.openBatchReview(this.pastePreview.text); + else if (outcome === 'clip') this.keepClip(this.pastePreview.text, 'paste', this.shellCwd); + else if (outcome === 'insert') { const text = this.pastePreview.text; this.pasteReview = undefined; this.pastePreview = undefined; this.editor.insertPaste(text); this.selectedSuggestion = 0; } else if (outcome === 'back') { this.pasteReview = undefined; if (!this.pasteCompactFits()) this.pastePreview = undefined; } else if (outcome === 'cancel') { this.pasteReview = undefined; this.pastePreview = undefined; } this.render(); @@ -1610,7 +1829,9 @@ export class TerminalApp { if (this.pastePreview) { const preview = this.pastePreview; if (key.kind === 'enter') { this.pastePreview = undefined; this.editor.insertPaste(preview.text); this.selectedSuggestion = 0; } - else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis); + else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis, preview.batch); + else if (key.kind === 'text' && key.value.toLowerCase() === 'b' && preview.batch) void this.openBatchReview(preview.text); + else if (key.kind === 'text' && key.value.toLowerCase() === 'c') this.keepClip(preview.text, 'paste', this.shellCwd); else if (key.kind === 'escape' || key.kind === 'interrupt') this.pastePreview = undefined; this.render(); return; @@ -1710,7 +1931,11 @@ export class TerminalApp { // The sticky header paints over the transcript region's first row. const sticky = localVisibleIndex === 0 ? this.stickyHeader(wrapped, viewStart) : undefined; if (sticky) { - // The sticky overlay owns the top row: a click jumps to the block's real header. + // The sticky overlay owns the top row: its controls act on that block; elsewhere a click jumps to its header. + const controls = key.kind === 'mouseClick' ? this.stickyControls(sticky.startId, columns)?.controls : undefined; + const x = key.x ?? 0; + if (controls?.copy && x >= controls.copy.column && x <= controls.copy.end) { void this.copyBlock(sticky.startId); return; } + if (controls && x >= controls.column) { this.openBlockPalette(sticky.startId); this.render(); return; } if (key.kind === 'mouseClick') { this.historyViewport.scrollLines(wrapped.length, outputHeight, sticky.targetIndex - viewStart); this.hoveredLineIndex = undefined; @@ -1724,12 +1949,11 @@ export class TerminalApp { // A press may start a drag selection; a plain click still acts exactly as before. if (key.kind === 'mouseClick' && row && key.y) this.selection = beginSelection(viewStart + localVisibleIndex, key.y); if (row) { - const affordance = blockAffordance(row, columns); - if (!this.running && key.kind === 'mouseClick' && row.lineIndex === this.hoveredLineIndex - && affordance && (key.x ?? 0) >= affordance.column && row.blockStartId !== undefined) { - this.openBlockPalette(row.blockStartId); - this.render(); - return; + const affordance = blockAffordance(row, columns, this.controlOptions(row.blockStartId)); + if (!this.running && key.kind === 'mouseClick' && row.lineIndex === this.hoveredLineIndex && affordance && row.blockStartId !== undefined) { + const x = key.x ?? 0; + if (affordance.copy && x >= affordance.copy.column && x <= affordance.copy.end) { this.selection = undefined; void this.copyBlock(row.blockStartId); return; } + if (x >= affordance.column) { this.selection = undefined; this.openBlockPalette(row.blockStartId); this.render(); return; } } if (key.kind === 'mouseClick' && row.isFoldHint && row.commandIndex !== undefined) { this.output.toggleExpanded(row.commandIndex); @@ -1816,6 +2040,35 @@ 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. + // Ctrl+Q while a program reads keys directly: the composer takes the next command for the queue; the program + // never sees those keys. Esc returns typing to the program. + if (key.kind === 'queueCommand' && this.directInputRequest() && this.queueAvailable()) { + this.queueCompose = true; + this.render(); + return; + } + if (key.kind === 'escape' && this.queueCompose) { + this.queueCompose = false; + this.editor.clear(); + this.render(); + return; + } + if (key.kind === 'escape' && this.queueEdit) { + this.cancelQueueEdit(); + this.render(); + return; + } + 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: this.answersByDefault()}; + } // 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)) { @@ -1830,6 +2083,10 @@ export class TerminalApp { if (key.kind === 'escape') this.clearBlockFocus(); if (FLOW_EDIT_KEYS.has(key.kind) && key.kind !== 'enter') this.clearBlockFocus(); + if (key.kind === 'previousFailure' || key.kind === 'nextFailure') { + if (!this.running) this.goToFailure(key.kind === 'previousFailure' ? 'previous' : 'next'); + return; + } if (key.kind === 'historyDelete' && this.historySearchActive) { const entry = this.historyQuery === this.editor.text.substring(HISTORY_SEARCH.length) ? this.historyResults[this.selectedSuggestion] : undefined; if (entry) void this.historyService.index.delete(entry.id).then(() => { @@ -1860,6 +2117,7 @@ export class TerminalApp { this.clearCorrection(); if (this.running) { this.running.interrupted = true; + this.running.stoppedAt = Date.now(); this.editor.clear(); this.session.interrupt(); } else { @@ -1879,7 +2137,7 @@ export class TerminalApp { // Job control belongs to zsh: forward ^Z so it stops the foreground job. // With no foreground command there is nothing to suspend, so the idle // composer ignores it rather than treating it as text, undo, or exit. - if (this.running) this.session.write('\u001A'); + if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); } return; } if (key.kind === 'selectAll') { @@ -1978,9 +2236,10 @@ export class TerminalApp { // Paste Guard: worth-a-look pastes are previewed first (never changed); ordinary ones insert at once. const analysis = analyzePaste(key.value); if (needsPreview(analysis, this.promptConfiguration.pastePreview)) { - this.pastePreview = {text: key.value, analysis}; + const batch = this.batchAvailable(key.value); + this.pastePreview = {text: key.value, analysis, batch}; // The compact strip never squeezes the composer: when this screen has no room for it, Review is the surface. - if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis); + if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis, batch); this.render(); return; } @@ -2090,29 +2349,9 @@ export class TerminalApp { else if (key.kind === 'deleteLineAfter') this.editor.deleteLineAfter(); else if (key.kind === 'delete') this.editor.delete(); else if (key.kind === 'newline') this.editor.insert('\n'); - else if (key.kind === 'enter') { - if (this.running) { - if (this.editor.text.trim() === '/zsh') { - this.editor.clear(); - this.output.addFrontendInteraction('/zsh', 'Wait for the foreground command to finish or interrupt it, then run /zsh.', INFO); - } else if (this.editor.text.trim() === '/clear') { - this.editor.clear(); - this.output.addFrontendInteraction('/clear', 'Wait for the foreground command to finish before archiving this transcript.', INFO); - } else if (this.editor.text.trim() === '/resume') { - this.editor.clear(); - this.output.addFrontendInteraction('/resume', 'Wait for the foreground command to finish before switching transcripts.', INFO); - } else { - const input = this.editor.text; - this.editor.clear(); - this.session.write(`${input}\r`); - return; - } - this.editor.clear(); - } else { - if (this.preparingCommand) return; - void this.submit(); - } - } + else if (key.kind === 'queueCommand') this.routeComposer('queue'); + else if (key.kind === 'sendToProgram') this.routeComposer('send'); + else if (key.kind === 'enter') this.routeComposer('enter'); } @@ -2380,7 +2619,25 @@ export class TerminalApp { } this.render(); } - else if (slash.kind === 'copy') await this.copyRecent(slash.index); + else if (slash.kind === 'copy') await this.copySlash(command, slash.args); + else if (slash.kind === 'pasteClipboard') await this.pasteClipboard(command); + else if (slash.kind === 'clips') { + if (slash.suggestions === undefined) this.openClips(); + else this.configureClipboardChanges(slash.suggestions); + } + else if (slash.kind === 'pins') { this.panelOrigin = undefined; this.openPins(); } + else if (slash.kind === 'compare') { + if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); + else { + const recent = this.output.recentShellCommands(); + const first = recent[(slash.first ?? 1) - 1]; + const second = slash.second === undefined ? undefined : recent[slash.second - 1]; + if (!first || (slash.second !== undefined && !second)) this.output.addFrontendInteraction(command.trim(), `No completed command output at ${!first ? slash.first ?? 1 : slash.second}.`, ERROR); + else if (second === first) this.output.addFrontendInteraction(command.trim(), 'Choose two different outputs to compare.', ERROR); + else this.openCompare(first, second ?? (slash.first === undefined ? previousRun(first, recent) : undefined)); + } + } + else if (slash.kind === 'copySettings') { this.panelOrigin = undefined; this.rowPanel = createRowPanel('Copy', '/copy and /cp', ['copyMode', 'copyIncludeStatus', 'copyAutoExpand']); } else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); } else if (slash.kind === 'motion') { // The same Motion screen and state as /appearance → Motion. @@ -2468,6 +2725,7 @@ export class TerminalApp { else if (slash.kind === 'llm') this.openUnderstandingPanel(); else if (slash.kind === 'doctor') void this.openDoctor(); else if (slash.kind === 'watch') this.handleWatch(command, slash.op, slash.arguments); + else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); else { @@ -2487,7 +2745,103 @@ export class TerminalApp { private openPalette(): void { if (this.settingsPanelActive) return; const record = this.focusedCommandIndex === undefined ? this.output.recent(1) : this.output.recent(this.focusedCommandIndex + 1); - this.paletteState = createPalette([...paletteItems(), ...(record ? blockPaletteItems(record) : [])]); + const history = this.historySearchActive && this.historyQuery === this.editor.text.substring(HISTORY_SEARCH.length) + ? this.historyResults[this.selectedSuggestion] : undefined; + const historyItems: PaletteItem[] = history ? [{id: `history:pin:${history.id}`, label: 'Pin selected history command', + detail: displaySafe(history.command), category: 'Transcript', action: {kind: 'pinHistory', id: history.id}}, + {id: `history:clip:${history.id}`, label: 'Keep selected history command as clip', detail: displaySafe(history.command), + category: 'Transcript', action: {kind: 'keepHistoryClip', id: history.id}}] : []; + const composerItems: PaletteItem[] = this.editor.text.trim() && !isSlashInput(this.editor.text) + ? [{id: 'clip:keep-composer', label: 'Keep composer draft as clip', detail: 'Keep text in memory without changing the draft or executing it', category: 'Transcript', action: {kind: 'keepComposerClip'}}] : []; + this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record) : [])]); + } + + /** Newest-first indexes of completed commands that did not exit 0 (a nonzero exit or an interrupt is authoritative; output text is not). */ + private failedBlockIndexes(): number[] { + return this.output.view().completed.flatMap((record, index) => record.exitCode !== 0 ? [index] : []); + } + + private failureNavigationItems(): PaletteItem[] { + const failed = this.failedBlockIndexes(); + if (!failed.length) return []; + const count = `${failed.length} failed command${failed.length === 1 ? '' : 's'} in this transcript`; + return [ + {id: 'failure:previous', label: 'Previous failed command', detail: `Older block that exited nonzero or was interrupted · ${count}`, category: 'Transcript', action: {kind: 'failure', direction: 'previous'}}, + {id: 'failure:next', label: 'Next failed command', detail: `Newer block that exited nonzero or was interrupted · ${count}`, category: 'Transcript', action: {kind: 'failure', direction: 'next'}}, + ]; + } + + /** Focus the previous (older) or next (newer) failed block, found by its command record and brought into view. */ + private goToFailure(direction: 'previous' | 'next'): void { + const failed = this.failedBlockIndexes(); + const current = this.focusedCommandIndex; + const target = direction === 'previous' + ? failed.find(index => current === undefined || index > current) + : [...failed].reverse().find(index => current !== undefined && index < current); + if (target === undefined) { + this.output.addFrontendInteraction('/failures', direction === 'previous' ? (failed.length ? 'No older failed command.' : 'No failed command in this transcript.') : (current === undefined ? 'Nothing is focused; use Previous failed command first.' : 'No newer failed command.'), INFO); + return; + } + const record = this.output.view().completed[target]; + this.focusedLineIndex = undefined; + this.focusedActivityId = undefined; + this.focusedCommandIndex = target; + if (record) this.revealBlock(record.startId); + } + + /** Scroll so a block's first row is in view (the same placement find uses for a match). */ + private revealBlock(startId: number): void { + const {columns, rows} = this.dimensions(); + const wrapped = this.output.wrapped(columns); + const row = wrapped.findIndex(item => item.blockStartId === startId); + if (row < 0) return; + const height = this.planFrame(columns, rows).viewportRows; + this.historyViewport.scrollLines(wrapped.length, height, revealStart(row, wrapped.length, height) - this.historyViewport.resolve(wrapped.length, height)); + } + + private openReferences(record: CompletedCommand): void { + const references = findOutputReferences(record); + if (!references.length) { + this.output.addFrontendInteraction('/references', 'No files, links, commits or devices found in this output.', INFO); + return; + } + this.paletteState = createPalette(referencePaletteItems(record.startId, references)); + } + + /** One action on one reference, resolved again from its block: nothing here runs a command. */ + private async runReference(startId: number, refId: string, verb: 'open' | 'copy' | 'stage'): Promise { + const say = (message: string, style = INFO) => { this.output.addFrontendInteraction('/references', message, style); this.render(); }; + const record = this.output.view().completed.find(item => item.startId === startId); + const reference = record ? findOutputReferences(record).find(item => item.id === refId) : undefined; + if (!record || !reference) return say('That output is no longer available.', ERROR); + if (verb === 'copy') { + const text = referenceCopyText(reference); + try { await writeClipboard(text); this.noteClipboard(`Copied ${reference.kind === 'file' ? 'location' : reference.kind} · ${copyStats(text).characters.toLocaleString()} characters`, 'success'); } + catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } + return; + } + if (reference.kind === 'file') { + const resolved = resolveLocation({path: reference.value, ...(reference.line ? {line: reference.line} : {}), ...(reference.column ? {column: reference.column} : {})}, record.historicalContext?.cwd ?? this.shellCwd); + if (!resolved.ok) return say(resolved.reason, ERROR); + const adapter = this.hostActions(); + await this.performHostAction(resolved.kind === 'directory' ? adapter.openDirectory(resolved.location.path) : adapter.openFile(resolved.location), say); + return; + } + if (reference.kind === 'url') { + const opener = selectOpener(); + if (!opener) return say(`No system URL opener is available here. The link is ${reference.value}`); + try { spawn(opener, [reference.value], {detached: true, stdio: 'ignore'}).unref(); say(`Opened ${reference.value}.`); } catch { say(`Couldn't open ${reference.value}.`, ERROR); } + return; + } + const argv = referenceFollowUp(reference); + if (!argv) return say(reference.kind === 'device' ? `${reference.value} is not ready (${reference.state ?? 'unknown state'}), so there is no follow-up for it.` : 'There is nothing to stage for this.'); + // The composer is the person's: staged text never replaces what they have typed. + if (this.editor.text.trim()) return say('The composer has text. Clear it (or run it) first; nothing was staged.'); + const quote = this.shellId === 'fish' ? fishQuote : posixQuote; + this.editor.insert(argv.map(arg => (/^[\w@%+=:,./-]+$/u.test(arg) ? arg : quote(arg))).join(' ')); + this.clearBlockFocus(); + this.historyViewport.latest(); + say('Staged in the composer for you to review. Press Enter to run it.'); } private clearBlockFocus(): void { @@ -2508,10 +2862,19 @@ export class TerminalApp { const index = records.findIndex(item => item.startId === startId); const record = records[index]; if (!record) return; // A clear/restore must never act on stale screen coordinates. - const payload = blockCopyPayload(record, action); - if (payload !== undefined) { - try { await writeClipboard(payload); } - catch (error) { this.output.addFrontendInteraction('/copy', clipboardFailure(error), ERROR); } + const includeStatus = this.promptConfiguration.copy.includeStatus; + if (action === 'copyOutput') await this.copyRecords([record], includeStatus); + else if (action === 'copyReport') this.startReport([record], 'markdown'); + else if (action === 'compare') this.openCompare(record); + else if (action === 'references') this.openReferences(record); + else if (action === 'pin') this.pinCommand(record.command, record.historicalContext?.cwd ?? this.shellCwd); + else if (action === 'clipCommand' || action === 'clipOutput') this.keepClip(action === 'clipCommand' ? record.command : recordCopyText(record, false), + action === 'clipCommand' ? 'command' : 'output', record.historicalContext?.cwd, null); + else if (action === 'copyBoth') await this.copyRecords([record], includeStatus, record.command); + else if (action === 'copyCommand') { + // The command text only: completion status never applies here. + try { await writeClipboard(record.command); this.noteClipboard(`Copied command · ${copyStats(record.command).characters.toLocaleString()} characters`, 'success'); } + catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } } else if (action === 'fold') this.output.toggleExpanded(index); else if (action === 'pager') await this.openBlockInPager(record); else if (action === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } @@ -2534,6 +2897,39 @@ export class TerminalApp { case 'block': await this.runBlockAction(action.startId, action.id); break; + case 'reference': + await this.runReference(action.startId, action.refId, action.verb); + break; + case 'failure': + this.goToFailure(action.direction); + break; + case 'pinHistory': { + const entry = this.historyResults.find(entry => entry.id === action.id); + if (entry) this.pinCommand(entry.command, entry.cwd ?? this.shellCwd); + break; + } + case 'keepComposerClip': + if (!this.running && !isSlashInput(this.editor.text)) this.keepClip(this.editor.text, 'composer', this.shellCwd); + break; + case 'keepHistoryClip': { + const entry = this.historyResults.find(entry => entry.id === action.id); + if (entry) this.keepClip(entry.command, 'history', entry.cwd, entry.session ?? null); + break; + } + case 'clip': { + if (action.verb === 'remove') { this.clips.remove(action.id); this.openClips(); break; } + const clip = this.clips.get(action.id); + if (!clip || this.running) break; + if (this.editor.text.trim()) { this.output.addFrontendInteraction('/clips', 'The composer has text. Clear it before staging a clip.', INFO); break; } + const analysis = analyzePaste(clip.text); + const batch = this.batchAvailable(clip.text); + this.pastePreview = {text: clip.text, analysis, batch}; + this.pasteReview = createPasteReview(clip.text, analysis, batch); + break; + } + case 'clearClips': + this.clips.clear(); this.openClips(); + break; case 'slash': { const slash = parseSlashCommand(action.command); if (slash) await this.runSlash(action.command, slash); @@ -2606,6 +3002,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(); @@ -2633,11 +3031,7 @@ export class TerminalApp { this.noteForeignScreen(); this.handOverTerminal(); } - if (command.includes('\n')) { - this.session.submit(`{ ${command}\n}`); - } else { - this.session.submit(command); - } + this.session.submit(shellSubmission(command, this.shellId)); // After the command is on its way: one sweep acknowledging the submission (never delays it). if (!this.passthrough) this.startSweep('prompt', 'vivid'); this.render(); @@ -2711,22 +3105,152 @@ export class TerminalApp { this.render(); } - private async copyRecent(index: number): Promise { - const command = index === 1 ? '/copy' : `/copy ${index}`; - const record = this.output.recentShell(index); - if (!record) { - this.output.addFrontendInteraction(command, `No completed command output at /copy ${index}`, ERROR); + /** Whether a block's Copy control currently confirms a copy. */ + private controlOptions(startId: number | undefined): {copied: boolean; safe: boolean} { + const copied = this.copiedBlock; + return {copied: Boolean(copied && copied.startId === startId && Date.now() < copied.until), safe: getCurrentGlyphMode() === 'safe'}; + } + + /** Controls in muted text; a confirmed copy in the success color (words carry it under NO_COLOR). */ + private styledControls(controls: BlockControls, width?: number): string { + const text = width === undefined ? controls.suffix : controls.suffix.slice(-width); + return `${SUBTLE}${text.replace(/\[[✓+] Copied\]/u, match => `${SUCCESS}${match}${SUBTLE}`)}${RESET}`; + } + + /** The sticky row's controls for a completed block (none for a running one, or when NMSh is busy with a panel). */ + private stickyControls(startId: number, columns: number): {controls: BlockControls; width: number} | undefined { + if (this.running) return undefined; + if (!this.output.view().completed.some(record => record.startId === startId)) return undefined; + const width = stickyControlsWidth(columns); + const controls = width ? blockControlsLayout(columns - width, columns, this.controlOptions(startId)) : undefined; + return controls ? {controls, width} : undefined; + } + + /** A block's direct Copy: that command's full stored output (folded lines too), with the Copy settings. */ + private async copyBlock(startId: number): Promise { + if (this.running || this.stopped) return; + const record = this.output.view().completed.find(item => item.startId === startId); + if (!record) return; // A clear or restore must never act on stale coordinates. + if (!await this.copyRecords([record], this.promptConfiguration.copy.includeStatus)) return; + // Only after the clipboard accepted it: Copy reads "Copied" for a moment, in place. + this.copiedBlock = {startId, until: Date.now() + 1600}; + if (this.copiedBlockTimer) clearTimeout(this.copiedBlockTimer); + this.copiedBlockTimer = setTimeout(() => { this.copiedBlock = undefined; this.copiedBlockTimer = undefined; if (!this.stopped) this.render(); }, 1600); + this.copiedBlockTimer.unref?.(); + this.render(); + } + + /** + * Copy as Report. A report is made to be shared, and pattern-based redaction cannot promise to catch everything, + * so every report goes through its review (Enter copies): it never reaches the clipboard unseen. Oldest first. + */ + private startReport(records: readonly CompletedCommand[], format: ReportFormat): void { + this.panelOrigin = undefined; + this.reportReview = createReportReview(records, {...DEFAULT_REPORT_OPTIONS, format, home: homedir()}, Boolean(configuredEditor(process.env))); + this.render(); + } + + /** The report's clipboard write: the same tool, feedback and Auto-expand rule as every other copy. */ + private async copyReportText(text: string, records: readonly CompletedCommand[]): Promise { + try { await writeClipboard(text); } + catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); return; } + if (this.promptConfiguration.copy.autoExpand) this.output.expandBlocks(records.filter(record => record.output.trim()).map(record => record.startId)); + const stats = copyStats(text); + this.noteClipboard(`Copied report · ${records.length} command${records.length === 1 ? '' : 's'} · ${stats.characters.toLocaleString()} characters`, 'success'); + } + + /** e in the report review: the text in your editor (a private temporary file), back into the review when it exits. */ + private async editReport(review: ReportReviewState): Promise { + const editor = configuredEditor(process.env); + if (!editor || !this.hostTerminalFree()) return; + const directory = mkdtempSync(join(tmpdir(), 'nmsh-report-')); + const file = join(directory, review.options.format === 'markdown' ? 'report.md' : 'report.txt'); + try { + writeFileSync(file, review.text, {mode: 0o600}); + const code = await this.withHostTerminal(() => new Promise(done => { + const child = spawn(editor[0]!, [...editor.slice(1), file], {stdio: 'inherit'}); + child.once('error', () => done(null)); + child.once('close', exit => done(exit)); + })); + if (code === 0 && this.reportReview === review) applyReportEdit(review, readFileSync(file, 'utf8')); + } catch { /* The review keeps the text it had. */ } + finally { rmSync(directory, {recursive: true, force: true}); this.render(); } + } + + /** Compare output…: straight to the difference when the other side is known, otherwise choose it (previous run first). */ + private openCompare(base: CompletedCommand, partner?: CompletedCommand): void { + this.panelOrigin = undefined; + this.comparePanel = createComparePanel(base, this.output.recentShellCommands(), partner); + this.render(); + } + + /** A text copy that is not command output (a diff, a comparison report): the same tool and feedback. */ + private async copyText(text: string, what: string, caution = ''): Promise { + try { await writeClipboard(text); } + catch (error) { const note = clipboardFailure(error); this.noteClipboard(note, 'error'); return note; } + const stats = copyStats(text); + const note = `${what} · ${stats.lines.toLocaleString()} line${stats.lines === 1 ? '' : 's'} · ${stats.characters.toLocaleString()} characters${caution}`; + this.noteClipboard(note, 'success'); + return note; + } + + /** /copy and /cp: one parser, the configured default, and the same clipboard write as the picker and block Copy. */ + private async copySlash(command: string, args: string): Promise { + const parsed = parseCopyArgs(args); + if (!parsed.ok) { this.output.addFrontendInteraction(command.trim(), parsed.error, ERROR); return; } + const {selector, status, report} = parsed.request; + const includeStatus = status ?? this.promptConfiguration.copy.includeStatus; + if (selector.kind === 'picker' || (selector.kind === 'default' && this.promptConfiguration.copy.mode === 'picker')) { + this.panelOrigin = undefined; + this.copyPicker = createCopyPicker(this.output.recentShellCommands(), includeStatus); + this.copyPickerReport = report; return; } - const payload = serializeCopyPayload(record); - // Nothing printed: say so and leave the clipboard as it was (NMSh's status row is not output to copy). - if (!payload) { this.output.addFrontendInteraction(command, `Nothing to copy: ${record.command.split('\n')[0]!.slice(0, 80)} printed no output.`, INFO); return; } - try { - await writeClipboard(payload); - this.output.addFrontendInteraction(command, copyFeedback(copyStats(payload), index), INFO); - } catch (error) { - this.output.addFrontendInteraction(command, clipboardFailure(error), ERROR); + const resolved = resolveCopySelection(selector.kind === 'default' ? {kind: 'latest', count: 1} : selector, this.output.recentShellCommands()); + if (!resolved.ok) { this.output.addFrontendInteraction(command.trim(), resolved.error, ERROR); return; } + if (report) { this.startReport(resolved.records, report); return; } + await this.copyRecords(resolved.records, includeStatus); + } + + /** + * The one clipboard write for command output (/copy, /cp, the picker, block Copy and Copy output): the records' + * stored output, oldest first, each followed by its recorded completion status when asked. Nothing to copy leaves + * the clipboard as it was. Feedback is a brief note above the composer, given only after the write succeeded. + */ + private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string): Promise { + const payload = [before ?? '', copySelectionPayload(records, includeStatus)].filter(Boolean).join('\n'); + if (!payload.trim()) { + const what = records.length === 1 ? `${records[0]!.command.split('\n')[0]!.slice(0, 60)} printed no output` : 'the selected commands printed no output'; + this.noteClipboard(`Nothing to copy: ${what}; the clipboard is unchanged`, 'info'); + return false; + } + try { await writeClipboard(payload); } + catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); return false; } + // Folding is presentation and copying reads stored data: only the explicit setting connects them, and only + // for the blocks whose text was copied (a silent block copied for nothing stays as it was). + if (this.promptConfiguration.copy.autoExpand) { + this.output.expandBlocks(records.filter(record => recordCopyText(record, includeStatus)).map(record => record.startId)); } + this.noteClipboard(copiedNote(records.length, copyStats(payload), includeStatus), 'success'); + return true; + } + + /** One transient clipboard note in the notice slot above the composer (never a transcript line). */ + private noteClipboard(text: string, tone: 'success' | 'info' | 'error'): void { + const ms = tone === 'error' ? 6000 : 2600; + this.clipboardNote = {text, tone, until: Date.now() + ms}; + if (this.clipboardNoteTimer) clearTimeout(this.clipboardNoteTimer); + this.clipboardNoteTimer = setTimeout(() => { this.clipboardNote = undefined; this.clipboardNoteTimer = undefined; if (!this.stopped) this.render(); }, ms); + this.clipboardNoteTimer.unref?.(); + this.render(); + } + + private clipboardRows(columns: number): string[] { + const note = this.clipboardNote; + if (!note || this.passthrough || Date.now() > note.until) return []; + const glyph = note.tone === 'success' ? `${SUCCESS}${GLYPHS.success}` : note.tone === 'error' ? `${ERROR}${GLYPHS.failure}` : `${SUBTLE}·`; + // Notes can name a command: drawn as text, never interpreted. + return [truncateAnsi(`${glyph}${RESET} ${SECONDARY}${displaySafe(note.text)}${RESET}`, columns)]; } private async archiveCurrentPresentation(): Promise { @@ -2817,6 +3341,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 +3368,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,16 +3622,22 @@ 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 { this.commandModes.observeModes(data); + if (this.running) this.commandLine.push(data); this.lastOutputTime = Date.now(); const wasPassthrough = this.passthrough; this.output.write(data); 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 +3654,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; @@ -3147,10 +3699,14 @@ export class TerminalApp { const completedAt = new Date(at); const elapsed = completedAt.getTime() - command.startedAt; this.output.setActiveActivities(this.tapActivityObserver.finish(completedAt.getTime())); - const completedRecord = this.output.complete(exitCode); + // Never collapse output under the reader: while scrolled back or selecting, the block finishes expanded. + const completedRecord = this.output.complete(exitCode, {holdOpen: this.historyViewport.detached || Boolean(this.selection)}); + if (this.replaying && completedRecord) this.replayedRecords.push(completedRecord); 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(); @@ -3166,11 +3722,19 @@ export class TerminalApp { const facts = extractFacts(command.command, outputText); const isInterrupted = command.interrupted || exitCode === 130; const displayCompletedAt = presentationCompletionTime(completedAt); - const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); 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); - this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`); + const finish = (pair?: ActivityVerbPair) => { + const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts, pair); + 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); + if (command.queued) parts.main += ' · from queue'; + return parts; + }; + // What is stored (copies, reports, restored records) is always the factual wording; only the drawn row is playful. + const factual = finish(); + this.output.setCompletionLifecycle(`${factual.main}${factual.detail}`); + const parts = agent || failure ? factual : finish(this.activityPairFor(command)); const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS); this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); // The raw shell error stays; a curated command NMSh knows gets a factual follow-up (identity only, no lookup or install). @@ -3178,6 +3742,18 @@ export class TerminalApp { if (known) this.output.addHistoryLine(`${INFO}${known}${RESET}`); } this.running = undefined; + this.inputState = undefined; + this.queueCompose = false; + 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 +3770,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(); @@ -3421,7 +3998,8 @@ export class TerminalApp { /** What /prompt's module manager shows beside the draft: live capability status, packs, recommendations, the Claude Code bridge. */ private modulesContext(): ModulesContext { - return {status: id => this.contextEngine.status(id), packs: this.modulesListings, recommendations: this.moduleRecommendations, + const presentation = this.promptContext(); + return {conditionMet: module => moduleConditionMet(module, presentation), status: id => this.contextEngine.status(id), packs: this.modulesListings, recommendations: this.moduleRecommendations, ...(this.claudeBridgeState ? {claudeBridge: this.claudeBridgeState} : {}), now: Date.now()}; } @@ -3895,8 +4473,8 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState - || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.reportReview || this.comparePanel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } /** Complex panels declare the smallest size that shows their essential controls. */ @@ -3930,7 +4508,11 @@ export class TerminalApp { if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); if (this.stripStudio) return renderStripStudio(this.stripStudio, this.promptConfiguration, columns, this.dimensions().rows, (configuration, width) => this.statusStripRow(width, configuration)); - if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.promptConfiguration.statusStrip.enabled ? [this.statusStripRow(columns - 2)] : [' (Status strip Off)']); + if (this.copyPicker) return renderCopyPicker(this.copyPicker, columns, this.dimensions().rows - 4); + if (this.reportReview) return renderReportReview(this.reportReview, columns, this.dimensions().rows - 4); + if (this.comparePanel) return renderComparePanel(this.comparePanel, columns, this.dimensions().rows - 4); + if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.rowPanel.rowIds.includes('copyMode') ? [] + : this.promptConfiguration.statusStrip.enabled ? [this.statusStripRow(columns - 2)] : [' (Status strip Off)']); if (this.configureList) return renderConfigureList(this.configureList, registryFacts(), columns, this.dimensions().rows); if (this.tmuxPanel) return renderTmuxPanel(this.tmuxPanel, columns, this.dimensions().rows); if (this.dotfiles) return renderDotfilesPanel(this.dotfiles, columns, this.dimensions().rows); @@ -3989,7 +4571,10 @@ export class TerminalApp { const activity = this.askActivityLine(); return framePanel(renderAsk(this.askState, columns, {presentation: this.promptConfiguration.askPresentation, height: this.dimensions().rows - 4, shell: this.shellId, ...(activity ? {activity} : {})}), columns); } + if (this.pinsPanel) return framePanel(renderPinsPanel(this.pinsPanel, columns, this.dimensions().rows - 4), columns); + if (this.batchReview) return framePanel(renderBatchReview(this.batchReview, columns, this.dimensions().rows - 4), columns); if (this.pasteReview) return framePanel(renderPasteReview(this.pasteReview, columns, this.dimensions().rows - 4), columns); + if (this.queuePanel) return framePanel(renderQueuePanel(this.queuePanel, this.queueState, this.running?.command, columns, this.dimensions().rows - 4, this.queueConditionsAvailable()), columns); if (this.watchPanel) return framePanel(renderWatchPanel(this.watchPanel, this.watches.active(), columns, Date.now(), this.dimensions().rows - 4), columns); if (this.doctorPanel) return framePanel(renderDoctorPanel(this.doctorPanel, columns, Date.now(), !this.decorativeMotionAllowed()), columns); if (this.cursorPanel) return framePanel(this.cursorPanelRows(this.cursorPanel, columns, this.cursorEnv(this.cursorPanel.draft, this.promptConfiguration)), columns); @@ -4013,12 +4598,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 +5126,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); } @@ -4771,6 +5359,19 @@ export class TerminalApp { // ---- Status strip, /configure, /tmux, /integrations ------------------------------- private rowPanel?: RowPanelState; + /** /copy as an Interactive Picker (Settings → Copy, or /copy ui). */ + private copyPicker?: CopyPickerState; + /** /copy picks headed for a report (`/copy ui --report`). */ + private copyPickerReport?: ReportFormat; + /** The review before a report is copied. */ + private reportReview?: ReportReviewState; + /** Compare output…: choosing the other block, then the difference. */ + private comparePanel?: ComparePanelState; + private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number}; + private clipboardNoteTimer?: ReturnType; + /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ + private copiedBlock?: {startId: number; until: number}; + private copiedBlockTimer?: ReturnType; private configureList?: ConfigureListState; private tmuxPanel?: TmuxPanelState; /** The include plan shown in the tmux review, applied exactly if confirmed. */ @@ -5470,7 +6071,7 @@ export class TerminalApp { const customStops = editor.gradient.stops.length >= MIN_CUSTOM_STOPS ? [...editor.gradient.stops] : [...PRESET_STOPS.lavender]; return editor.target === 'idle' ? {...config, idleVisuals: {...config.idleVisuals, colorSource: 'custom', customStops}} - : {...config, liveActivity: {colors: 'custom', customStops}}; + : {...config, liveActivity: {...config.liveActivity, colors: 'custom', customStops}}; } /** Shared stop editor keys; Esc (when not typing a hex) saves the stops and returns. */ @@ -6477,10 +7078,192 @@ export class TerminalApp { // The agent shelf shares this chrome slot (and the screen plan's rows): hidden at rest, never a permanent row. const shelf = this.shelfRow(columns); const now = Date.now(); - const rows = [...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)]; + const suggestion = this.clipboardChangeSuggested && !this.running && this.clipboardChangeWatch?.enabled + ? [truncateAnsi(`${SUBTLE}Clipboard changed · /ps reads it for review${RESET}`, columns)] : []; + const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)]; return shelf ? [...rows, shelf] : rows; } + /** The shell grammar this session's shell speaks, for splitting a paste into commands (none for other shells). */ + private batchShell(): BatchShell | undefined { + return this.shellId === 'zsh' || this.shellId === 'bash' || this.shellId === 'fish' ? this.shellId : undefined; + } + + /** A paste can be offered as a queue of commands: the queue is available and the shell's grammar says it splits cleanly. */ + private batchAvailable(text: string): boolean { + const shell = this.batchShell(); + return Boolean(shell && this.queueAvailable() && text.includes('\n') && createBatchReview(text, shell, 1)); + } + + /** + * B: split the paste, have the real shell parse-check every command on its own (it never runs them), and only then + * show the review. A boundary the shell does not accept means the paste stays one block of text. + */ + private async openBatchReview(text: string): Promise { + const shell = this.batchShell(); + const review = shell ? createBatchReview(text, shell, Math.max(0, QUEUE_LIMIT - this.queueState.entries.length)) : undefined; + if (!shell || !review) return; + const check = await verifyCommands(batchTexts(review), shell); + if (this.stopped || this.pastePreview?.text !== text) return; + if (check.ok) this.batchReview = review; + else { + this.output.addFrontendInteraction('/ps', check.reason, INFO); + this.pastePreview = {...this.pastePreview, batch: false}; + if (this.pasteReview) this.pasteReview.batch = false; + } + this.render(); + } + + /** /ps: read the clipboard now, once, and bring it to paste review. Nothing is kept, inserted or run by reading it. */ + private async pasteClipboard(command: string): Promise { + if (this.running) { this.output.addFrontendInteraction(command, 'A command is running: /ps pastes at the prompt. Use your terminal\'s paste to send text to the program.', INFO); return; } + let text: string; + try { text = await this.readClipboardText(); } + catch (error) { + this.output.addFrontendInteraction(command, error instanceof ClipboardUnavailableError ? error.message : `Could not read the clipboard: ${error instanceof Error ? error.message : String(error)}`, ERROR); + return; + } + if (this.stopped) return; + if (!text.trim()) { this.output.addFrontendInteraction(command, 'The clipboard is empty.', INFO); return; } + this.clipboardChangeSuggested = false; + const analysis = analyzePaste(text); + const batch = this.batchAvailable(text); + this.pastePreview = {text, analysis, batch}; + // An explicit /ps always shows the review first, whatever Paste Preview is set to. + this.pasteReview = createPasteReview(text, analysis, batch); + } + + /** Explicit staging only: never reads the clipboard, writes files or submits commands. */ + private keepClip(text: string, source: Clip['source'], cwd?: string, session: string | null | undefined = this.sessionId): void { + const added = this.clips.add(text, {source, cwd, ...(session ? {session} : {})}); + this.output.addFrontendInteraction('/clips', added.ok + ? `Kept clip ${added.clip.id} in memory for this frontend. /clips reviews it before insertion.${added.dropped ? ` Discarded ${added.dropped} oldest clip(s) to stay within the memory limit.` : ''}` + : added.reason, added.ok ? INFO : ERROR); + } + + private openClips(): void { + const clips = this.clips.list(); + if (!clips.length) { this.output.addFrontendInteraction('/clips', 'No clips kept. Use C in Paste Review, a block’s Actions, or Keep composer draft as clip in the palette. Clips disappear when this frontend exits.', INFO); return; } + const items: PaletteItem[] = clips.flatMap(clip => { + const detail = `${clip.source} · ${displaySafe(clip.text.replace(/[\r\n]/gu, ' ')).slice(0, 160)}`; + return [{id: `clip:${clip.id}:review`, label: `Review clip ${clip.id}`, detail, category: 'Transcript', action: {kind: 'clip', id: clip.id, verb: 'review'}}, + {id: `clip:${clip.id}:remove`, label: `Discard clip ${clip.id}`, detail, category: 'Transcript', action: {kind: 'clip', id: clip.id, verb: 'remove'}}]; + }); + items.push({id: 'clips:clear', label: 'Discard all session clips', detail: 'Remove all staged text from this frontend’s memory', category: 'Transcript', action: {kind: 'clearClips'}}); + this.paletteState = createPalette(items); + } + + private configureClipboardChanges(enabled: boolean): void { + if (!enabled) { + this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; + this.output.addFrontendInteraction('/clips', 'Clipboard-change suggestions off for this frontend.', INFO); + return; + } + if (!this.clipboardChangeCounter) { + this.output.addFrontendInteraction('/clips', 'Clipboard-change metadata is unavailable on this platform. /ps still reads text only when requested.', INFO); + return; + } + this.clipboardChangeWatch ??= new ClipboardChangeWatch({counter: this.clipboardChangeCounter, + idle: () => !this.stopped && !this.running && !this.externalPassthrough && !this.startupPending && !this.settingsPanelActive && !this.editor.text, + changed: () => { this.clipboardChangeSuggested = true; this.render(); }, + failed: () => { + this.clipboardChangeSuggested = false; + this.output.addFrontendInteraction('/clips', 'Clipboard-change metadata became unavailable; suggestions disabled.', INFO); this.render(); + }}); + this.clipboardChangeSuggested = false; + this.clipboardChangeWatch.start(); + this.output.addFrontendInteraction('/clips', 'Clipboard-change suggestions on for this frontend. Only a native change counter is read while idle; /ps explicitly reads text. /clips suggestions off stops them.', INFO); + } + + // ---------------------------------------------------------------- pins and recipes + + private openPins(options: {naming?: string[]} = {}): void { + const loaded = loadPins(); + if (!loaded.ok) { this.output.addFrontendInteraction('/pins', loaded.reason, ERROR); return; } + this.pinsPanel = createPinsPanel(loaded.value, this.shellCwd, {composerText: this.editor.text, canEdit: Boolean(configuredEditor(process.env)), ...(options.naming ? {naming: options.naming} : {})}); + } + + /** Pin from a block: kept as written, global until you scope it in /pins; nothing about its output is kept. */ + private pinCommand(command: string, cwd: string): void { + const result = addPin({command, cwd}); + this.output.addFrontendInteraction('/pins', result.ok ? `Pinned. /pins lists what you kept; it is stored in ${pinsPath()}.` : result.reason, result.ok ? INFO : ERROR); + } + + private refreshPins(panel: PinsPanelState, message?: {text: string; error: boolean}): void { + const loaded = loadPins(); + if (loaded.ok) panel.data = loaded.value as PinData; + panel.composerText = this.editor.text; + panel.message = loaded.ok ? message : {text: loaded.reason, error: true}; + } + + private async handlePinsKey(key: Key): Promise { + const panel = this.pinsPanel!; + const action = pinsPanelKey(panel, key); + const done = (result: {ok: true} | {ok: false; reason: string}, text: string) => this.refreshPins(panel, result.ok ? {text, error: false} : {text: result.reason, error: true}); + if (action?.kind === 'close') { this.pinsPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'stage') { + // The composer is the person's: a pin never replaces what they have typed. + if (this.editor.text.trim()) panel.message = {text: 'The composer has text. Clear it (or run it) first; nothing was staged.', error: true}; + else { + if (action.command.includes('\n')) this.editor.insertPaste(action.command); else this.editor.insert(action.command); + this.pinsPanel = undefined; this.returnFromPanel(); + this.output.addFrontendInteraction('/pins', 'Staged in the composer for you to review. Press Enter to run it.', INFO); + } + } else if (action?.kind === 'use') await this.useRecipe(panel, action.recipe, action.values); + else if (action?.kind === 'rename') done(changeItem(action.id, {name: action.name}), 'Renamed.'); + else if (action?.kind === 'scope') done(changeItem(action.id, {scope: action.scope}), action.scope === 'directory' ? 'Kept for this folder and the folders inside it.' : 'Kept for every folder.'); + else if (action?.kind === 'delete') done(removeItem(action.id), 'Removed.'); + else if (action?.kind === 'pinComposer') done(addPin({command: this.editor.text, cwd: this.shellCwd}), 'Pinned what is in the composer. It is not run.'); + else if (action?.kind === 'saveRecipe') { + const quoted = action.steps.map(step => quotedPlaceholder(step)).find(Boolean); + if (quoted) { panel.message = {text: `{{${quoted}}} is inside quotes. Put placeholders outside quotes: NMSh quotes the value for you.`, error: true}; panel.mode = 'name'; panel.naming = {steps: action.steps}; panel.input = action.name; } + else done(addRecipe({name: action.name, steps: action.steps, cwd: this.shellCwd}), `Saved the recipe “${action.name}”. Enter on it asks for its values and shows the commands first.`); + } else if (action?.kind === 'edit') { await this.editRecipe(panel, action.recipe); } + this.render(); + } + + /** Enter on a recipe: its values are quoted into the steps, and the expanded commands are reviewed before anything is queued. */ + private async useRecipe(panel: PinsPanelState, recipe: Recipe, values: Record): Promise { + if (!this.queueAvailable()) { panel.message = {text: 'The command queue is off or unavailable here, so a recipe cannot be queued.', error: true}; return; } + const expanded = expandSteps(recipe.steps, values, this.shellId === 'fish' ? fishQuote : posixQuote); + if (!expanded.ok) { panel.message = {text: expanded.reason, error: true}; return; } + const shell = this.batchShell(); + const check = shell ? await verifyCommands(expanded.steps, shell) : {ok: false as const, reason: 'This shell cannot check recipes.'}; + if (this.pinsPanel !== panel) return; + if (!check.ok) { panel.message = {text: check.reason.replace('so the paste was not split', 'so the recipe was not queued'), error: true}; return; } + const where = this.shellCwd; + const note = recipe.scope === 'directory' && recipe.cwd && recipe.cwd !== where ? `Saved for ${recipe.cwd}; this shell is in ${where}.` : `Runs in this shell, from ${where}.`; + this.pinsPanel = undefined; + this.batchReview = batchFromCommands(expanded.steps, Math.max(0, QUEUE_LIMIT - this.queueState.entries.length), {title: `Recipe: ${recipe.name}`, note}); + } + + /** e on a recipe: its commands in your editor (a private temporary file); saved only if they still split into whole commands. */ + private async editRecipe(panel: PinsPanelState, recipe: Recipe): Promise { + const editor = configuredEditor(process.env); + const shell = this.batchShell(); + if (!editor || !shell || !this.hostTerminalFree()) return; + const directory = mkdtempSync(join(tmpdir(), 'nmsh-recipe-')); + const file = join(directory, 'recipe.sh'); + try { + writeFileSync(file, recipe.steps.join('\n'), {mode: 0o600}); + const code = await this.withHostTerminal(() => new Promise(finish => { + const child = spawn(editor[0]!, [...editor.slice(1), file], {stdio: 'inherit'}); + child.once('error', () => finish(null)); + child.once('close', exit => finish(exit)); + })); + if (code !== 0) { this.refreshPins(panel, {text: 'The editor did not finish; the recipe is unchanged.', error: true}); return; } + const split = splitBatch(readFileSync(file, 'utf8'), shell); + if (!split.ok) { this.refreshPins(panel, {text: `Not saved: ${split.reason} The recipe is unchanged.`, error: true}); return; } + const steps = split.commands.map(command => command.text); + const quoted = steps.map(step => quotedPlaceholder(step)).find(Boolean); + const problem = quoted ? `{{${quoted}}} is inside quotes; put placeholders outside quotes.` : steps.map(step => keepProblem(step)).find(Boolean); + if (problem || !steps.length) { this.refreshPins(panel, {text: `Not saved: ${problem ?? 'a recipe needs at least one command.'}`, error: true}); return; } + const saved = changeItem(recipe.id, {steps}); + this.refreshPins(panel, saved.ok ? {text: `Saved ${steps.length} command${steps.length === 1 ? '' : 's'}. A queued run keeps the commands it was reviewed with.`, error: false} : {text: saved.reason, error: true}); + } catch { this.refreshPins(panel, {text: 'The editor could not be started; the recipe is unchanged.', error: true}); } + finally { rmSync(directory, {recursive: true, force: true}); } + } + /** Whether the compact preview fits above the composer on this screen (the same rule notices follow). */ private pasteCompactFits(): boolean { const {columns, rows} = this.dimensions(); @@ -6520,7 +7303,7 @@ export class TerminalApp { if (hidden > 0 && summary.length) summary[summary.length - 1] += `${SUBTLE} · +${hidden} more${RESET}`; for (const item of summary) rows.push(` ${item}`); if (analysis.commands.some(command => ['text', 'unknown'].includes(primaryKind(command.kinds)))) rows.push(`${SUBTLE}${PASTE_EXACT_NOTE}${RESET}`); - rows.push(`${SUBTLE}Enter insert · R review · Esc cancel${RESET}`); + rows.push(`${SUBTLE}Enter insert · R review${preview.batch ? ' · B queue as commands' : ''} · Esc cancel · C keep clip${RESET}`); return rows.map(row => truncateAnsi(row, columns)); } @@ -6560,9 +7343,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 +7354,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 +7371,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. */ @@ -6914,7 +7702,7 @@ export class TerminalApp { nmsh: {configurationLoaded: true, sessionMode: this.sessionMode, ...(serviceReachable !== undefined ? {serviceReachable} : {}), transcriptDirectory: join(nmshConfigDirectory(), 'sessions'), shell: {id: this.shellId, label: shellAdapter(this.shellId).label, ...(resolveCommand(this.shellId) ? {executable: resolveCommand(this.shellId)!} : {}), promptSeen: !this.startupPending}, host: {name: this.host.name, truecolor: colorLevel() === 'truecolor', keyboard: this.host.capabilities.enhancedKeyboard || this.host.capabilities.kittyKeyboard}}, - ...(git ? {git} : {}), ...(root ? {repoRoot: root} : {}), providers: providerRows, + ...(git ? {git} : {}), ...(root ? {repoRoot: root} : {}), providers: providerRows, contextEngine: {...this.contextEngine.stats}, understanding: {mode: configuration.localUnderstanding.mode, ...(model ? {model: {label: model.label, runtime: model.runtime, ...(model.path ? {path: model.path} : {}), owned: Boolean(model.owned)}} : {}), runtimeAvailable, ...(model ? {state: stateLabel(this.understanding.status, configuration.localUnderstanding)} : {})}, agents: this.agents.harnesses().map(item => ({label: item.harness.name, installed: Boolean(item.executable)})), @@ -7286,7 +8074,10 @@ export class TerminalApp { private readonly cursorPresenter = new CursorPresenter(() => resolveCursorSettings(this.promptConfiguration.cursor, {...contextFor(this.promptConfiguration), chrome: UI_COLORS.accent}), () => { if (!this.stopped) this.paintPresentation(Date.now()); }); private caretCause: 'typing' | 'jump' = 'jump'; /** A paste waiting for Insert / Review / Cancel (presentation and classification only; the text is never changed). */ - private pastePreview?: {text: string; analysis: PasteAnalysis}; + private pastePreview?: {text: string; analysis: PasteAnalysis; /** Splits into separate commands that can be queued. */ batch: boolean}; + private batchReview?: BatchReviewState; + /** The one place the clipboard is read (only /ps calls it). */ + private readClipboardText: () => Promise = () => readClipboard(); private pasteReview?: PasteReviewState; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, @@ -8422,7 +9213,7 @@ export class TerminalApp { inputRows: fullInput.allRows.length, suggestions, inspectorRows: this.inspectorRows(columns).length, - running: Boolean(this.running), + running: Boolean(this.running) || Boolean(this.queueLine(80)), detached: this.historyViewport.detached, hasOutput: transcriptRows > 0, composerPosition: this.promptConfiguration.composerPosition, @@ -8498,6 +9289,9 @@ export class TerminalApp { for (const [row, spans] of result.spans) findSpans.set(row, {spans, active: index === this.findState!.active}); }); this.visibleBlocks = wrapped.slice(viewStart, viewStart + outputHeight).map(row => row.blockStartId); + const sticky = this.stickyHeader(wrapped, viewStart); + // While the block's header is scrolled away, its controls ride on the sticky row instead: never both. + const stickyControls = sticky ? this.stickyControls(sticky.startId, columns) : undefined; const visible = wrapped.slice(viewStart, viewStart + outputHeight).map((row, offset) => { if (isRowSelected(this.selection, viewStart + offset)) return `${background(UI_COLORS.selection)}${PRIMARY}${row.plain}${RESET}`; const marked = findSpans.get(viewStart + offset); @@ -8505,11 +9299,13 @@ export class TerminalApp { marked.active ? '' : '\u001b[4m'); const ansi = presenter.decorate(row, row.lineIndex === undefined ? undefined : this.output.lineTypes.get(row.lineIndex), interaction); const focused = this.focusedCommandIndex !== undefined && row.lineIndex === this.output.recent(this.focusedCommandIndex + 1)?.startId; - const controls = !this.running && (focused || row.lineIndex === this.hoveredLineIndex) ? blockAffordance(row, columns) : undefined; - return controls ? `${ansi}${RESET}${controls.suffix}` : ansi; + const controls = !this.running && (focused || row.lineIndex === this.hoveredLineIndex) && !(stickyControls && row.blockStartId === sticky?.startId) + ? blockAffordance(row, columns, this.controlOptions(row.blockStartId)) : undefined; + return controls ? `${ansi}${RESET}${this.styledControls(controls)}` : ansi; }); - const sticky = this.stickyHeader(wrapped, viewStart); - const stickyRow = sticky && this.output.presentSticky(sticky.startId, columns); + const stickyRow = sticky && (stickyControls + ? this.output.presentSticky(sticky.startId, columns, this.styledControls(stickyControls.controls, stickyControls.width), stickyControls.width) + : this.output.presentSticky(sticky.startId, columns)); if (stickyRow && visible.length > 0) visible[0] = stickyRow; const SELECTION_BG = background(UI_COLORS.selection); const sel = this.editor.displaySelection; @@ -8530,8 +9326,15 @@ export class TerminalApp { } } + const direct = this.directInputRequest(); const inputRows = input.rows.map(row => { const prefix = row.prefix.startsWith(GLYPHS.prompt) ? `${ACCENT}${GLYPHS.prompt}${RESET}${row.prefix.slice(GLYPHS.prompt.length)}` : row.prefix; + if (direct) { + const queueHint = this.queueAvailable() ? ' · Ctrl+Q queues a command' : ''; + return row === input.rows[0] ? truncateAnsi(`${prefix}${SUBTLE}${directInputPlaceholder(direct, inputProgram(direct, this.running!.command))}${queueHint}${RESET}`, this.inputColumns(columns)) : ''; + } + const hint = row === input.rows[0] && !this.editor.text ? this.composerHint() : undefined; + if (hint) return truncateAnsi(`${prefix}${SUBTLE}${hint}${RESET}`, this.inputColumns(columns)); let textStyled = ''; const glyphsInRow = graphemes(row.text); for (let i = 0; i < glyphsInRow.length; i++) { @@ -8585,11 +9388,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 +9592,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,18 +9662,262 @@ 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[] { + const queue = this.queueLine(columns); + if (!this.running) return queue ? [queue, ''] : []; + const request = this.inputState?.request; + // A wait reads top to bottom (headline, then the question) wherever the composer is docked. + if (request) { + const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); + const count = this.queueAvailable() ? this.queueState.entries.length : 0; + // The question keeps both rows; the queue is a count at the end of the headline, when it fits. + const tail = count ? `${SUBTLE} · ${queueGlyph()} ${count} queued${RESET}` : ''; + if (tail && rows[0] !== undefined && displayWidth(stripAnsi(rows[0])) + displayWidth(stripAnsi(tail)) <= columns) rows[0] += tail; + return rows.map(row => truncateAnsi(row, columns)); + } + const activity = truncateAnsi(this.currentActivity(), columns); + // The queue reads after what runs now, in the row the region already reserves. + if (queue) return [activity, queue]; + return plan.composerPosition === 'top' ? ['', activity] : [activity, '']; + } + + /** While a command runs, the empty composer says what Enter will do with what you type. */ + private composerHint(): string | undefined { + if (!this.running || this.passthrough || this.queueEdit || !this.queueAvailable() || this.justStopped()) return undefined; + // The program's question keeps the activity rows, so the composer itself says how compose mode ends. + if (this.queueCompose) return 'Queue a command · Enter adds it · Esc returns to the program'; + const request = this.inputState?.request; + const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; + return this.answersByDefault() ? `Enter answers ${program} · Ctrl+Q queues a command` : `Enter queues the next command · Ctrl+S sends to ${program}`; + } + + /** Ctrl+C or Ctrl+Z was sent moments ago: typing is type-ahead for the prompt the stop brings back. */ + private justStopped(): boolean { + const at = this.running?.stoppedAt; + return at !== undefined && Date.now() - at < STOP_TYPE_AHEAD_MS; + } + + /** + * Enter goes to the running program: it is known to wait for input, or it left a question open on its line + * ("Password: ", "Continue? [y/N] ") that no probe has looked at yet. A reply typed at a fresh prompt (a password + * most of all) must never become a queued shell command, so the open question wins until it is answered. + */ + private answersByDefault(): boolean { + return Boolean(this.inputState?.request) || (this.commandLine.open && promptShaped(this.commandLine.line)); + } + + /** Queueing works when the setting is on and whatever owns the shell runs a queue (an older service does not). */ + /** The session's service understands per-entry conditions and approval (older ones would ignore them, so they are never offered). */ + private queueConditionsAvailable(): boolean { + return this.queueAvailable() && this.session.features.has('queue-conditions'); + } + + private queueAvailable(): boolean { + return this.promptConfiguration.commandQueue !== false && this.session.features.has('queue'); + } + + /** The queue's line: what is queued and what runs next, or why it waits; an edit in progress says how to finish. */ + private queueLine(columns: number): string | undefined { + if (!this.queueAvailable()) return undefined; + if (this.queueEdit) return truncateAnsi(`${SECONDARY}${queueGlyph()} Editing a queued command${RESET}${SUBTLE} · Enter saves · Esc cancels${RESET}`, columns); + return queueRow(this.queueState, columns); + } + + private onQueueState(state: QueueState, news: QueueNews): void { + const previous = this.queueState; + this.queueState = state; + if (news.refused) this.output.addFrontendInteraction('/queue', news.refused, INFO); + if (news.dropped) this.output.addHistoryLine(`${INFO}${news.dropped} queued command${news.dropped === 1 ? '' : 's'} did not run: the shell ended.${RESET}`); + const paused = state.paused; + // A pause NMSh decided (not the person's own) is a safety event: say it where the failure is. + if (paused && paused.reason !== 'user' && paused.at !== previous.paused?.at && state.entries.length) { + this.output.addHistoryLine(`${STOPPED}${pausedGlyph()} Queue paused: ${pauseReason(paused)}.${RESET}${SECONDARY} ${state.entries.length} waiting · /queue to resume or clear${RESET}`); + } + if (this.queueEdit && !state.entries.some(entry => entry.id === this.queueEdit!.id)) { + this.queueEdit = undefined; + this.output.addFrontendInteraction('/queue', 'That queued command already ran or was removed; your edit is still in the composer.', INFO); + } + this.render(); + } + + /** Add the composer's text to the queue. */ + private enqueue(text: string): void { + if (!this.queueAvailable()) { + this.output.addFrontendInteraction('/queue', this.session.features.has('queue') + ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); + this.render(); + return; + } + this.session.queue({op: 'add', text}); + this.sessionSubmissions.push({text, slash: false}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + this.editor.clear(); + this.composerHistory.reset(); + this.runningDraft = undefined; + this.queueCompose = false; + this.render(); + } + + /** + * Enter, Ctrl+Q (queue) and Ctrl+S (send to the running program). Input ownership comes first: a program that is + * waiting for input (InputWatch's evidence) gets Enter, and only an explicit Ctrl+Q queues then. Otherwise, while + * a command runs, Enter queues; at idle it queues behind pending entries so nothing jumps the order. + */ + private routeComposer(intent: 'enter' | 'queue' | 'send'): void { + // Composing a queue entry (Ctrl+Q during direct input): Enter adds it, and nothing typed here reaches the program. + if (this.queueCompose) { + if (intent === 'send') return; + intent = 'queue'; + } + const text = this.editor.text; + if (this.queueEdit) { + if (intent === 'send') return; + const edit = this.queueEdit; + if (!text.trim()) { this.output.addFrontendInteraction('/queue', 'A queued command cannot be empty; remove it in /queue instead.', INFO); this.render(); return; } + this.queueEdit = undefined; + this.session.queue({op: 'edit', id: edit.id, text}); + this.editor.replaceText(edit.draft); + this.render(); + return; + } + const slash = isSlashInput(text) ? parseSlashCommand(text) : undefined; + if (this.running) { + if (intent !== 'send' && slash && slash.kind !== 'unknown') { + const refused = ({zsh: 'Wait for the foreground command to finish or interrupt it, then run /zsh.', clear: 'Wait for the foreground command to finish before archiving this transcript.', + resume: 'Wait for the foreground command to finish before switching transcripts.'} as Record)[slash.kind === 'shell' && text.trim() === '/zsh' ? 'zsh' : slash.kind]; + if (refused) { this.editor.clear(); this.output.addFrontendInteraction(text.trim(), refused, INFO); this.render(); return; } + void this.submit(); + return; + } + const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); + if (toProgram) { + this.editor.clear(); + this.runningDraft = undefined; + this.session.write(`${text}\r`); + return; + } + if (!text.trim()) return; + this.enqueue(text); + return; + } + if (intent === 'send') return; + if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || (this.queueState.entries.length > 0 && !this.queueState.paused))) { + this.enqueue(text); + return; + } + if (this.preparingCommand) return; + void this.submit(); + } + + /** `/queue` opens the manager; `/queue pause|resume|clear` act at once. */ + private handleQueueCommand(command: string, op: 'open' | 'pause' | 'resume' | 'clear'): void { + if (!this.queueAvailable()) { + this.output.addFrontendInteraction(command, this.session.features.has('queue') + ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); + return; + } + if (op === 'open') { this.panelOrigin = undefined; this.queuePanel = {selected: 0}; return; } + const count = this.queueState.entries.length; + if (op === 'clear') { + this.session.queue({op: 'clear'}); + this.output.addFrontendInteraction(command, count ? `Cleared ${count} queued command${count === 1 ? '' : 's'}; none of them ran.` : 'The queue was already empty.', INFO); + return; + } + this.session.queue({op}); + this.output.addFrontendInteraction(command, op === 'pause' ? `Queue paused${count ? ` with ${count} waiting` : ''}; /queue resume continues it.` : `Queue resumed${count ? `; ${count} will run in order` : ''}.`, INFO); + } + + /** Load a queued entry into the composer to edit it in place; the queue holds at that entry until it is saved. */ + private beginQueueEdit(id: number): void { + const entry = this.queueState.entries.find(item => item.id === id); + if (!entry) return; + this.queueEdit = {id, draft: this.editor.text}; + this.session.queue({op: 'edit-begin', id}); + this.editor.replaceText(entry.text); + } + + private cancelQueueEdit(): void { + const edit = this.queueEdit; + if (!edit) return; + this.queueEdit = undefined; + this.session.queue({op: 'edit-cancel', id: edit.id}); + this.editor.replaceText(edit.draft); + } + + /** 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 && !this.queueCompose && 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.running.stoppedAt = Date.now(); } + this.session.interrupt(); + return; + } + if (key.kind === 'eof') { this.session.endInput(); return; } + const bytes = directKeyBytes(key); + if (bytes) this.session.write(bytes); + } + + /** This command's phrase pair under Expressive wording: chosen once from the command and its start, so it never changes while it runs. */ + private activityPair(): ActivityVerbPair | undefined { + if (!this.running || this.promptConfiguration.liveActivity.style !== 'expressive') return undefined; + return this.chooseActivityPair(this.running); + } + + private chooseActivityPair(command: {command: string; startedAt: number; activity?: ActivityVerbPair}): ActivityVerbPair { + if (!command.activity) command.activity = selectActivityVerb(command.command, command.startedAt, this.lastActivityPair); + this.lastActivityPair = command.activity; + return command.activity; + } + + /** The pair for a command that just finished: the one it showed while running, or the same deterministic choice if it never drew. */ + private activityPairFor(command: {command: string; startedAt: number; activity?: ActivityVerbPair}): ActivityVerbPair | undefined { + if (this.promptConfiguration.liveActivity.style !== 'expressive') return undefined; + return this.chooseActivityPair(command); + } + private currentActivity(): string { if (!this.running) return ''; const elapsed = this.activityAnimationNow - this.running.startedAt; const isActive = (Date.now() - this.lastOutputTime) < 750; const animationElapsed = this.decorativeMotionAllowed() ? presentationAnimationElapsed(elapsed) : 0; - const parts = liveActivityParts(this.running.command, elapsed, animationElapsed); + const parts = liveActivityParts(this.running.command, elapsed, animationElapsed, this.activityPair()); // A soft light sweep over the working phrase: its own colors lifted in place, never moved; faster while output arrives. // Colors come from Live activity colors; the sweep is the same light sweep everywhere. 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 +9959,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), @@ -9035,6 +10079,8 @@ export class TerminalApp { if (this.stopped) return; this.hostTitle.end(); this.stopped = true; + this.clips.clear(); + this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; this.contextEngine.dispose(); if (this.contextRefreshTimer) { clearTimeout(this.contextRefreshTimer); this.contextRefreshTimer = undefined; } if (this.noticeExpiry) { clearTimeout(this.noticeExpiry); this.noticeExpiry = undefined; } @@ -9070,6 +10116,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/appearance/themeImporters.ts b/src/appearance/themeImporters.ts index e2e4356e..6f076ab7 100644 --- a/src/appearance/themeImporters.ts +++ b/src/appearance/themeImporters.ts @@ -401,6 +401,80 @@ export function importOhMyPosh(data: Record, fileName: string, return finish('oh-my-posh', theme, mapping, [...warnings], name); } +// ---- Starship ---------------------------------------------------------------- + +const STARSHIP_ROLE: Record = {directory: 'cwd', git_branch: 'gitBranch', nodejs: 'node', golang: 'go', python: 'python', + docker_context: 'docker', kubernetes: 'kubernetes', package: 'project'}; +const STARSHIP_MODIFIERS = new Set(['bold', 'italic', 'underline', 'dimmed', 'inverted', 'blink', 'hidden', 'strikethrough', 'none', 'prev_fg', 'prev_bg']); + +/** + * Starship config (TOML, already parsed as data). Only static colors are read: hex values in module `style` strings, + * the active `[palettes.*]` table that `palette = "name"` selects, and the `[…](style)` parts of the character + * module's success/error symbols. Format strings, `$variables`, conditions, `[custom.*]` commands, `command`/`when` + * keys, ANSI color names and 0–255 numbers are never evaluated or invented into colors; they become warnings. Which + * modules the file enables is disclosed, not imported: module visibility is chosen in NMSh's own Modules screen. + */ +export function importStarship(data: Record, fileName: string, base: CustomTheme): ImportOutcome { + const warnings = new Set(['Starship modules, format strings and commands are not imported; only static colors become an NMSh Native theme.']); + const palettes = isRecord(data.palettes) ? data.palettes : {}; + const selected = typeof data.palette === 'string' ? data.palette : undefined; + const palette = selected && isRecord(palettes[selected]) ? palettes[selected] as Record : {}; + if (selected && !isRecord(palettes[selected])) warnings.add(`The palette "${sanitizeName(selected)}" is not defined in the file, so its names were not resolved.`); + const unresolved = new Set(); + const color = (style: unknown): string | undefined => { + if (typeof style !== 'string' || style.length > 200) return undefined; + let found: {fg?: string; bg?: string} = {}; + for (const token of style.trim().split(/\s+/u).slice(0, 12)) { + const side = token.startsWith('bg:') ? 'bg' : 'fg'; + const name = token.replace(/^(?:fg|bg):/u, ''); + if (!name || STARSHIP_MODIFIERS.has(name)) continue; + const literal = hex(name) ?? (typeof palette[name] === 'string' ? hex(palette[name]) : undefined); + if (literal) found = {...found, [side]: literal}; + else unresolved.add(sanitizeName(name)); + } + return found.bg ?? found.fg; + }; + const prompt: Partial> = {}; + const mapping: RoleMapping[] = []; + let accent: string | undefined; + const modules: string[] = []; + let custom = 0; + for (const [key, value] of Object.entries(data).slice(0, 200)) { + if (key === 'custom') { custom = isRecord(value) ? Object.keys(value).length : 1; continue; } + if (!isRecord(value)) continue; + if (key !== 'palettes') modules.push(sanitizeName(key)); + const role = STARSHIP_ROLE[key]; + const fill = color(value.style); + if (fill) accent ??= fill; + if (role && fill && !prompt[role]) { prompt[role] = fill; mapping.push({role: ROLE_LABELS[role], from: `[${sanitizeName(key)}] style`}); } + if (key === 'character') { + const symbolColor = (symbol: unknown) => typeof symbol === 'string' ? color(/\]\(([^)]{1,120})\)/u.exec(symbol)?.[1]) : undefined; + const success = symbolColor(value.success_symbol), failure = symbolColor(value.error_symbol); + if (success && !prompt.success) { prompt.success = success; mapping.push({role: 'Success', from: '[character] success_symbol'}); } + if (failure && !prompt.failure) { prompt.failure = failure; mapping.push({role: 'Failure', from: '[character] error_symbol'}); } + } + } + if (custom) warnings.add(`${custom} [custom] module${custom === 1 ? '' : 's'} ignored: their commands are never run.`); + if (unresolved.size) warnings.add(`Colors that are not static hex values (names, ANSI colors, numbers) were not imported: ${[...unresolved].slice(0, 6).join(', ')}${unresolved.size > 6 ? '…' : ''}.`); + if (!Object.keys(prompt).length && !accent) return {errors: ['No static Starship style colors were found to build an NMSh theme from.']}; + const kept = PROMPT_THEME_ROLES.filter(role => !prompt[role]); + if (kept.length) warnings.add(`No Starship source for ${kept.map(role => ROLE_LABELS[role]).join(', ')}; ${kept.length === 1 ? 'it keeps' : 'they keep'} ${base.name} colors.`); + if (modules.length) warnings.add(`The file configures ${modules.length} module${modules.length === 1 ? '' : 's'} (${modules.slice(0, 8).join(', ')}${modules.length > 8 ? ', …' : ''}). Which modules NMSh shows is set in /modules; Starship itself keeps working as a prompt provider.`); + warnings.add('Starship does not define a terminal background; text tiers assume a dark terminal (change it in the editor).'); + const name = sanitizeName(fileStem(fileName).replace(/^starship$/u, 'Starship')) || 'Starship theme'; + const theme: CustomTheme = {...structuredClone(base), name, basedOn: 'Starship import', dark: true, + prompt: {...base.prompt, ...prompt}, ui: {...base.ui, ...(accent ? {accent} : {}), + ...(prompt.success ? {success: prompt.success} : {}), ...(prompt.failure ? {failure: prompt.failure} : {})}}; + delete theme.terminal; + if (accent) mapping.push({role: 'Accent', from: 'first colored module'}); + return finish('starship', theme, mapping, [...warnings], name); +} + +function isStarship(data: unknown): data is Record { + return isRecord(data) && !isRecord(data.colors) && (isRecord(data.palettes) || typeof data.palette === 'string' || typeof data.add_newline === 'boolean' + || Object.keys(STARSHIP_ROLE).some(key => isRecord(data[key])) || isRecord(data.character)); +} + // ---- Dispatcher ------------------------------------------------------------ function fileStem(fileName: string): string { @@ -465,12 +539,13 @@ export function importThemeSource(text: string, fileName: string, defaults: Reco if (isScheme(json) && (want('base16') || want('base24'))) return schemeImport(json, text, defaults, format === 'auto' ? undefined : format); return {errors: ['This JSON is not an NMSh theme, Oh My Posh config, Windows Terminal scheme or Base16/Base24 scheme.']}; } - if (extension === 'toml' || format === 'wezterm' || (format === 'oh-my-posh' && /^\s*\[/mu.test(text))) { + if (extension === 'toml' || format === 'wezterm' || ((format === 'oh-my-posh' || format === 'starship') && /^\s*\[/mu.test(text))) { let data: unknown; try { data = parseToml(text); } catch { return {errors: [looksLikeLua(text, fileName) ? WEZTERM_LUA_GUIDANCE : 'Not valid TOML.']}; } if (isOhMyPosh(data) && want('oh-my-posh')) return importOhMyPosh(data, fileName, base); if (isRecord(data) && isRecord(data.colors) && want('wezterm')) return importWezTermToml(data, fileName, defaults); - return {errors: ['This TOML is not an Oh My Posh config or a WezTerm color scheme.']}; + if (isStarship(data) && want('starship')) return importStarship(data, fileName, base); + return {errors: ['This TOML is not an Oh My Posh config, a Starship config or a WezTerm color scheme.']}; } if (format === 'auto' && looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; // Line-oriented terminal configs, detected by their own color syntax before YAML. @@ -494,5 +569,5 @@ function windowsTerminalPalette(json: Record): TerminalPalette ...(hex(json.cursorColor) ? {cursor: hex(json.cursorColor)!} : {})}; } -export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm']; +export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'starship', 'kitty', 'ghostty', 'iterm2', 'wezterm']; export const importFormatLabel = (format: ImportFormatChoice): string => format === 'auto' ? 'Detect automatically' : THEME_SOURCE_LABELS[format]; diff --git a/src/appearance/themeLibrary.ts b/src/appearance/themeLibrary.ts index 53a0bf17..cd42c21b 100644 --- a/src/appearance/themeLibrary.ts +++ b/src/appearance/themeLibrary.ts @@ -20,10 +20,10 @@ export const THEME_LIBRARY_LIMIT = 64; /** The asset a pre-library `customTheme` migrates into. */ export const LEGACY_THEME_ID = 'legacy-custom'; -export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; +export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'starship', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; export type ThemeSourceKind = typeof THEME_SOURCE_KINDS[number]; export const THEME_SOURCE_LABELS: Record = { - nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', + nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', starship: 'Starship', kitty: 'Kitty', ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', }; diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index e3b4538a..c4c79b7b 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -69,7 +69,7 @@ export const CONCEPTS: readonly Concept[] = [ description: 'Appearance covers the Native theme, vibrance, UI chrome colors and terminal opacity/blur where the terminal supports it.'}, {id: 'themeStudio', label: 'Theme Studio', support: 'actionable', open: '/theme', covers: ['/theme'], aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], - description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, + description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Starship, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, {id: 'uiChrome', label: 'UI chrome', support: 'actionable', open: '/chrome', covers: ['/chrome'], aliases: ['ui chrome', 'chrome', 'frames', 'panel colors', 'tab colors'], description: 'UI chrome is NMSh\'s frames, rules, tabs, selection and accents; it follows the theme or a custom preset. Not Chroma (animated color treatment).'}, @@ -124,6 +124,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'leave', label: 'Leave NMSh for an ordinary shell', support: 'actionable', capability: 'shell.leave', covers: ['/zsh', '/fish', '/bash', '/exit'], aliases: ['leave nmsh', 'exit nmsh', 'quit nmsh', 'ordinary shell', 'plain shell', 'regular shell'], description: '/zsh, /fish and /bash leave NMSh for an ordinary shell (the session waits; `nmsh` returns). /exit uses your default shell.'}, + {id: 'queue', label: 'Command queue', support: 'actionable', open: '/queue', covers: ['/queue'], + aliases: ['command queue', 'queue a command', 'queue commands', 'run after this finishes', 'run next'], + description: 'While a command runs, Enter queues the next command (Ctrl+Q always queues; Ctrl+S sends to the program). Entries run in order in the same shell after each finishes; a failure or Ctrl+C pauses the rest. /queue shows, edits, reorders, pauses, resumes or clears them.'}, {id: 'sessions', label: 'Live sessions', support: 'actionable', capability: 'session.list', open: '/sessions', covers: ['/sessions', '/rename', 'settings:Sessions'], aliases: ['live sessions', 'running sessions', 'detached sessions', 'current sessions', 'open sessions', 'other sessions', 'sessions', 'session', 'other windows', 'startup restore'], description: 'Live sessions are NMSh sessions running right now, attached or detached. /sessions switches to or ends one.'}, @@ -232,9 +235,18 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'glyphs', label: 'Glyph style', support: 'actionable', open: '/glyphs', where: '/glyphs (also Settings → Glyph style)', covers: ['/glyphs'], aliases: ['glyphs', 'glyph style', 'nerd font', 'nerd fonts', 'icons', 'symbols'], description: 'Glyph style picks Nerd Font symbols or safe terminal symbols for icons and prompt shapes.'}, - {id: 'copy', label: 'Copy output', support: 'actionable', open: '/copy', covers: ['/copy'], - aliases: ['copy output', 'copy the output', 'copy last output', 'copy latest output'], - description: '/copy copies the latest command output as plain text; /copy N copies an earlier one.'}, + {id: 'copy', label: 'Copy output', support: 'actionable', open: '/copy', covers: ['/copy', '/cp', 'settings:Copy'], + aliases: ['copy output', 'copy the output', 'copy last output', 'copy latest output', 'copy several outputs', 'copy completion status', 'quick copy', 'copy picker', 'copy as report', 'copy report', 'bug report', 'share output'], + description: '/copy copies the latest command output as plain text (or opens a picker, in Settings → Copy); /copy N copies an earlier one, /copy -N the latest N, /copy 2-4 or /copy 1,3,5 several, oldest first. --status adds each command\'s completion status after its output; --no-status leaves it out. --report (or --report=plain) copies a reviewed Markdown report: command, status, exit code, duration and output, with optional redaction.'}, + {id: 'compare', label: 'Compare output', support: 'actionable', open: '/compare', covers: ['/compare'], + aliases: ['compare output', 'compare outputs', 'diff output', 'compare runs', 'what changed between runs', 'compare with previous run'], + description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'}, + {id: 'paste-batch', label: 'Paste and queue a batch', support: 'actionable', open: '/ps', covers: ['/ps', '/clips'], + aliases: ['paste commands', 'paste batch', 'paste from clipboard', 'queue pasted commands', 'review pasted commands', 'ps command'], + description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch before you queue it; quotes, loops and heredocs stay whole. C deliberately keeps a clip in memory; /clips reviews it before insertion. Clips disappear when this frontend exits. Nothing executes automatically.'}, + {id: 'pins', label: 'Pins and recipes', support: 'actionable', open: '/pins', covers: ['/pins'], + aliases: ['pin command', 'pins', 'recipes', 'save queue as recipe', 'reuse command', 'saved commands'], + description: '/pins lists commands you pinned (Actions → Pin command) and recipes saved from the queue (s in /queue). Enter on a pin puts it in the composer; Enter on a recipe asks for its {{placeholders}}, shows the expanded commands, and queues them only when you confirm. Stored in pins.json; secrets are refused.'}, {id: 'homebrew', label: 'Homebrew packages', support: 'actionable', open: '/tools', covers: [], aliases: ['homebrew', 'brew packages', 'brew', 'formulae', 'casks', 'package manager'], description: 'Ask can inspect Homebrew: installed packages, versions, search, info, dependencies and what is outdated, and install, upgrade or uninstall after showing the exact brew command and your Yes. Ownership is only claimed from Homebrew\'s own evidence.'}, @@ -340,14 +352,14 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, - {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'transcripts']}, - {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, + {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'compare', 'transcripts', 'pins']}, + {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you; /worktrees and /github show its worktrees, pull requests and issues.', concepts: ['worktrees', 'githubWorkspace'], notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers run as NMSh-managed background tasks: a live status row, detected URLs to open, and "stop the dev server" to end them.']}, - {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview']}, + {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview', 'paste-batch']}, {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, diff --git a/src/clipboard/ClipStore.ts b/src/clipboard/ClipStore.ts new file mode 100644 index 00000000..88451844 --- /dev/null +++ b/src/clipboard/ClipStore.ts @@ -0,0 +1,41 @@ +/** Deliberately kept text, owned by this frontend only. Never persisted or executed. */ +export interface Clip { + readonly id: number; + readonly text: string; + readonly source: 'composer' | 'history' | 'command' | 'output' | 'paste'; + readonly cwd?: string; + readonly session?: string; +} + +export const CLIP_LIMIT = 12; +export const CLIP_MAX_BYTES = 1024 * 1024; +type ClipContext = Omit; + +export class ClipStore { + private clips: Clip[] = []; + private nextId = 1; + + list(): readonly Clip[] { return this.clips.map(clip => ({...clip})); } + get(id: number): Clip | undefined { const clip = this.clips.find(clip => clip.id === id); return clip ? {...clip} : undefined; } + remove(id: number): boolean { + const before = this.clips.length; + this.clips = this.clips.filter(clip => clip.id !== id); + return this.clips.length !== before; + } + clear(): void { this.clips = []; } + + add(text: string, context: ClipContext): {ok: true; clip: Clip; dropped: number} | {ok: false; reason: string} { + if (!text.trim()) return {ok: false, reason: 'There is no text to keep.'}; + const bytes = Buffer.byteLength(text, 'utf8'); + if (bytes > CLIP_MAX_BYTES) return {ok: false, reason: 'A clip cannot exceed 1 MiB.'}; + const clip: Clip = {...context, id: this.nextId++, text}; + this.clips.unshift(clip); + let total = this.clips.reduce((sum, item) => sum + Buffer.byteLength(item.text, 'utf8'), 0); + let dropped = 0; + while (this.clips.length > CLIP_LIMIT || total > CLIP_MAX_BYTES) { + total -= Buffer.byteLength(this.clips.pop()!.text, 'utf8'); + dropped++; + } + return {ok: true, clip: {...clip}, dropped}; + } +} diff --git a/src/clipboard/ClipboardChanges.ts b/src/clipboard/ClipboardChanges.ts new file mode 100644 index 00000000..cb5970b7 --- /dev/null +++ b/src/clipboard/ClipboardChanges.ts @@ -0,0 +1,80 @@ +import {execFile} from 'node:child_process'; + +/** Fixed native metadata query: no string, image, type or clipboard item is requested. */ +const COUNTER_SCRIPT = "ObjC.import('AppKit'); $.NSPasteboard.generalPasteboard.changeCount"; + +export function parseClipboardCounter(raw: string): number { + const text = raw.trim(); + const count = Number(text); + if (raw.length > 64 || !/^(?:0|[1-9]\d{0,15})$/u.test(text) || !Number.isSafeInteger(count)) + throw new Error('Clipboard change metadata is unavailable.'); + return count; +} + +/** Conventional Linux clipboard tools expose text, not a reliable change counter. Fail closed there. */ +export function clipboardCounter(platform = process.platform): (() => Promise) | undefined { + if (platform !== 'darwin') return undefined; + return () => new Promise((resolve, reject) => { + execFile('/usr/bin/osascript', ['-l', 'JavaScript', '-e', COUNTER_SCRIPT], {encoding: 'utf8', timeout: 1500, maxBuffer: 64}, (error, stdout) => { + if (error) { reject(new Error('Clipboard change metadata is unavailable.')); return; } + try { resolve(parseClipboardCounter(stdout)); } catch (error) { reject(error); } + }); + }); +} + +interface WatchOptions { + counter: () => Promise; + idle: () => boolean; + changed: () => void; + failed: () => void; + schedule?: (callback: () => void) => () => void; +} + +/** Opt-in, frontend-only suggestions. The only dependency is a metadata counter, never a text reader. */ +export class ClipboardChangeWatch { + private active = false; + private generation = 0; + private previous?: number; + private cancel?: () => void; + + constructor(private readonly options: WatchOptions) {} + get enabled(): boolean { return this.active; } + + start(): void { + this.stop(); + this.active = true; + this.previous = undefined; + void this.poll(this.generation); + } + + stop(): void { + this.active = false; + this.generation++; + this.cancel?.(); this.cancel = undefined; + this.previous = undefined; + } + + private async poll(generation: number): Promise { + if (!this.active || generation !== this.generation) return; + this.cancel = undefined; + try { + if (this.options.idle()) { + const count = await this.options.counter(); + if (!this.active || generation !== this.generation) return; + if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid change counter'); + const changed = this.previous !== undefined && count !== this.previous; + this.previous = count; + if (changed) this.options.changed(); + } + } catch { + if (!this.active || generation !== this.generation) return; + this.stop(); this.options.failed(); return; + } + const schedule = this.options.schedule ?? (callback => { + const timer = setTimeout(callback, 5000); + timer.unref(); + return () => clearTimeout(timer); + }); + this.cancel = schedule(() => { void this.poll(generation); }); + } +} diff --git a/src/clipboard/clipboard.ts b/src/clipboard/clipboard.ts index cfd9996c..889dbbd4 100644 --- a/src/clipboard/clipboard.ts +++ b/src/clipboard/clipboard.ts @@ -88,9 +88,67 @@ export async function writeClipboard(text: string, options: ClipboardEnvironment if (code !== 0) finish(new Error(`${backend.command} exited with code ${code}`)); else { exitedSuccessfully = true; if (inputDone) finish(); } }); - // Writable 'finish' means all input was written successfully; end callbacks - // also run on write errors, potentially before the stream's error event. - child.stdin.once('finish', () => { inputDone = true; if (exitedSuccessfully) finish(); }); - child.stdin.end(text); + // A successful write callback acknowledges the payload. Backend exit can + // destroy stdin before 'finish', even after EOF and all bytes were delivered. + // Write errors reach this callback before the stream's error event. + child.stdin.write(text, error => { + if (error) { finish(new Error(`${backend.command} stdin failed: ${error.message}`)); return; } + inputDone = true; + if (exitedSuccessfully) finish(); + }); + child.stdin.end(); + }); +} + +/** The tool that prints the clipboard: only ever started by an explicit /ps, never in the background. */ +export function selectClipboardReadBackend(options: ClipboardEnvironment = {}): ClipboardBackend | undefined { + const {platform = process.platform, env = process.env, resolve = (name: string) => resolveCommand(name)} = options; + if (platform === 'darwin') return {command: 'pbpaste', args: []}; + if (platform !== 'linux') return undefined; + const candidates: [string, string[]][] = []; + if (env.WAYLAND_DISPLAY) candidates.push(['wl-paste', ['--no-newline']]); + if (env.DISPLAY) candidates.push(['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]); + for (const [name, args] of candidates) { + const command = resolve(name); + if (command) return {command, args}; + } + return undefined; +} + +/** + * The clipboard's text, read once on request. Bounded like a write (1 MiB, 3 s); a tool that fails, hangs or prints + * too much is an error with a reason, and nothing it printed is kept. + */ +export async function readClipboard(options: ClipboardEnvironment = {}): Promise { + const backend = selectClipboardReadBackend(options); + if (!backend) { + const platform = options.platform ?? process.platform; + throw new ClipboardUnavailableError(platform === 'linux' ? 'install wl-paste (Wayland) or xclip/xsel (X11)' : `unsupported platform ${platform}`); + } + const timeoutMs = options.timeoutMs ?? CLIPBOARD_TIMEOUT_MS; + return await new Promise((resolve, reject) => { + const child = spawn(backend.command, backend.args, {detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'ignore']}); + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const killTree = () => { + if (child.pid && process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already ended */ } + } else child.kill('SIGKILL'); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { killTree(); reject(error); } else resolve(Buffer.concat(chunks).toString('utf8')); + }; + const timer = setTimeout(() => finish(new Error(`${backend.command} timed out`)), timeoutMs); + child.once('error', finish); + child.stdout.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > CLIPBOARD_MAX_BYTES) { chunks.length = 0; finish(new ClipboardUnavailableError(`the clipboard holds more than ${CLIPBOARD_MAX_BYTES / 1024 / 1024} MiB`)); return; } + chunks.push(chunk); + }); + child.once('close', code => { if (code !== 0) finish(new Error(`${backend.command} exited with code ${code}`)); else finish(); }); }); } diff --git a/src/clipboard/copySelection.ts b/src/clipboard/copySelection.ts new file mode 100644 index 00000000..76f88e49 --- /dev/null +++ b/src/clipboard/copySelection.ts @@ -0,0 +1,133 @@ +import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {parseReportFlag, type ReportFormat} from './report.js'; + +/** + * Which completed commands /copy takes, by recency (1 is the latest), and whether NMSh's completion status follows + * each one's output. One parser and one payload builder for /copy, the interactive picker and the per-block Copy + * actions, so every path copies the same text for the same records. + * + * - `/copy` the configured default (Quick Copy: the latest output; Interactive Picker: choose) + * - `/copy latest` the latest output, whatever the default + * - `/copy ui` the Interactive Picker, whatever the default + * - `/copy N` the Nth latest output + * - `/copy -N` the latest N outputs + * - `/copy A-B` outputs A through B (either order) + * - `/copy 1,3,5` those outputs; items may be ranges (`1,3-5`) + * - `--status` / `--no-status` override the "Include completion status" setting for this copy only. Flags always + * start with two dashes, so they never read as a negative count. + * - `--report` (Markdown) or `--report=plain`: the selection as a structured report (see report.ts). + * `/cp` is the same command: it reaches this parser with the same arguments. + */ +export const COPY_MAX_INDEX = 999; +/** At most this many outputs in one copy. */ +export const COPY_MAX_RECORDS = 100; + +export type CopySelector = + | {kind: 'default'} + | {kind: 'picker'} + | {kind: 'latest'; count: number} + | {kind: 'indices'; indices: number[]}; + +export interface CopyRequest { + selector: CopySelector; + /** An explicit --status / --no-status; undefined follows the setting. */ + status?: boolean; + /** Copy as a report in this format instead of plain output. */ + report?: ReportFormat; +} + +export type CopyArgs = {ok: true; request: CopyRequest} | {ok: false; error: string}; + +const USAGE = 'Use /copy, /copy latest, /copy ui, /copy N, /copy -N, /copy A-B or /copy 1,3-5, optionally with --status, --no-status or --report[=plain].'; + +/** The text after `/copy`. Nothing is guessed: anything unrecognised is an error that names the usage. */ +export function parseCopyArgs(text: string): CopyArgs { + let status: boolean | undefined; + let report: ReportFormat | undefined; + const selectors: string[] = []; + for (const token of text.trim().split(/\s+/u).filter(Boolean)) { + const format = parseReportFlag(token); + if (format === 'invalid') return {ok: false, error: `${token}: a report is --report (Markdown) or --report=plain.`}; + if (format) { report = format; continue; } + if (token === '--status' || token === '--no-status') { + const value = token === '--status'; + if (status !== undefined && status !== value) return {ok: false, error: 'Use either --status or --no-status, not both.'}; + status = value; + } else if (token.startsWith('--')) return {ok: false, error: `Unknown option ${token}. ${USAGE}`}; + else selectors.push(token); + } + if (selectors.length > 1) return {ok: false, error: `One selection at a time (write lists with commas: /copy 1,3,5). ${USAGE}`}; + if (report && status !== undefined) return {ok: false, error: 'A report always includes each command\'s status; leave out --status and --no-status.'}; + const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {})}}); + const selector = selectors[0]; + if (selector === undefined) return withStatus({kind: 'default'}); + if (selector === 'ui') return withStatus({kind: 'picker'}); + if (selector === 'latest') return withStatus({kind: 'latest', count: 1}); + const latest = /^-([1-9]\d*)$/u.exec(selector); + if (latest) { + const count = Number(latest[1]); + if (count > COPY_MAX_RECORDS) return {ok: false, error: `At most ${COPY_MAX_RECORDS} outputs in one copy.`}; + return withStatus({kind: 'latest', count}); + } + const indices: number[] = []; + for (const item of selector.split(',')) { + const match = /^([1-9]\d*)(?:-([1-9]\d*))?$/u.exec(item); + if (!match) return {ok: false, error: `${selector} is not a selection. ${USAGE}`}; + const from = Number(match[1]); + const to = Number(match[2] ?? match[1]); + if (Math.max(from, to) > COPY_MAX_INDEX) return {ok: false, error: `Outputs are numbered 1 to ${COPY_MAX_INDEX}.`}; + for (let index = Math.min(from, to); index <= Math.max(from, to); index += 1) { + if (!indices.includes(index)) indices.push(index); + if (indices.length > COPY_MAX_RECORDS) return {ok: false, error: `At most ${COPY_MAX_RECORDS} outputs in one copy.`}; + } + } + return withStatus({kind: 'indices', indices}); +} + +export type CopyResolution = + | {ok: true; records: CompletedCommand[]; indices: number[]} + | {ok: false; error: string}; + +/** + * The records a selection names, oldest first (the order they ran). `recent` holds completed shell commands newest + * first. A named output that does not exist refuses the whole copy rather than copying part of what was asked; + * `-N` takes what there is, up to N. + */ +export function resolveCopySelection(selector: Extract, recent: readonly CompletedCommand[]): CopyResolution { + if (!recent.length) return {ok: false, error: 'No completed command output to copy yet.'}; + let indices: number[]; + if (selector.kind === 'latest') indices = Array.from({length: Math.min(selector.count, recent.length)}, (_, offset) => offset + 1); + else { + const missing = selector.indices.filter(index => index > recent.length); + if (missing.length) { + const available = recent.length === 1 ? 'only 1 completed command' : `only ${recent.length} completed commands`; + return {ok: false, error: `No completed command output at ${missing.length === 1 ? missing[0] : missing.join(', ')}: there ${recent.length === 1 ? 'is' : 'are'} ${available}. Nothing was copied.`}; + } + indices = [...selector.indices]; + } + indices.sort((left, right) => right - left); + return {ok: true, records: indices.map(index => recent[index - 1]!), indices}; +} + +/** + * One record's part of the clipboard: its own output (stdout and stderr as stored, never presentation), then, when + * asked, the completion status NMSh recorded for it when it finished (the same text the transcript shows). An + * empty string means there is nothing of this record to copy. + */ +export function recordCopyText(record: CompletedCommand, includeStatus: boolean): string { + const status = includeStatus ? record.lifecycleText.trim() : ''; + // A trailing newline in the output would leave a blank line before the status; the output itself is unchanged. + const output = status ? record.output.replace(/\r?\n$/u, '') : record.output; + return [output, status].filter(Boolean).join('\n'); +} + +/** The clipboard text for records in the order given: each record's output, followed by its own status when included. */ +export function copySelectionPayload(records: readonly CompletedCommand[], includeStatus: boolean): string { + return records.map(record => recordCopyText(record, includeStatus)).filter(Boolean).join('\n'); +} + +/** The brief note after a successful copy: what was copied and how much (one output keeps the plain wording). */ +export function copiedNote(count: number, stats: {characters: number; lines: number}, includeStatus: boolean): string { + const what = count === 1 ? 'Copied' : `Copied ${count} outputs`; + return `${what}${includeStatus ? ' with completion status' : ''} · ${stats.characters.toLocaleString()} character${stats.characters === 1 ? '' : 's'} · ${stats.lines.toLocaleString()} line${stats.lines === 1 ? '' : 's'}`; +} diff --git a/src/clipboard/report.ts b/src/clipboard/report.ts new file mode 100644 index 00000000..0bbac690 --- /dev/null +++ b/src/clipboard/report.ts @@ -0,0 +1,243 @@ +import type {CompletedCommand} from '../output/OutputBuffer.js'; + +/** + * Copy as Report: one or several command blocks as a structured record to paste into an issue, a chat with an AI + * assistant or a bug report. Built only from the command records (command, stored output, exit code, recorded + * status, duration, start, directory), never from the rendered screen. Nothing here sends anything anywhere. + * + * Redaction is pattern-based and offered, never promised: it catches common token, key and credential shapes and + * private paths, says what it changed, and the review lets the person read (and edit) the result before copying. + */ +export type ReportFormat = 'markdown' | 'plain'; + +export interface ReportOptions { + format: ReportFormat; + /** Include each command's working directory (when recorded). */ + directory: boolean; + /** Include when each command started. */ + timestamps: boolean; + /** Replace what looks like secrets and private paths. */ + redact: boolean; + /** The home directory to show as ~ (and to recognise as private). */ + home?: string; +} + +export const DEFAULT_REPORT_OPTIONS: ReportOptions = {format: 'markdown', directory: false, timestamps: false, redact: true}; + +export type FindingKind = 'private key' | 'token' | 'credential' | 'private path' | 'email address'; + +export interface ReportFinding { kind: FindingKind; count: number } + +interface Pattern { kind: FindingKind; pattern: RegExp; replace: (match: string, ...groups: string[]) => string } + +const REDACTED = '[REDACTED]'; + +/** Common shapes, most specific first. Each replaces only the secret part and keeps what makes the line readable. */ +const PATTERNS: readonly Pattern[] = [ + {kind: 'private key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/gu, replace: () => '[REDACTED PRIVATE KEY]'}, + {kind: 'credential', pattern: /\b((?:set-)?cookie\s*:\s*)[^\n]+/giu, replace: (_match, prefix) => `${prefix}${REDACTED}`}, + {kind: 'credential', pattern: /(--?(?:password|passwd|pass|token|secret|api-?key|access-?key|auth-?token|client-?secret)(?:=|\s+))(?!-)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/giu, replace: (match, prefix, value) => /^["']?\[REDACTED/u.test(value) ? match : `${prefix}${REDACTED}`}, + {kind: 'credential', pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@/]+@/giu, replace: (_match, prefix) => `${prefix}${REDACTED}@`}, + {kind: 'credential', pattern: /\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)[^\s"']+/giu, replace: (_match, prefix) => `${prefix}${REDACTED}`}, + {kind: 'token', pattern: /(? REDACTED}, + {kind: 'token', pattern: /(? REDACTED}, + {kind: 'credential', pattern: /\b((?:[A-Za-z0-9_.-]*?(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth))["']?\s*[=:]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;]+)/giu, + replace: (match, prefix, value) => /^["']?\[REDACTED/u.test(value) || !value.replace(/["']/gu, '') ? match : `${prefix}${REDACTED}`}, + {kind: 'email address', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gu, replace: () => '[REDACTED EMAIL]'}, +]; + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + +/** Private paths: the home directory becomes ~, other people's home directories lose their user name. */ +function pathPatterns(home: string | undefined): Pattern[] { + const patterns: Pattern[] = []; + if (home && home.length > 1) patterns.push({kind: 'private path', pattern: new RegExp(`${escapeRegExp(home.replace(/\/+$/u, ''))}(?=/|\\b|$)`, 'gu'), replace: () => '~'}); + patterns.push({kind: 'private path', pattern: /(\/(?:Users|home)\/)(?!user\b)[A-Za-z0-9._-]+(?=\/|\b)/gu, replace: (_match, prefix) => `${prefix}`}); + return patterns; +} + +/** What redaction would change, by kind, without changing anything. */ +export function scanSensitive(text: string, home?: string): ReportFinding[] { + return redactText(text, home).findings; +} + +/** + * What the patterns are matched against: each character in compatibility form (NFKC: full-width and other look-alike + * letters become their plain forms) with combining marks dropped, and, for every UTF-16 unit of that skeleton, the + * span of the original text it came from. A secret dressed in look-alikes or decorated with marks is still found, + * and only its own span of the original is replaced: the rest of the report keeps its exact characters. + */ +function skeleton(text: string): {text: string; from: number[]; to: number[]} { + let out = ''; + const from: number[] = []; + const to: number[] = []; + let index = 0; + for (const char of text) { + const start = index; + index += char.length; + if (/\p{M}/u.test(char)) { if (to.length) to[to.length - 1] = index; continue; } + for (const unit of char.normalize('NFKC').replace(/\p{M}/gu, '')) { + for (let at = 0; at < unit.length; at += 1) { from.push(start); to.push(index); } + out += unit; + } + } + return {text: out, from, to}; +} + +export function redactText(text: string, home?: string): {text: string; findings: ReportFinding[]} { + const counts = new Map(); + let result = reportText(text); + for (const {kind, pattern, replace} of [...PATTERNS, ...pathPatterns(home)]) { + const view = skeleton(result); + const edits: Array<{from: number; to: number; text: string}> = []; + for (const match of view.text.matchAll(pattern)) { + const replaced = replace(match[0], ...match.slice(1).filter((group): group is string => typeof group === 'string')); + if (replaced === match[0] || !match[0].length) continue; + edits.push({from: view.from[match.index]!, to: view.to[match.index + match[0].length - 1]!, text: replaced}); + } + for (const edit of edits.reverse()) result = result.slice(0, edit.from) + edit.text + result.slice(edit.to); + if (edits.length) counts.set(kind, (counts.get(kind) ?? 0) + edits.length); + } + return {text: result, findings: [...counts].map(([kind, count]) => ({kind, count}))}; +} + +/** A report document assembled only after its source fragments have been sanitized/redacted. */ +export interface ReportResult {text: string; findings: ReportFinding[]} + +/** Redact data before adding Markdown or plain-report structure; patterns never see authored delimiters. */ +export function createReportContent(options: Pick) { + const counts = new Map(); + return { + text(value: string): string { + if (!options.redact) return reportText(value); + const result = redactText(value, options.home); + for (const finding of result.findings) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + finding.count); + return result.text; + }, + findings(): ReportFinding[] { return [...counts].map(([kind, count]) => ({kind, count})); }, + }; +} + +/** 1-based line numbers of `text` that carry a redaction marker (what the review points the reader at). */ +export function redactedLines(text: string): number[] { + return text.split('\n').flatMap((line, index) => /\[REDACTED[^\]]*\]|(?:^|[\s=:"'(`])~(?=\/|[\s"'`)]|$)|\/(?:Users|home)\//u.test(line) ? [index + 1] : []); +} + +/** "2 tokens and 1 private path" */ +export function describeFindings(findings: readonly ReportFinding[]): string { + const parts = findings.map(({kind, count}) => `${count} ${kind}${count === 1 ? '' : kind.endsWith('s') ? 'es' : 's'}`); + return parts.length <= 1 ? parts.join('') : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`; +} + +function duration(ms: number | undefined): string | undefined { + if (ms === undefined) return undefined; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60_000); + return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`; +} + +function timestamp(at: number | undefined): string | undefined { + if (at === undefined) return undefined; + const date = new Date(at); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +/** A Markdown code fence longer than any backtick run inside the text, so output can never close it early. */ +export function markdownDelimiter(text: string, minimum = 3): string { + let longest = minimum - 1; + for (const match of text.matchAll(/`+/gu)) longest = Math.max(longest, match[0].length); + return '`'.repeat(longest + 1); +} + +/** Control characters (escape sequences, carriage returns) are not text a report should carry. */ +/** + * The text a report may carry: line breaks normalized (including U+2028/U+2029); control characters and every + * default-ignorable code point (zero-width spaces and joiners, bidi controls, soft hyphens, variation selectors, + * fillers) removed. The scanner, the review and the clipboard + * then see the same characters, so a secret cannot hide from redaction behind something the review never shows. + */ +export function reportText(text: string): string { + // Line and paragraph separators are line breaks to the editors and Markdown readers a report is pasted into: they + // become real ones here, so a fence is sized for the lines a reader will see and cannot be closed from inside. + return text.replace(/\r\n?|[\u2028\u2029]/gu, '\n') + .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|\p{Cf}|\p{Default_Ignorable_Code_Point}/gu, ''); +} +const clean = reportText; + +/** + * Untrusted one-line text as Markdown inline code: cleaned like the rest of a report, folded onto one line, and + * delimited by more backticks than any run inside it, so it can neither end the code span nor add structure. + */ +export function inlineCode(text: string): string { + const value = clean(text).replace(/\s*\n\s*/gu, ' ').trim(); + const ticks = markdownDelimiter(value, 1); + const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : ''; + return `${ticks}${pad}${value}${pad}${ticks}`; +} + +interface Fact { label: string; value: string; code?: boolean } + +const oneLine = (text: string) => clean(text).replace(/\s*\n\s*/gu, ' ').trim(); + +function facts(record: CompletedCommand, options: ReportOptions): Fact[] { + const list: Fact[] = []; + const status = oneLine(record.lifecycleText); + // Restored transcripts are untrusted too: status text must not become + // Markdown links, HTML or formatting outside the authored report structure. + if (status) list.push({label: 'Status', value: status, code: true}); + list.push({label: 'Exit code', value: String(record.exitCode)}); + const took = duration(record.durationMs); + if (took) list.push({label: 'Duration', value: took}); + const cwd = record.historicalContext?.cwd; + if (options.directory && cwd) list.push({label: 'Directory', value: oneLine(cwd), code: true}); + const started = options.timestamps ? timestamp(record.startedAt) : undefined; + if (started) list.push({label: 'Started', value: started}); + return list; +} + +/** + * The report for records in the order given (oldest first), and what redaction changed. Markdown uses one section + * per command with its facts and a fenced output block; plain text uses labelled lines and clear separators. + */ +export function buildReport(records: readonly CompletedCommand[], options: ReportOptions): {text: string; findings: ReportFinding[]} { + const count = records.length; + const sections: string[] = []; + const content = createReportContent(options); + if (options.format === 'markdown') { + sections.push(`# Command report\n\n${count} command${count === 1 ? '' : 's'}, oldest first.`); + records.forEach((record, index) => { + const command = content.text(record.command); + const title = command.includes('\n') || command.includes('`') ? `## ${index + 1}. Command\n\n${markdownDelimiter(command)}sh\n${command}\n${markdownDelimiter(command)}` + : `## ${index + 1}. \`${command}\``; + const lines = facts(record, options).map(fact => { + const value = content.text(fact.value); + return `- **${fact.label}:** ${fact.code ? inlineCode(value) : value}`; + }); + const output = content.text(record.output).replace(/\n+$/u, ''); + const body = output ? `${markdownDelimiter(output)}text\n${output}\n${markdownDelimiter(output)}` : '_No output._'; + sections.push(`${title}\n\n${lines.join('\n')}\n\n${body}`); + }); + } else { + sections.push(`Command report · ${count} command${count === 1 ? '' : 's'}, oldest first`); + records.forEach((record, index) => { + const output = content.text(record.output).replace(/\n+$/u, ''); + const lines = [`=== ${index + 1}. $ ${content.text(record.command).split('\n').join('\n ')}`, ...facts(record, options).map(fact => `${fact.label}: ${content.text(fact.value)}`), + '--- output ---', output || '(no output)', '--- end ---']; + sections.push(lines.join('\n')); + }); + } + const text = `${sections.join('\n\n')}\n`; + return {text, findings: content.findings()}; +} + +/** `--report`, `--report=markdown|md|plain|text`: undefined when the token is not a report flag. */ +export function parseReportFlag(token: string): ReportFormat | 'invalid' | undefined { + const match = /^--report(?:=(.*))?$/u.exec(token); + if (!match) return undefined; + const value = (match[1] ?? 'markdown').toLowerCase(); + if (value === 'markdown' || value === 'md') return 'markdown'; + if (value === 'plain' || value === 'text' || value === 'txt') return 'plain'; + return 'invalid'; +} diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 8940689f..c3798ab7 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -36,8 +36,14 @@ const META: Record> = { const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti in owned chrome; /effects stop cancels'}, - {name: '/copy', insertion: '/copy', description: 'Copy latest command output'}, - {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output'}, + {name: '/copy', insertion: '/copy', description: 'Copy latest command output (or choose, per Settings → Copy)'}, + {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status'}, + {name: '/copy settings', insertion: '/copy settings', description: 'Quick Copy or Interactive Picker, and whether copies include completion status'}, + {name: '/compare', insertion: '/compare', description: 'Compare two outputs: /compare (latest with its previous run), /compare N, /compare N M (numbers as /copy)'}, + {name: '/pins', insertion: '/pins', description: 'Commands and recipes you kept: stage a pin, or review and queue a recipe (nothing runs by itself)'}, + {name: '/ps', insertion: '/ps', description: 'Paste from the clipboard, reviewed first: read when you run it, never in the background'}, + {name: '/clips', insertion: '/clips', description: 'Text deliberately kept in memory for this frontend; review before inserting, remove or clear'}, + {name: '/cp', insertion: '/cp', description: 'Same as /copy (every form: /cp 2, /cp -3, /cp ui, /cp latest, --status)'}, {name: '/appearance', insertion: '/appearance', description: 'Configure terminal appearance'}, {name: '/motion', insertion: '/motion', description: 'Motion: context transitions, command launch, completion highlight and effects (same as /appearance → Motion)'}, {name: '/prompt', insertion: '/prompt', description: 'Configure prompt provider and composer layout'}, @@ -102,6 +108,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/nmsh', insertion: '/nmsh raw ', description: 'Raw provider escape: /nmsh raw claude or codex with native CLI args'}, {name: '/guide', insertion: '/guide', description: 'Interactive guide to everything NMSh can do'}, {name: '/rename', insertion: '/rename ', description: 'Name this live session (display only); /rename alone returns to its familiar signature'}, + {name: '/queue', insertion: '/queue', description: 'Commands queued to run after the current one: view, edit, reorder, pause or clear (/queue pause|resume|clear)'}, {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, @@ -133,7 +140,16 @@ export const slashCommands: readonly SlashCommand[] = RAW_COMMANDS.map(command = export type ParsedSlashCommand = | {kind: 'effects'; effect: 'sparkles' | 'rain' | 'confetti' | 'stop' | 'help'; placement: 'top' | 'bottom'} - | {kind: 'copy'; index: number} + /** `args` is everything after /copy, parsed by parseCopyArgs (so a mistake gets a precise answer). */ + | {kind: 'copy'; args: string} + | {kind: 'copySettings'} + /** /ps: read the clipboard now and bring it to paste review. */ + | {kind: 'pasteClipboard'} + | {kind: 'clips'; suggestions?: boolean} + /** /pins: the pins and recipes panel. */ + | {kind: 'pins'} + /** /compare [A [B]]: outputs by /copy number; A defaults to the latest, B to A's previous run (or a choice). */ + | {kind: 'compare'; first?: number; second?: number; error?: string} | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} @@ -189,6 +205,7 @@ export type ParsedSlashCommand = | {kind: 'llm'} | {kind: 'doctor'} | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} + | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear'} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} | {kind: 'history', query: string} @@ -207,8 +224,20 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (!isSlashInput(input)) return undefined; const effect = /^\/effects(?:\s+(sparkles|rain|confetti|stop))?(?:\s+(top|bottom))?\s*$/u.exec(input); if (effect) return {kind: 'effects', effect: (effect[1] ?? 'help') as 'sparkles' | 'rain' | 'confetti' | 'stop' | 'help', placement: (effect[2] ?? 'bottom') as 'top' | 'bottom'}; - const match = /^\/copy(?:\s+([1-9]\d*))?\s*$/u.exec(input); - if (match) return {kind: 'copy', index: Number(match[1] ?? '1')}; + const compare = /^\/compare(?:\s+(.*?))?\s*$/u.exec(input); + if (compare) { + const words = (compare[1] ?? '').split(/\s+/u).filter(Boolean); + if (words.length > 2 || words.some(word => !/^[1-9]\d{0,2}$/u.test(word))) return {kind: 'compare', error: 'Use /compare, /compare N or /compare N M (numbers as /copy shows them, 1 is the latest).'}; + return {kind: 'compare', ...(words[0] ? {first: Number(words[0])} : {}), ...(words[1] ? {second: Number(words[1])} : {})}; + } + if (/^\/ps\s*$/u.test(input)) return {kind: 'pasteClipboard'}; + const clips = /^\/clips(?:\s+suggestions\s+(on|off))?\s*$/u.exec(input); + if (clips) return {kind: 'clips', ...(clips[1] ? {suggestions: clips[1] === 'on'} : {})}; + if (/^\/pins\s*$/u.test(input)) return {kind: 'pins'}; + // /cp is /copy: one parser, one handler, every form. + if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'}; + const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input); + if (copy) return {kind: 'copy', args: copy[1] ?? ''}; if (/^\/appearance\s*$/u.test(input)) return {kind: 'appearance'}; if (/^\/motion\s*$/u.test(input)) return {kind: 'motion'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; @@ -306,6 +335,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (family) return {kind: 'providers', family: (family === 'pickers' ? 'picker' : family) as 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'}; if (/^\/(?:llm|localllm)\s*$/u.test(input)) return {kind: 'llm'}; if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; + const queue = /^\/queue(?:\s+(pause|resume|clear))?\s*$/u.exec(input); + if (queue) return {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear'}; const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); if (watch) { const rest = (watch[1] ?? '').trim(); diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index a5d1566e..dfdeda83 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -25,6 +25,7 @@ export const PORTABLE_CATEGORIES = { chrome: ['uiChrome', 'glyphStyle', 'cursor'], syntax: ['syntax'], transcript: ['transcript', 'outputFolding'], + copy: ['copy'], layout: ['composerLayout', 'composerPosition', 'transcriptPresentation'], suggestions: ['suggestions', 'suggestionsOnEmpty'], providers: ['history', 'picker', 'navigation', 'welcome'], @@ -32,7 +33,7 @@ export const PORTABLE_CATEGORIES = { idle: ['idleVisuals', 'liveActivity'], notifications: ['notifications', 'sessionNotices'], tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateMode', 'updateFrequency'], - sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention', 'terminalTitle'], + sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention', 'terminalTitle', 'commandQueue'], agents: ['agentActivity'], shell: ['shellBackend'], editor: ['openWith'], diff --git a/src/context/capabilities/runtimes.ts b/src/context/capabilities/runtimes.ts index 45726e87..2c969ea1 100644 --- a/src/context/capabilities/runtimes.ts +++ b/src/context/capabilities/runtimes.ts @@ -272,4 +272,37 @@ export const javaRuntime = runtimeCapability('runtime.java', 'Java runtime and b return value ? {value, evidence: [requested?.from, build?.name, active.active ? 'JDK release file' : undefined].filter(Boolean).join(', ') || 'java'} : undefined; }); -export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime] as CapabilityDefinition[]; +export const rubyRuntime = runtimeCapability('runtime.ruby', 'Ruby version', ['.ruby-version', 'Gemfile (ruby "x")', '.tool-versions', 'mise.toml', + 'the ruby executable the shell resolves: its install location (never run)'], [], +{requested: '3.3', requestedFrom: '.ruby-version', active: '3.3.5', manager: 'rbenv', buildTool: 'bundler'}, async context => { + let requested = await versionFile(context, ['.ruby-version']); + const gemfile = await findNearest(context, ['Gemfile']); + if (!requested && gemfile) { + const declared = /^[ \t]*ruby[ \t]+['"](\d[\w.+-]{0,31})['"]/mu.exec(await readMetadataText(gemfile.path, 64 * 1024) ?? '')?.[1]; + if (declared) requested = {version: declared, from: 'Gemfile'}; + } + requested ??= await toolRequest(context, ['ruby']); + if (!requested && !gemfile && !context.fields.has('active')) return undefined; + const active = await activeVersion(context, ['ruby']); + const value = compact({requested: requested?.version.replace(/^ruby-(?=\d)/u, ''), requestedFrom: requested?.from, ...active, ...(gemfile ? {buildTool: 'bundler'} : {})}); + return value ? {value, evidence: [requested?.from, gemfile?.name, active.active ? 'ruby on PATH' : undefined].filter(Boolean).join(', ') || 'ruby'} : undefined; +}); + +export const phpRuntime = runtimeCapability('runtime.php', 'PHP version', ['.php-version', 'composer.json (require.php)', '.tool-versions', 'mise.toml', + 'the php executable the shell resolves: its install location (never run)'], [], +{requested: '8.3', requestedFrom: '.php-version', active: '8.3.12', manager: 'Homebrew', buildTool: 'composer'}, async context => { + let requested = await versionFile(context, ['.php-version']); + const manifest = await findNearest(context, ['composer.json']); + if (!requested && manifest) { + const pkg = parseJsonData(await readMetadataText(manifest.path, LARGE_METADATA_BYTES)); + const constraint = record(pkg) && record(pkg.require) ? text(pkg.require.php, 64) : undefined; + if (constraint) requested = {version: constraint, from: 'composer.json'}; + } + requested ??= await toolRequest(context, ['php']); + if (!requested && !manifest && !context.fields.has('active')) return undefined; + const active = await activeVersion(context, ['php']); + const value = compact({requested: requested?.version, requestedFrom: requested?.from, ...active, ...(manifest ? {buildTool: 'composer'} : {})}); + return value ? {value, evidence: [requested?.from, manifest?.name, active.active ? 'php on PATH' : undefined].filter(Boolean).join(', ') || 'php'} : undefined; +}); + +export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime] as CapabilityDefinition[]; diff --git a/src/context/engine.ts b/src/context/engine.ts index 429e7ea3..d97cd3a3 100644 --- a/src/context/engine.ts +++ b/src/context/engine.ts @@ -33,6 +33,8 @@ export interface CapabilityStatus { evidence?: string; error?: string; durationMs?: number; + /** A previous resolved value can survive a failed refresh; absence is not inferred from failure. */ + hasValue?: boolean; } export interface EngineStats { @@ -251,7 +253,7 @@ export class ContextEngine { : entry.status === 'failed' || entry.status === 'timeout' ? entry.status : entry.status === 'absent' ? 'absent' : entry.status === 'resolved' ? (entry.dirty || now - (entry.collectedAt ?? 0) > definition.ttlMs ? 'stale' : 'fresh') : 'idle'; - return {id, state, ...(entry.collectedAt !== undefined ? {collectedAt: entry.collectedAt} : {}), ...(entry.evidence ? {evidence: entry.evidence} : {}), + return {id, state, hasValue: entry.present, ...(entry.collectedAt !== undefined ? {collectedAt: entry.collectedAt} : {}), ...(entry.evidence ? {evidence: entry.evidence} : {}), ...(entry.error ? {error: entry.error} : {}), ...(entry.durationMs !== undefined ? {durationMs: entry.durationMs} : {})}; } diff --git a/src/context/packs/builtin/nmsh.project.json b/src/context/packs/builtin/nmsh.project.json index bcb53345..014cfe06 100644 --- a/src/context/packs/builtin/nmsh.project.json +++ b/src/context/packs/builtin/nmsh.project.json @@ -7,7 +7,7 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation; tool behavior studied from public documentation only."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["project.package", "runtime.node", "runtime.python", "runtime.go", "runtime.rust", "runtime.java"], + "requires": ["project.package", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.rust", "runtime.java"], "modules": [ { "id": "package", "label": "Package", "description": "Version and package manager from the nearest declarative manifest.", @@ -55,6 +55,30 @@ {"fact": "runtime.go", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} ]}] }, + { + "id": "ruby", "label": "Ruby", "description": "Ruby version from .ruby-version, Gemfile or the install location; never run.", + "category": "runtime", "priority": 43, "role": "runtime", "icon": "ruby", "condition": "onCommand", + "triggers": ["ruby", "bundle", "bundler", "rails", "rake", "gem"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "ruby", "parts": [ + {"fact": "runtime.ruby", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.ruby", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.ruby", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.ruby", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, + { + "id": "php", "label": "PHP", "description": "PHP version from .php-version, composer.json or the install location; never run.", + "category": "runtime", "priority": 42, "role": "runtime", "icon": "php", "condition": "onCommand", + "triggers": ["php", "composer", "artisan", "phpunit"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "php", "parts": [ + {"fact": "runtime.php", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.php", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.php", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.php", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, { "id": "rust", "label": "Rust", "description": "The rustup toolchain this directory selects, or the installed rustc.", "category": "runtime", "priority": 44, "role": "runtime", "icon": "rust", "condition": "onCommand", diff --git a/src/doctor/doctor.ts b/src/doctor/doctor.ts index 50e08c64..667ef8e2 100644 --- a/src/doctor/doctor.ts +++ b/src/doctor/doctor.ts @@ -39,10 +39,22 @@ export interface DoctorEnvironment { understanding: {mode: string; model?: {label: string; runtime: string; path?: string; owned: boolean}; runtimeAvailable: boolean; state?: string}; agents: Array<{label: string; installed: boolean}>; virtualEnv?: string; + /** The Context Engine's own counters since NMSh started (nothing is collected to read them). */ + contextEngine?: {started: number; completed: number; cancelled: number; timedOut: number; failed: number; cacheHits: number; coalesced: number}; } const join = (directory: string, name: string) => `${directory.replace(/\/$/u, '')}/${name}`; +/** Prompt data health: how often collection ran, was served from cache, timed out or failed. A few timeouts are normal on a cold disk. */ +export function contextEngineCheck(stats: NonNullable): DoctorCheck { + const section = 'NMSh'; + const total = stats.completed + stats.timedOut + stats.failed; + const detail = `${stats.started} collections started · ${stats.cacheHits} served from cache · ${stats.coalesced} merged · ${stats.cancelled} cancelled · ${stats.timedOut} timed out · ${stats.failed} failed. /modules shows each module's own timing`; + const bad = total >= 20 && (stats.timedOut + stats.failed) / total > 0.2; + return bad ? {section, label: 'Prompt data often times out or fails', state: 'attention', detail, action: {label: 'Review modules', kind: 'slash', command: '/modules'}} + : {section, label: 'Prompt data collection healthy', state: stats.started ? 'ok' : 'info', detail}; +} + function nmshChecks(env: DoctorEnvironment): DoctorCheck[] { const section = 'NMSh'; const checks: DoctorCheck[] = [env.nmsh.configurationLoaded @@ -54,6 +66,7 @@ function nmshChecks(env: DoctorEnvironment): DoctorCheck[] { } else checks.push({section, label: 'Running in-process (no session service)', state: 'info'}); checks.push(env.writable(env.nmsh.transcriptDirectory) ? {section, label: 'Transcript store writable', state: 'ok'} : {section, label: 'Transcript store is not writable', state: 'failure', detail: env.nmsh.transcriptDirectory}); + if (env.contextEngine) checks.push(contextEngineCheck(env.contextEngine)); return checks; } diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index b304e004..cbcfd945 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -73,6 +73,14 @@ Use /history with plain text or combine cwd:, project:, exit:, before:, after:, Enter or Tab restores the selected command without executing it. Ctrl+X removes the selected record from NMSh search. Session transcripts and the original zsh/Atuin history remain intact; deletion is remembered locally. +To keep the selected history command, open the palette (F1 or Ctrl+Shift+P) and choose **Pin selected history command**. It keeps the command and its recorded directory without executing it; /pins lists it later. + +At the prompt, F8 focuses the newest failed command, then walks to older failures. Shift+F8 returns to newer failures. These keys preserve your draft; the palette also offers Previous/Next failed command. Your terminal may reserve function keys. + +Use C in Paste Review to keep an ephemeral clip, or a block's Actions to keep its command or output. The palette can keep your composer draft or selected history command. /clips reviews kept text before insertion; B opens the existing batch review. No clip runs automatically or replaces a draft. Clips stay in this frontend's memory (up to 12 clips and 1 MiB total), can be discarded, and disappear when it exits, including detach. + +/clips suggestions on optionally checks macOS's native clipboard change counter while the composer is idle. It never reads clipboard contents. A changed counter suggests /ps; only that explicit command reads text for review. Suggestions default off, last only for this frontend, and stop with /clips suggestions off or if metadata fails. Linux clipboard tools have no supported metadata counter; background text polling is unsupported. + ## Directory navigation Use /dirs or the command palette to find recorded directories. Native ranks frequency and recency from approved command history. Select with arrows and Enter or Tab to insert a literal cd command, then press Enter separately to run it in zsh. Ordinary cd keeps its normal behavior. diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts new file mode 100644 index 00000000..4ff2f25d --- /dev/null +++ b/src/input/BatchReview.ts @@ -0,0 +1,209 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {classifyCommand, KIND_LABELS, primaryKind, splitCommands, type PasteKind} from './pasteGuard.js'; +import {displaySafe} from './PasteReview.js'; +import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; +import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; + +/** + * Batch Review: a pasted sequence split into the commands the shell would run, to look at before any of it is queued. + * Nothing runs because it was pasted or shown here. Enter queues exactly the commands listed, in the order listed, + * into this session's own queue (one at a time, in the same shell, pausing on a failure); every other key leaves the + * paste as text. + */ +export interface BatchEntry extends BatchCommand { + kinds: PasteKind[]; +} + +export interface BatchReviewState { + /** The paste exactly as it arrived. */ + source: string; + entries: BatchEntry[]; + cursor: number; + /** First visible list row. */ + top: number; + /** Free places in the session's queue. */ + capacity: number; + /** Where the commands come from, when not a paste (a recipe): shown in the header. */ + title?: string; + /** What the commands expect (the recipe's folder and the current one), shown above the list. */ + note?: string; +} + +export type BatchReviewAction = {kind: 'queue'} | {kind: 'text'} | {kind: 'edit'} | {kind: 'back'} | {kind: 'cancel'}; + +/** Contents of `$( )`, `<( )`, `>( )` and backtick substitutions in a command (balanced; an approximation that errs towards finding more). */ +function substitutions(text: string): string[] { + const found: string[] = []; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (char === '`') { + const end = text.indexOf('`', index + 1); + if (end < 0) break; + found.push(text.slice(index + 1, end)); + index = end; + } else if ((char === '$' || char === '<' || char === '>') && text[index + 1] === '(') { + let depth = 1; + let at = index + 2; + while (at < text.length && depth) { if (text[at] === '(') depth += 1; else if (text[at] === ')') depth -= 1; at += 1; } + found.push(text.slice(index + 2, depth ? at : at - 1)); + index += 1; + } + } + return found; +} + +/** + * Everything the whole command can do, not just how its first line starts: every statement in it (heredoc bodies + * and block contents included, which only ever over-reports) and everything inside command substitutions. + */ +export function entryKinds(text: string, depth = 0): PasteKind[] { + const kinds = new Set(); + for (const piece of splitCommands(text)) for (const kind of classifyCommand(piece.text, piece.pipedFrom)) kinds.add(kind); + // A quote the splitter loses track of (an apostrophe in a heredoc, in a comment) must not hide what follows it: + // every line is classified on its own as well, and every statement after a `;`, `&&`, `||`, `|` or `&`. This only + // ever adds findings. + for (const line of text.split('\n')) { + for (const part of line.split(/&&|\|\||[;|&]/u)) { + const trimmed = part.trim().replace(/^[{(]\s*/u, '').replace(/^(?:do|then|else)\s+/u, ''); + if (trimmed && !trimmed.startsWith('#')) for (const kind of classifyCommand(trimmed, false)) if (kind !== 'text') kinds.add(kind); + } + } + if (depth < 3) for (const inner of substitutions(text)) for (const kind of entryKinds(inner, depth + 1)) kinds.add(kind); + return [...kinds]; +} + +/** The paste as separate commands, when the shell grammar says it safely can be (two or more); undefined means "keep it as one block". */ +export function createBatchReview(source: string, shell: BatchShell, capacity: number): BatchReviewState | undefined { + const result = splitBatch(source, shell); + if (!result.ok || result.commands.length < 2) return undefined; + return {source, entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), cursor: 0, top: 0, capacity}; +} + +/** A reviewed list of commands that are already separate (a recipe's steps): the same review, queue and safety. */ +export function batchFromCommands(commands: readonly string[], capacity: number, details: {title: string; note?: string}): BatchReviewState { + const entries = commands.map((text, index): BatchEntry => ({text, firstLine: index + 1, lastLine: index + 1, kinds: entryKinds(text)})); + return {source: commands.join('\n'), entries, cursor: 0, top: 0, capacity, title: details.title, ...(details.note ? {note: details.note} : {})}; +} + +/** Why a paste is not offered as a batch (for the review's own note), or undefined when it is. */ +export function batchRefusal(source: string, shell: BatchShell): string | undefined { + const result = splitBatch(source, shell); + if (!result.ok) return result.reason; + return result.commands.length < 2 ? 'It is a single command.' : undefined; +} + +export function batchReviewKey(state: BatchReviewState, key: Key, height: number): BatchReviewAction | undefined { + const rows = listRows(height); + const last = state.entries.length - 1; + const select = (to: number) => { state.cursor = Math.max(0, Math.min(last, to)); }; + const move = (by: number) => { + const to = state.cursor + by; + if (to < 0 || to > last) return; + const [entry] = state.entries.splice(state.cursor, 1); + state.entries.splice(to, 0, entry!); + state.cursor = to; + }; + if (key.kind === 'enter') return state.entries.length && state.entries.length <= state.capacity ? {kind: 'queue'} : undefined; + if (key.kind === 'escape') return {kind: 'back'}; + if (key.kind === 'interrupt') return {kind: 'cancel'}; + if (key.kind === 'up' || key.kind === 'wheelUp') select(state.cursor - 1); + else if (key.kind === 'down' || key.kind === 'wheelDown') select(state.cursor + 1); + else if (key.kind === 'pageUp') select(state.cursor - rows); + else if (key.kind === 'pageDown') select(state.cursor + rows); + else if (key.kind === 'delete' || key.kind === 'backspace') remove(state); + else if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'x') remove(state); + else if (value === 'k') move(-1); + else if (value === 'j') move(1); + else if (value === 't') return {kind: 'text'}; + else if (value === 'e' && state.entries.length) return {kind: 'edit'}; + else if (value === 'q' && state.entries.length && state.entries.length <= state.capacity) return {kind: 'queue'}; + } + state.top = Math.max(0, Math.min(state.top, Math.max(0, state.entries.length - rows))); + if (state.cursor < state.top) state.top = state.cursor; + else if (state.cursor >= state.top + rows) state.top = state.cursor - rows + 1; + return undefined; +} + +function remove(state: BatchReviewState): void { + if (!state.entries.length) return; + state.entries.splice(state.cursor, 1); + state.cursor = Math.max(0, Math.min(state.cursor, state.entries.length - 1)); +} + +/** The commands as they will be queued (the reviewed list, one entry per command). */ +export function batchTexts(state: BatchReviewState): string[] { + return state.entries.map(entry => entry.text); +} + +/** A command line as rows of at most `width` cells: wrapped, never cut, so nothing of it is out of sight. */ +function wrapRows(line: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + let used = 0; + for (const char of displaySafe(line)) { + // Characters a terminal may draw wide (ambiguous, combining, emoji) count as two cells, so no row can overflow. + const cells = Math.max(displayWidth(char), (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1); + if (used + cells > width && row) { rows.push(row); row = ''; used = 0; } + row += char; + used += cells; + } + rows.push(row); + return rows; +} + +const FIXED_ROWS = 10; +const listRows = (height: number) => Math.max(1, Math.min(8, height - FIXED_ROWS - 3)); +const RISKY: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline']); + +/** At most `height` rows: header, the list (scrolling), the selected command in full, the notes and the controls. */ +export function renderBatchReview(state: BatchReviewState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const accent = foreground(UI_COLORS.accent); + const failure = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const count = state.entries.length; + const width = Math.max(10, columns - 4); + const out: string[] = [` ${primary}Review commands${reset} ${subtle}${truncateText(`${state.title ? `${state.title} · ` : ''}${count} command${count === 1 ? '' : 's'}, in this order`, Math.max(8, width - 18))}${reset}`]; + if (state.note) out.push(` ${subtle}${truncateText(state.note, width)}${reset}`); + out.push(''); + const rows = listRows(height); + const numberWidth = String(count).length; + const labelWidth = columns >= 76 ? 26 : 0; + if (!count) out.push(` ${secondary}Nothing left to queue. Esc goes back to the paste.${reset}`); + for (let index = state.top; index < Math.min(count, state.top + rows); index += 1) { + const entry = state.entries[index]!; + const focused = index === state.cursor; + const kind = primaryKind(entry.kinds); + const color = RISKY.has(kind) ? failure : kind === 'install' || kind === 'modifies' ? accent : subtle; + const label = labelWidth ? `${color}${padCells(truncateText(KIND_LABELS[kind], labelWidth - 1), labelWidth)}${reset}` : RISKY.has(kind) ? `${failure}! ${reset}` : ''; + const lines = entry.text.split('\n'); + const first = displaySafe(lines[0]!) + (lines.length > 1 ? ` ⏎ +${lines.length - 1}` : ''); + out.push(truncateAnsi(`${focused ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${label}${focusForeground(focused)}${truncateText(first, Math.max(8, width - numberWidth - labelWidth - 4))}${reset}`, columns)); + } + if (count > rows) out.push(` ${subtle}${state.top + 1}–${Math.min(count, state.top + rows)} of ${count}${reset}`); + const selected = state.entries[state.cursor]; + const notes = 2; + const controls = renderControlRows([['↑↓', 'select'], ['x', 'remove'], ['k j', 'move'], ['Enter', `queue ${count}`], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); + if (selected) { + const kind = primaryKind(selected.kinds); + out.push('', ` ${subtle}Command ${state.cursor + 1}, exactly as it will be queued${reset}${RISKY.has(kind) || kind === 'install' || kind === 'modifies' ? `${RISKY.has(kind) ? failure : accent} · ${KIND_LABELS[kind]} somewhere in it${reset}` : ''}`); + const wrapped = selected.text.split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); + const room = Math.max(2, height - out.length - notes - controls.length - 1); + const shown = wrapped.length > room ? room - 1 : room; + for (const row of wrapped.slice(0, shown)) out.push(` ${secondary}${row}${reset}`); + if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); + } + out.push(''); + if (count > state.capacity) out.push(` ${failure}${truncateText(`The queue has room for ${state.capacity} more; remove ${count - state.capacity} to queue these.`, width)}${reset}`); + else out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. Then the shell runs them one at a time, and the queue stops if one fails.', width)}${reset}`); + out.push(...controls.map(row => ` ${row}`)); + const fixed = Math.max(4, height); + return (out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out).map(row => truncateAnsi(row, columns)); +} diff --git a/src/input/PasteReview.ts b/src/input/PasteReview.ts index cbf7b298..ffb6eb74 100644 --- a/src/input/PasteReview.ts +++ b/src/input/PasteReview.ts @@ -21,11 +21,13 @@ export interface PasteReviewState { starts: Array; /** First visible source line. */ top: number; + /** The paste splits into separate commands that could be queued (offered as B). */ + batch: boolean; } -export type PasteReviewResult = 'insert' | 'back' | 'cancel' | undefined; +export type PasteReviewResult = 'insert' | 'back' | 'cancel' | 'batch' | 'clip' | undefined; -export function createPasteReview(text: string, analysis: PasteAnalysis): PasteReviewState { +export function createPasteReview(text: string, analysis: PasteAnalysis, batch = false): PasteReviewState { const lines = text.replace(/\r\n?/gu, '\n').split('\n'); const starts: Array = new Array(lines.length).fill(undefined); // Locate each command in order from where the previous one ended; display-only, linear in the paste size. @@ -42,7 +44,7 @@ export function createPasteReview(text: string, analysis: PasteAnalysis): PasteR while (lineIndex + 1 < lines.length && offsets[lineIndex + 1]! <= found) lineIndex += 1; starts[lineIndex] ??= primaryKind(command.kinds); } - return {text, analysis, lines, starts, top: 0}; + return {text, analysis, lines, starts, top: 0, batch}; } /** The body rows available for source lines: header, a blank, a note row and the footer are fixed. */ @@ -58,6 +60,8 @@ export function pasteReviewKey(state: PasteReviewState, key: Key, height: number if (key.kind === 'escape') return 'back'; if (key.kind === 'interrupt') return 'cancel'; if (key.kind === 'text' && key.value.toLowerCase() === 'r') return 'back'; + if (key.kind === 'text' && key.value.toLowerCase() === 'c') return 'clip'; + if (key.kind === 'text' && key.value.toLowerCase() === 'b' && state.batch) return 'batch'; if (key.kind === 'up' || key.kind === 'wheelUp') move(state.top - (key.kind === 'wheelUp' ? 3 : 1)); else if (key.kind === 'down' || key.kind === 'wheelDown') move(state.top + (key.kind === 'wheelDown' ? 3 : 1)); else if (key.kind === 'pageUp') move(state.top - page); @@ -67,9 +71,13 @@ export function pasteReviewKey(state: PasteReviewState, key: Key, height: number return undefined; } -/** Make a pasted line safe and legible to draw: control characters become visible, tabs become spaces. */ +/** + * Make a pasted line safe and legible to draw: control characters become visible, tabs become spaces. Format and + * separator characters (bidi overrides and isolates, zero-width characters, line and paragraph separators) are shown + * as · too: they change how text reads or hide that it is there, and a reviewer must be able to see them. + */ export function displaySafe(line: string): string { - return line.replace(/\t/gu, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => `␛`.length && char === '\u001B' ? '␛' : '·'); + return line.replace(/\t/gu, ' ').replace(/[\u0000-\u001f\u007f-\u009f]|[\p{Cf}\p{Zl}\p{Zp}]/gu, char => char === '\u001B' ? '␛' : '·'); } const RISKY: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline']); @@ -98,7 +106,7 @@ export function renderPasteReview(state: PasteReviewState, columns: number, heig } const exact = state.analysis.commands.some(command => primaryKind(command.kinds) === 'text' || primaryKind(command.kinds) === 'unknown'); out.push(truncateAnsi(` ${subtle}${exact ? PASTE_EXACT_NOTE : 'Nothing runs until you press Enter again.'}${reset}`, columns)); - out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ['Esc', 'back']])); + out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ...(state.batch ? [['B', 'queue as commands'] as const] : []), ['Esc', 'back'], ['C', 'keep clip']])); // A very short terminal drops the note before the controls; the panel never exceeds its height. const fixed = Math.max(3, height); return out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out; diff --git a/src/input/pasteBatch.ts b/src/input/pasteBatch.ts new file mode 100644 index 00000000..122318c3 --- /dev/null +++ b/src/input/pasteBatch.ts @@ -0,0 +1,304 @@ +/** + * Paste batches: where a pasted block of shell text can safely be cut into separate commands for a reviewed queue. + * + * Only a COMPLETE top-level statement ends at a newline. A newline inside a quote, a heredoc, a `$( )` or `( )` group, + * an `if`/`for`/`while`/`case`/`function` block (`begin`/`switch`/`end` in Fish), after a trailing `\`, `&&`, `||` or `|` + * stays inside the statement, and a chain such as `a && b` is one command: splitting it would change what runs. + * Anything this scanner cannot account for (unterminated quotes, stray closers, constructs it does not model) is not + * guessed at: the result says why, and the caller offers the paste as one block of text. + * + * Each command is the exact characters from the paste (line endings normalised to \n), never re-quoted or trimmed + * inside. Nothing here runs, resolves or expands anything. + */ +export type BatchShell = 'zsh' | 'bash' | 'fish'; + +export interface BatchCommand { + text: string; + /** 1-based first and last source line (comment lines before a command belong to it). */ + firstLine: number; + lastLine: number; +} + +export type BatchResult = {ok: true; commands: BatchCommand[]} | {ok: false; reason: string}; + +/** Nesting deeper than this is not a paste to reason about. */ +const MAX_DEPTH = 200; +/** Pastes larger than this are one block of text. */ +export const MAX_BATCH_CHARACTERS = 1_000_000; + +class Unsplittable extends Error {} +const fail = (reason: string): never => { throw new Unsplittable(reason); }; + +const OPENERS_POSIX = new Set(['if', 'while', 'until', 'for', 'select']); +const CLOSERS_POSIX = new Set(['fi', 'done', 'esac']); +const OPENERS_FISH = new Set(['if', 'while', 'for', 'switch', 'begin', 'function']); +/** zsh constructs with block forms this scanner does not model. */ +const UNMODELLED = new Set(['foreach', 'repeat', 'coproc']); +/** Words after which a command can start again. */ +const CONTINUES_COMMAND = new Set(['then', 'do', 'else', 'elif', '!', 'time', 'builtin', 'command', 'exec', 'noglob', 'nocorrect', 'sudo']); + +export function splitBatch(input: string, shell: BatchShell): BatchResult { + const text = input.replace(/\r\n?/gu, '\n'); + if (text.length > MAX_BATCH_CHARACTERS) return {ok: false, reason: 'The paste is too large to split safely.'}; + try { return {ok: true, commands: scan(text, shell)}; } + catch (error) { if (error instanceof Unsplittable) return {ok: false, reason: error.message}; throw error; } +} + +function scan(text: string, shell: BatchShell): BatchCommand[] { + const fish = shell === 'fish'; + const length = text.length; + const lineStarts = [0]; + for (let index = 0; index < length; index += 1) if (text[index] === '\n') lineStarts.push(index + 1); + const lineOf = (index: number) => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { const mid = (low + high + 1) >> 1; if (lineStarts[mid]! <= index) low = mid; else high = mid - 1; } + return low + 1; + }; + + /** Index just past a quoted or substituted span that starts at `from`, or fail. */ + const skipSingle = (from: number, escapes: boolean): number => { + for (let index = from; index < length; index += 1) { + if (escapes && text[index] === '\\') { index += 1; continue; } + if (text[index] === '\'') return index + 1; + } + return fail('A single quote is never closed.'); + }; + const skipDouble = (from: number, depth: number): number => { + for (let index = from; index < length; index += 1) { + const char = text[index]!; + if (char === '\\') { index += 1; continue; } + if (char === '"') return index + 1; + if (char === '$' && text[index + 1] === '(') index = skipGroup(index + 2, ')', depth + 1, text[index + 2] === '(') - 1; + else if (char === '$' && text[index + 1] === '{') index = skipGroup(index + 2, '}', depth + 1) - 1; + else if (char === '`') index = skipBacktick(index + 1) - 1; + } + return fail('A double quote is never closed.'); + }; + const skipBacktick = (from: number): number => { + for (let index = from; index < length; index += 1) { + if (text[index] === '\\') { index += 1; continue; } + if (text[index] === '`') return index + 1; + } + return fail('A backtick is never closed.'); + }; + /** Past the matching `close` of a `$(`, `${` or `(` group that started before `from`. */ + const skipGroup = (from: number, close: ')' | '}', depth: number, arithmetic = false): number => { + if (depth > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + const open = close === ')' ? '(' : '{'; + for (let index = from; index < length; index += 1) { + const char = text[index]!; + if (char === '\\') { index += 1; continue; } + if (char === '\'') { index = skipSingle(index + 1, fish || text[index - 1] === '$') - 1; continue; } + if (char === '"') { index = skipDouble(index + 1, depth) - 1; continue; } + if (char === '`') { index = skipBacktick(index + 1) - 1; continue; } + // A comment inside a substitution can hide a quote or a parenthesis from the shell (or show one to it): not split. + if (close === ')' && !arithmetic && char === '#' && (index === from || /[\s;&|(]/u.test(text[index - 1]!))) fail('A comment inside a substitution is not split.'); + // A here-document inside a substitution has a body this scanner does not read: say so instead of guessing where the group ends. + if (!arithmetic && close === ')' && char === '<' && text[index + 1] === '<' && text[index + 2] !== '<' && text[index - 1] !== '<') fail('A here-document inside a substitution is not split.'); + if (char === '$' && text[index + 1] === '(') { index = skipGroup(index + 2, ')', depth + 1, arithmetic || text[index + 2] === '(') - 1; continue; } + if (char === '$' && text[index + 1] === '{') { index = skipGroup(index + 2, '}', depth + 1, arithmetic) - 1; continue; } + if (char === open && close === ')') { index = skipGroup(index + 1, ')', depth + 1, arithmetic) - 1; continue; } + if (char === close) return index + 1; + } + return fail(close === ')' ? 'A parenthesis is never closed.' : 'A brace is never closed.'); + }; + + /** + * Past a `#` comment to the end of its line. Interactive zsh only treats `#` as a comment when the person turned + * interactive_comments on, which is not known here; otherwise everything after it is ordinary shell text, including + * `;` and `$( )`. A comment with no shell syntax in it reads the same either way. One with syntax in it is refused, + * so the review can never show something as a comment that the shell would run. + */ + const skipComment = (from: number): number => { + let end = from; + while (end < length && text[end] !== '\n') end += 1; + if (shell === 'zsh' && /['"`$;&|()<>\\{}]/u.test(text.slice(from, end))) fail('A comment contains shell syntax, and zsh runs that unless interactive comments are on.'); + return end; + }; + + const commands: BatchCommand[] = []; + const stack: Array<'(' | '{'> = []; + let blocks = 0; + let cases = 0; + let inTest = false; + let continuation = false; + let commandStart = true; + let functionPending = false; + let statementStart = 0; + let previousWord = ''; + let sawCode = false; + const heredocs: Array<{tag: string; strip: boolean}> = []; + + const emit = (end: number) => { + const raw = text.slice(statementStart, end); + const trimmed = raw.replace(/^\s+/u, '').replace(/\s+$/u, ''); + if (!trimmed) return; + const first = statementStart + (raw.length - raw.replace(/^\s+/u, '').length); + commands.push({text: trimmed, firstLine: lineOf(first), lastLine: lineOf(first + trimmed.length - 1)}); + }; + const complete = () => !continuation && !stack.length && !blocks && !cases && !inTest && !functionPending; + const nextWordAfterBreak = (from: number): string => { + let index = from; + while (index < length) { + const char = text[index]!; + if (char === '\n' || char === ' ' || char === '\t') { index += 1; continue; } + if (char === '#') { while (index < length && text[index] !== '\n') index += 1; continue; } + break; + } + return /^(?:&&|\|\||\||[A-Za-z_][\w-]*)/u.exec(text.slice(index, index + 40))?.[0] ?? ''; + }; + + let index = 0; + while (index < length) { + const char = text[index]!; + if (char === '\n') { + index += 1; + if (!continuation) commandStart = true; + if (heredocs.length) { + // The bodies follow the line that named them; each ends at a line that is exactly its tag. + for (const heredoc of heredocs.splice(0)) { + for (;;) { + if (index >= length) fail('A here-document is never closed.'); + const end = text.indexOf('\n', index); + const line = text.slice(index, end < 0 ? length : end); + index = end < 0 ? length : end + 1; + if ((heredoc.strip ? line.replace(/^\t+/u, '') : line) === heredoc.tag) break; + } + } + if (complete() && sawCode) { emit(index); statementStart = index; sawCode = false; commandStart = true; } + continue; + } + if (complete() && sawCode) { + const next = nextWordAfterBreak(index); + if (/^(?:&&|\|\||\|)/u.test(next)) { if (!fish) fail('A line starts with an operator.'); } + else if (fish && (next === 'and' || next === 'or')) { /* a Fish modifier continues the statement above */ } + else { emit(index - 1); statementStart = index; sawCode = false; commandStart = true; } + } else if (!sawCode) { + // Blank or comment-only lines wait for the command they precede. + } + continue; + } + if (char === ' ' || char === '\t') { index += 1; continue; } + if (char === '#') { index = skipComment(index); continue; } + if (char === '\\') { + if (text[index + 1] === '\n') { index += 2; continue; } + sawCode = true; commandStart = false; continuation = false; + index += 2; + continue; + } + // Operators. + if (char === '&' && text[index + 1] === '&') { continuation = true; commandStart = true; index += 2; sawCode = true; continue; } + if (char === '|') { + continuation = !inTest; commandStart = true; sawCode = true; + index += text[index + 1] === '|' || text[index + 1] === '&' ? 2 : 1; + continue; + } + if (char === ';') { + sawCode = true; continuation = false; commandStart = true; + index += text[index + 1] === ';' ? 2 : 1; + if (cases && text[index - 1] === ';' && text[index - 2] === ';') { /* end of a case arm */ } + continue; + } + if (char === '&') { sawCode = true; continuation = false; commandStart = true; index += 1; continue; } + if (char === '(' && commandStart && text[index + 1] === '(' && !fish) { + // (( expression )): arithmetic, whose << is a shift and not a here-document. + const end = skipGroup(index + 2, ')', 1, true); + if (text[end] === ')') { index = end + 1; sawCode = true; continuation = false; commandStart = false; continue; } + } + if (char === '(') { + if (stack.length >= MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + stack.push('('); sawCode = true; continuation = false; commandStart = true; index += 1; continue; + } + if (char === ')') { + if (stack.at(-1) === '(') stack.pop(); + else if (cases) { /* the ) after a case pattern */ } + else fail('A closing parenthesis has nothing to close.'); + sawCode = true; continuation = false; commandStart = true; index += 1; + continue; + } + if (char === '<' || char === '>') { + // Redirections; `<<` names a here-document (not in Fish, which has none), `<<<` is a here-string. + if (!fish && char === '<' && text[index + 1] === '<' && text[index + 2] !== '<' && text[index - 1] !== '<') { + let at = index + 2; + const strip = text[at] === '-'; + if (strip) at += 1; + while (text[at] === ' ' || text[at] === '\t') at += 1; + // The marker is a word: the shell removes its quotes and backslashes before comparing lines with it. + const match = /^(?:[^\s;&|<>()'"\\]|'[^'\n]*'|"[^"\n]*"|\\.)+/u.exec(text.slice(at, at + 200)); + if (!match) fail('A here-document has no readable end marker.'); + const tag = match![0].replace(/'([^']*)'|"([^"]*)"|\\(.)/gu, (_all, single?: string, double?: string, escaped?: string) => single ?? double ?? escaped ?? ''); + if (!tag || /[$`]/u.test(tag)) fail('A here-document marker uses an expansion.'); + heredocs.push({tag, strip}); + index = at + match![0].length; + sawCode = true; continuation = false; commandStart = false; + continue; + } + // Process substitution <( ) and >( ). + if (text[index + 1] === '(') { index = skipGroup(index + 2, ')', 1); sawCode = true; continuation = false; commandStart = false; continue; } + sawCode = true; continuation = false; commandStart = false; index += 1; + continue; + } + // A word, possibly with quoted or substituted parts. + let word = ''; + let plain = true; + sawCode = true; + while (index < length) { + const c = text[index]!; + if (c === '\n' || c === ' ' || c === '\t' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || c === '<' || c === '>') break; + if (c === '\\') { if (text[index + 1] === '\n') { index += 2; continue; } plain = false; index += 2; continue; } + if (c === '\'') { plain = false; index = skipSingle(index + 1, fish || text[index - 1] === '$'); continue; } + if (c === '"') { plain = false; index = skipDouble(index + 1, 1); continue; } + if (c === '`') { plain = false; index = skipBacktick(index + 1); continue; } + if (c === '$' && text[index + 1] === '(') { plain = false; index = skipGroup(index + 2, ')', 1, text[index + 2] === '('); continue; } + if (c === '$' && text[index + 1] === '{') { plain = false; index = skipGroup(index + 2, '}', 1); continue; } + if (c === '#' && word === '' ) break; + word += c; + index += 1; + } + continuation = false; + if (!plain || !word) { commandStart = false; functionPending = false; continue; } + if (commandStart) { + if (!fish && UNMODELLED.has(word) && shell === 'zsh') fail(`"${word}" blocks are not split.`); + if (fish) { + if (OPENERS_FISH.has(word) && !(word === 'if' && previousWord === 'else')) { blocks += 1; if (blocks > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); } + else if (word === 'end') { if (!blocks) fail('"end" has nothing to close.'); blocks -= 1; } + } else { + if (OPENERS_POSIX.has(word)) { blocks += 1; if (blocks > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); } + else if (word === 'case') { cases += 1; blocks += 1; } + else if (CLOSERS_POSIX.has(word)) { + if (!blocks) fail(`"${word}" has nothing to close.`); + blocks -= 1; + if (word === 'esac') { if (!cases) fail('"esac" has nothing to close.'); cases -= 1; } + } else if (word === 'function') functionPending = true; + else if (word === '[[') inTest = true; + } + } + if (word === '{' && !fish) { + if (stack.length >= MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + stack.push('{'); functionPending = false; commandStart = true; continue; + } + if (word === '}' && !fish && commandStart) { + if (stack.at(-1) !== '{') fail('A closing brace has nothing to close.'); + stack.pop(); commandStart = false; continue; + } + if (word === ']]') inTest = false; + const wasStart = commandStart; + commandStart = commandStart && CONTINUES_COMMAND.has(word); + if (fish && wasStart && (word === 'and' || word === 'or' || word === 'not')) commandStart = true; + previousWord = wasStart ? word : ''; + } + if (heredocs.length) fail('A here-document is never closed.'); + if (continuation) fail('The paste ends in the middle of a command.'); + if (stack.length || blocks || cases || inTest) fail('A block or group is never closed.'); + if (sawCode) emit(length); + else if (commands.length) { + // Trailing comment lines stay with the command above them. + const rest = text.slice(statementStart).replace(/\s+$/u, '').replace(/^\n+/u, ''); + if (rest) { + const last = commands[commands.length - 1]!; + commands[commands.length - 1] = {text: `${last.text}\n${rest}`, firstLine: last.firstLine, lastLine: lineOf(text.replace(/\s+$/u, '').length - 1)}; + } + } + return commands; +} diff --git a/src/input/pasteBatchCheck.ts b/src/input/pasteBatchCheck.ts new file mode 100644 index 00000000..306ec172 --- /dev/null +++ b/src/input/pasteBatchCheck.ts @@ -0,0 +1,58 @@ +import {spawn} from 'node:child_process'; +import {shellSubmission} from '../shell/submission.js'; +import type {BatchShell} from './pasteBatch.js'; + +/** + * The final gate for a paste split into commands: the real shell, in parse-only mode (`bash -n`, `zsh -n`, + * `fish --no-execute`: it reads and checks syntax and never runs anything), must accept EACH command on its own, + * exactly as it would be submitted. A boundary the splitter drew through a quote, a group, a block or a heredoc + * leaves a fragment the shell rejects, so the split is refused instead of shown. + * + * This does not decide what a command does, only that the pieces are whole. It fails closed: a shell that cannot + * be started, or that takes too long, means "not verified". + */ +const COMMAND: Record = { + bash: ['bash', '--noprofile', '--norc', '-n'], + zsh: ['zsh', '-f', '-n'], + fish: ['fish', '--no-config', '--no-execute'], +}; +const TIMEOUT_MS = 4000; +const CONCURRENCY = 8; + +export type BatchCheck = {ok: true} | {ok: false; reason: string}; + +function parses(shell: BatchShell, command: string): Promise<'yes' | 'no' | 'unavailable'> { + return new Promise(resolve => { + const [file, ...args] = COMMAND[shell]; + let settled = false; + const finish = (result: 'yes' | 'no' | 'unavailable') => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } }; + let child; + // Fish 3.7 on Linux cannot read Node's socket-backed stdin in parse-only mode. + // -c supplies data under --no-execute; it never evaluates the command. An OS argv + // size refusal fails closed through the ordinary unavailable path. + const script = shellSubmission(command, shell); + try { child = spawn(file!, shell === 'fish' ? [...args, '-c', script] : args, {stdio: ['pipe', 'ignore', 'ignore']}); } + catch { resolve('unavailable'); return; } + const timer = setTimeout(() => { child.kill('SIGKILL'); finish('unavailable'); }, TIMEOUT_MS); + child.once('error', () => finish('unavailable')); + child.once('close', code => finish(code === 0 ? 'yes' : 'no')); + child.stdin.once('error', () => { /* the shell may exit before reading it all; its status decides */ }); + child.stdin.end(shell === 'fish' ? undefined : `${script}\n`); + }); +} + +export async function verifyCommands(commands: readonly string[], shell: BatchShell): Promise { + let next = 0; + let failure: BatchCheck | undefined; + const worker = async () => { + while (!failure) { + const index = next++; + if (index >= commands.length) return; + const result = await parses(shell, commands[index]!); + if (result === 'no') failure ??= {ok: false, reason: `Command ${index + 1} is not complete ${shell} syntax by itself, so the paste was not split.`}; + else if (result === 'unavailable') failure ??= {ok: false, reason: `${shell} could not be asked to check the commands, so the paste was not split.`}; + } + }; + await Promise.all(Array.from({length: Math.min(CONCURRENCY, commands.length)}, worker)); + return failure ?? {ok: true}; +} diff --git a/src/output/FoldPolicy.ts b/src/output/FoldPolicy.ts index e6ffe2d8..3ed593e7 100644 --- a/src/output/FoldPolicy.ts +++ b/src/output/FoldPolicy.ts @@ -41,6 +41,16 @@ export const OUTPUT_FOLDING_MODES: readonly OutputFoldingMode[] = ['never', 'sma /** Output at or below this many lines never auto-folds. */ export const MIN_AUTO_FOLD_LINES = 30; +/** + * Smart folds any block this long (logical lines, or rows at FOLD_REFERENCE_COLUMNS for long wrapped lines), whatever + * the command and whatever its exit status: a block of several screens is a burden to scroll past, and its important + * lines stay in view in the collapsed preview (foldHighlights). Below it, the content heuristics decide. + */ +export const LONG_OUTPUT_LINES = 120; +/** The width long lines are measured at for LONG_OUTPUT_LINES (a decision made once, at completion, for any window). */ +export const FOLD_REFERENCE_COLUMNS = 120; +/** Important lines (failures, diagnostics) kept visible from a collapsed block's hidden middle. */ +export const FOLD_HIGHLIGHT_LINES = 3; /** Score needed to fold; below it the block stays expanded. */ export const FOLD_THRESHOLD = 3; @@ -84,9 +94,24 @@ function normalize(line: string): string { return line.toLowerCase().replace(/[\da-f]{7,}/gu, 'h').replace(/\d+(?:\.\d+)?/gu, '#').replace(/\s+/gu, ' ').trim(); } +/** How many rows the output takes: logical lines, or more when long lines wrap at the reference width. */ +export function outputExtent(output: string, lineCount: number): number { + let rows = 0; + for (const line of output.split('\n')) { + rows += Math.max(1, Math.ceil(line.length / FOLD_REFERENCE_COLUMNS)); + if (rows >= LONG_OUTPUT_LINES) return Math.max(rows, lineCount); + } + return Math.max(rows, lineCount); +} + export function evaluateFold(input: FoldInput): FoldDecision { - if (input.exitCode !== 0) return {fold: false, score: -Infinity, reasons: ['failed commands stay expanded']}; if (input.lineCount <= MIN_AUTO_FOLD_LINES) return {fold: false, score: -Infinity, reasons: [`${input.lineCount} lines is short`]}; + // Long output folds on its length alone; nothing else is scanned (thousands of lines stay cheap). + const extent = outputExtent(stripAnsi(input.output), input.lineCount); + if (extent >= LONG_OUTPUT_LINES) { + return {fold: true, score: Infinity, reasons: [`${extent >= input.lineCount + 10 ? `${extent} rows when wrapped` : `${input.lineCount} lines`} is long; important lines stay visible`]}; + } + if (input.exitCode !== 0) return {fold: false, score: -Infinity, reasons: ['shorter failed commands stay expanded']}; const lines = stripAnsi(input.output).split('\n').map(line => line.replace(/\r/gu, '')).filter(line => line.trim()); const total = Math.max(1, lines.length); @@ -128,6 +153,22 @@ export function evaluateFold(input: FoldInput): FoldDecision { return {fold: score >= FOLD_THRESHOLD, score, reasons}; } +/** + * Lines worth keeping in view inside a collapsed block's hidden middle: failures and diagnostics (error-like lines, + * compiler locations, the first line of a stack trace), at most `limit`, preferring the last ones (a build's final + * error is usually the one that matters). Indexes into `lines`. Text evidence only: it never decides a failure. + */ +export function foldHighlights(lines: readonly string[], from: number, to: number, limit = FOLD_HIGHLIGHT_LINES): number[] { + const picked: number[] = []; + for (let index = to - 1; index >= from && picked.length < limit; index -= 1) { + const line = stripAnsi(lines[index] ?? ''); + if (!line.trim()) continue; + if (ERRORS.test(line) || COMPILER.test(line)) picked.push(index); + else if (STACK_FRAME.test(line) && !STACK_FRAME.test(stripAnsi(lines[index - 1] ?? ''))) picked.push(index); + } + return picked.reverse(); +} + /** Output long enough that the collapsed head/tail preview still hides lines. */ export function isFoldable(lineCount: number): boolean { return foldWindow(lineCount).head > 0; diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 2760b5da..9227ef14 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -7,7 +7,7 @@ import {PresentationMode} from './PresentationMode.js'; import {CommandClassifier} from './Classifier.js'; import {TranscriptCut} from '../session/TerminalModes.js'; import {type WelcomeCatFrame, type WelcomeSnapshot} from './Welcome.js'; -import {shouldAutoFold, shouldFoldAsk, type OutputFoldingMode, type RecordedAskTurn} from './FoldPolicy.js'; +import {foldHighlights, foldWindow, shouldAutoFold, shouldFoldAsk, type OutputFoldingMode, type RecordedAskTurn} from './FoldPolicy.js'; import {type PromptSnapshot} from '../prompt/snapshot.js'; import {type TranscriptAppearance} from '../prompt/configuration.js'; import {TranscriptPresenter, type TranscriptView} from './TranscriptPresenter.js'; @@ -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; @@ -54,6 +58,11 @@ export interface CompletedCommand { activities?: SecondaryActivity[]; /** An NMSh-owned block rather than a shell command: never history, /copy or shell-output folding input. */ frontend?: 'ask'; + /** + * Some of this command's output was not kept (it finished while no window was attached and the session's + * retention limit dropped output). Its `output` is what remained; copies, reports and comparisons say so. + */ + outputIncomplete?: true; /** A recorded Ask conversation: its visible turns as plain text (version 1). Older transcripts lack it. */ ask?: {version: 1; turns: RecordedAskTurn[]}; } @@ -324,7 +333,8 @@ export class OutputBuffer { this.classifier?.tick(); } - complete(exitCode: number): CompletedCommand | undefined { + /** `holdOpen`: the person is reading the transcript right now (scrolled back, selecting); the block finishes expanded. */ + complete(exitCode: number, options: {holdOpen?: boolean} = {}): CompletedCommand | undefined { const held = this.cut?.flush(); if (held) this.parser.write(held); this.parser.ensureLineBoundary(); @@ -336,7 +346,7 @@ export class OutputBuffer { const output = this.commandOutput(this.active.outputStart, endId); // Activity-bearing parents keep their own disclosure; otherwise the fold // policy decides from the finished output. Presentation only. - const autoFolded = this.active.activities.length === 0 + const autoFolded = this.active.activities.length === 0 && !options.holdOpen && shouldAutoFold(this.outputFolding, {command: this.active.command, output, exitCode, lineCount: endId - this.active.outputStart, ...(this.classifier ? {facts: this.classifier.streamFacts} : {})}); const expanded = this.active.activities.length === 0 && !autoFolded; @@ -445,6 +455,11 @@ export class OutputBuffer { expanded: !shouldFoldAsk(this.outputFolding, body), frontend: 'ask', ask: {version: 1, turns: body.map(turn => ({role: turn.role, text: turn.text}))}}); } + /** Completed shell command records, newest first, as /copy numbers them (NMSh-owned blocks such as recorded Ask are skipped). */ + recentShellCommands(): CompletedCommand[] { + return this.completed.filter(record => !record.frontend); + } + /** The index-th newest shell command record (NMSh-owned blocks such as recorded Ask are skipped). */ recentShell(index: number): CompletedCommand | undefined { return this.completed.filter(record => !record.frontend)[index - 1]; @@ -504,9 +519,29 @@ export class OutputBuffer { historicalContexts: this.historicalContexts, welcome: this.welcome, ownerOf: this.blockOwnership(), + foldHighlights: record => this.foldHighlightsFor(record), }; } + /** Important hidden lines of a collapsed block, by line id; computed on first draw and kept per block identity. */ + private readonly highlightCache = new Map(); + private foldHighlightsFor(record: CompletedCommand): readonly number[] { + if (record.endId === undefined) return []; + const key = `${record.startId}:${record.outputStartId}:${record.endId}`; + const cached = this.highlightCache.get(key); + if (cached) return cached; + const {head, tail} = foldWindow(record.endId - record.outputStartId); + const from = record.outputStartId + head; + const to = record.endId - tail; + // Only the command's own lines: NMSh's lines inside the block are never highlighted as its output. + const lines: string[] = []; + for (let id = from; id < to; id += 1) lines.push(this.lineTypes.get(id) !== 'metadata' ? this.parser.plainLineAt(id) ?? '' : ''); + const picked = foldHighlights(lines, 0, lines.length).map(index => from + index); + if (this.highlightCache.size > 512) this.highlightCache.clear(); + this.highlightCache.set(key, picked); + return picked; + } + /** * Maps a source line to the startId of the command block that owns it. * Blocks span [startId, endId) from the command records; the running @@ -530,8 +565,8 @@ export class OutputBuffer { } /** The finished, aligned sticky row for a block. */ - presentSticky(startId: number, width: number): string | undefined { - return this.presenter.presentSticky(this.view(), startId, width); + presentSticky(startId: number, width: number, trailing = '', trailingWidth = 0): string | undefined { + return this.presenter.presentSticky(this.view(), startId, width, trailing, trailingWidth); } /** One-row sticky rendering of a block's submitted command (see the presenter). */ @@ -547,6 +582,19 @@ export class OutputBuffer { } } + /** + * Unfold these blocks (by startId) as the person's own choice: hints never fold them again, and they can still be + * collapsed by hand. Unknown ids (a cleared or replaced block) are ignored; nothing else changes. + */ + expandBlocks(startIds: readonly number[]): void { + for (const startId of startIds) { + const record = this.completed.find(item => item.startId === startId); + if (!record || record.expanded) continue; + record.expanded = true; + this.userToggled.add(startId); + } + } + /** Blocks the user expanded or collapsed themselves; advisory hints never override them. */ private readonly userToggled = new Set(); diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 7d1a79b3..8e2786fc 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'; @@ -71,6 +72,8 @@ export interface TranscriptView { welcome?: WelcomeSnapshot; /** Structural block ownership from command records (never from text). */ ownerOf: (lineIndex: number) => number | undefined; + /** Line ids worth keeping in view inside a collapsed block's hidden middle (failures, diagnostics). */ + foldHighlights?: (record: CompletedCommand) => readonly number[]; } /** Transient interaction state the app tracks; the presenter decides how it looks. */ @@ -96,7 +99,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); } @@ -204,7 +212,11 @@ export class TranscriptPresenter { } }; pushLines(cmd.outputStartId, cmd.outputStartId + head); - const plain = foldHint(`${hiddenLines - head - tail} lines hidden · Ctrl+O`, '›', width); + // The block's important lines stay in view, in order, after the head (real output rows, unchanged). + const highlights = head ? view.foldHighlights?.(cmd) ?? [] : []; + for (const line of highlights) pushLines(line, line + 1); + const shown = highlights.length ? ` · ${highlights.length} important line${highlights.length === 1 ? '' : 's'} shown` : ''; + const plain = foldHint(`${hiddenLines - head - tail - highlights.length} lines hidden${shown} · Ctrl+O`, '›', width); const ansi = `${foreground(UI_COLORS.secondary)}${plain}\u001B[0m`; result.push({ansi, plain, lineIndex: cmd.outputStartId, isFoldHint: true, commandIndex}); pushLines(cmd.endId - tail, cmd.endId); @@ -294,12 +306,20 @@ export class TranscriptPresenter { return `${' '.repeat(indent)}${COMMAND_SURFACE}${row.replaceAll(RESET, `${RESET}${COMMAND_SURFACE}`)}\u001B[K${RESET}`; } - /** The finished sticky row for a block, aligned like the block's command rows. */ - presentSticky(view: TranscriptView, startId: number, width: number): string | undefined { - const column = this.layout === 'chat' ? chatColumn(width) : undefined; - const row = this.stickyHeaderRow(view, startId, column ?? width); + /** + * The finished sticky row for a block, aligned like the block's command rows. `trailing` (the block's controls, + * `trailingWidth` cells, ending in a reset) sits at the right edge on the same surface; the header is cut to leave + * room for it, so neither covers the other. + */ + presentSticky(view: TranscriptView, startId: number, width: number, trailing = '', trailingWidth = 0): string | undefined { + const available = Math.max(0, width - trailingWidth); + const chat = this.layout === 'chat' ? chatColumn(width) : undefined; + const column = chat === undefined ? undefined : Math.min(chat, available); + const row = this.stickyHeaderRow(view, startId, column ?? available); if (row === undefined) return undefined; - return this.stickyHeaderSurface(row, column === undefined ? 0 : Math.max(0, width - displayWidth(row))); + const indent = column === undefined ? 0 : Math.max(0, available - displayWidth(row)); + if (!trailingWidth) return this.stickyHeaderSurface(row, indent); + return this.stickyHeaderSurface(`${row}${' '.repeat(Math.max(0, available - indent - displayWidth(row)))}${trailing}`, indent); } /** Final ANSI for a visible row: live shimmer, command surface, hover and focus treatment. */ diff --git a/src/output/compare.ts b/src/output/compare.ts new file mode 100644 index 00000000..165e0a12 --- /dev/null +++ b/src/output/compare.ts @@ -0,0 +1,236 @@ +import type {CompletedCommand} from './OutputBuffer.js'; +import {createReportContent, inlineCode, markdownDelimiter, reportText, type ReportOptions, type ReportResult} from '../clipboard/report.js'; + +/** + * Compare output: two executions' stored output, line by line, computed only when asked. Faithful by default (every + * byte of every line counts); ignoring whitespace is an explicit, labelled choice. Sides are always ordered oldest + * first (A ran before B), whatever was picked first. + */ +export interface CompareOptions { + /** Lines that differ only in spacing count as the same (labelled wherever the result is shown or copied). */ + ignoreWhitespace: boolean; + /** Unchanged lines kept around each change. */ + context: number; +} + +export const DEFAULT_COMPARE_OPTIONS: CompareOptions = {ignoreWhitespace: false, context: 3}; + +export type DiffLine = + | {kind: 'same'; a: number; b: number; text: string} + | {kind: 'remove'; a: number; text: string} + | {kind: 'add'; b: number; text: string}; + +export interface Hunk { + /** 0-based first line on each side, and line counts, as in a unified diff header. */ + aStart: number; aCount: number; bStart: number; bCount: number; + lines: DiffLine[]; +} + +export interface Comparison { + a: CompletedCommand; + b: CompletedCommand; + options: CompareOptions; + identical: boolean; + /** Too different to align line by line within the bound: everything is shown as removed then added. */ + unaligned: boolean; + hunks: Hunk[]; + stats: {added: number; removed: number; changed: number; unchanged: number}; +} + +/** The edit-distance bound for the line diff: beyond it the outputs are reported as not alignable. */ +export const MAX_EDIT_DISTANCE = 4000; + +const outputLines = (output: string) => output === '' ? [] : output.replace(/\r\n?/gu, '\n').replace(/\n$/u, '').split('\n'); +const key = (line: string, options: CompareOptions) => options.ignoreWhitespace ? line.trim().replace(/\s+/gu, ' ') : line; + +/** + * Myers' O((N+M)·D) shortest edit script over line keys. Returns undefined when the distance exceeds `bound`. + * Common prefix and suffix are matched first (the usual case for re-runs of one command). + */ +export function diffLines(a: readonly string[], b: readonly string[], bound = MAX_EDIT_DISTANCE): DiffLine[] | undefined { + let head = 0; + while (head < a.length && head < b.length && a[head] === b[head]) head += 1; + let tail = 0; + while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1; + const midA = a.slice(head, a.length - tail); + const midB = b.slice(head, b.length - tail); + const n = midA.length; + const m = midB.length; + const middle: DiffLine[] = []; + if (n === 0 || m === 0) { + for (let index = 0; index < n; index += 1) middle.push({kind: 'remove', a: head + index, text: midA[index]!}); + for (let index = 0; index < m; index += 1) middle.push({kind: 'add', b: head + index, text: midB[index]!}); + } else { + const max = Math.min(n + m, bound); + const offset = max + 1; + const v = new Int32Array(2 * max + 3); + const trace: Int32Array[] = []; + let found = false; + for (let d = 0; d <= max && !found; d += 1) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? v[offset + k + 1]! : v[offset + k - 1]! + 1; + let y = x - k; + while (x < n && y < m && midA[x] === midB[y]) { x += 1; y += 1; } + v[offset + k] = x; + if (x >= n && y >= m) { found = true; break; } + } + } + if (!found) return undefined; + // Walk the trace back from the end to recover the script. + let x = n; + let y = m; + const reversed: DiffLine[] = []; + for (let d = trace.length - 1; d >= 0; d -= 1) { + const vd = trace[d]!; + const k = x - y; + const previousK = k === -d || (k !== d && vd[offset + k - 1]! < vd[offset + k + 1]!) ? k + 1 : k - 1; + const previousX = d === 0 ? 0 : vd[offset + previousK]!; + const previousY = previousX - previousK; + while (x > previousX && y > previousY) { x -= 1; y -= 1; reversed.push({kind: 'same', a: head + x, b: head + y, text: midB[y]!}); } + if (d > 0) { + if (x === previousX) { y -= 1; reversed.push({kind: 'add', b: head + y, text: midB[y]!}); } + else { x -= 1; reversed.push({kind: 'remove', a: head + x, text: midA[x]!}); } + } + } + middle.push(...reversed.reverse()); + } + const result: DiffLine[] = []; + for (let index = 0; index < head; index += 1) result.push({kind: 'same', a: index, b: index, text: b[index]!}); + result.push(...middle); + for (let index = tail; index > 0; index -= 1) result.push({kind: 'same', a: a.length - index, b: b.length - index, text: b[b.length - index]!}); + return result; +} + +/** Compare two records' stored output. `first` and `second` may come in either order; A is always the older run. */ +export function compareRecords(first: CompletedCommand, second: CompletedCommand, options: CompareOptions = DEFAULT_COMPARE_OPTIONS): Comparison { + const [a, b] = (first.startedAt ?? first.startId) <= (second.startedAt ?? second.startId) ? [first, second] : [second, first]; + const aLines = outputLines(a.output); + const bLines = outputLines(b.output); + const aKeys = aLines.map(line => key(line, options)); + const bKeys = bLines.map(line => key(line, options)); + const keyed = diffLines(aKeys, bKeys); + // Show the original text (B's for unchanged lines), whatever the keys were. + const script: DiffLine[] = keyed + ? keyed.map(line => line.kind === 'same' ? {...line, text: bLines[line.b]!} : line.kind === 'remove' ? {...line, text: aLines[line.a]!} : {...line, text: bLines[line.b]!}) + : [...aLines.map((text, index): DiffLine => ({kind: 'remove', a: index, text})), ...bLines.map((text, index): DiffLine => ({kind: 'add', b: index, text}))]; + const stats = {added: 0, removed: 0, changed: 0, unchanged: 0}; + for (let index = 0; index < script.length;) { + const line = script[index]!; + if (line.kind === 'same') { stats.unchanged += 1; index += 1; continue; } + // A run of removals followed by additions: paired lines are changes, the rest pure removals or additions. + let removes = 0; + let adds = 0; + while (script[index]?.kind === 'remove') { removes += 1; index += 1; } + while (script[index]?.kind === 'add') { adds += 1; index += 1; } + const pairs = Math.min(removes, adds); + stats.changed += pairs; + stats.removed += removes - pairs; + stats.added += adds - pairs; + } + return {a, b, options, identical: stats.added + stats.removed + stats.changed === 0, unaligned: !keyed, hunks: hunks(script, options.context), stats}; +} + +/** Group the script into hunks with `context` unchanged lines around each change; longer unchanged runs split hunks. */ +export function hunks(script: readonly DiffLine[], context: number): Hunk[] { + const changes = script.map((line, index) => line.kind === 'same' ? -1 : index).filter(index => index >= 0); + if (!changes.length) return []; + const ranges: Array<[number, number]> = []; + for (const index of changes) { + const from = Math.max(0, index - context); + const to = Math.min(script.length - 1, index + context); + const last = ranges.at(-1); + if (last && from <= last[1] + 1) last[1] = Math.max(last[1], to); + else ranges.push([from, to]); + } + return ranges.map(([from, to]) => { + const lines = script.slice(from, to + 1); + const aLines = lines.filter(line => line.kind !== 'add') as Array>; + const bLines = lines.filter(line => line.kind !== 'remove') as Array>; + // An empty side starts where the other side's position implies (as unified diff does). + const aStart = aLines[0]?.a ?? positionBefore(script, from, 'a'); + const bStart = bLines[0]?.b ?? positionBefore(script, from, 'b'); + return {aStart, aCount: aLines.length, bStart, bCount: bLines.length, lines}; + }); +} + +function positionBefore(script: readonly DiffLine[], index: number, side: 'a' | 'b'): number { + for (let at = index - 1; at >= 0; at -= 1) { + const line = script[at]!; + if (side === 'a' && line.kind !== 'add') return line.a + 1; + if (side === 'b' && line.kind !== 'remove') return line.b + 1; + } + return 0; +} + +const clock = (at: number | undefined) => { + if (at === undefined) return 'time unknown'; + const date = new Date(at); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`; +}; + +/** One side's identity in words: what ran, when, and how it ended. */ +export function sideLabel(record: CompletedCommand): string { + const command = record.command.split('\n')[0]!.slice(0, 120); + return `${command} · ${clock(record.startedAt)} · exit ${record.exitCode}${record.outputIncomplete ? ' · output incomplete' : ''}`; +} + +/** Facts a reader needs before trusting the result (missing or incomplete output, normalization). */ +export function comparisonNotes(comparison: Comparison): string[] { + const notes: string[] = []; + for (const [name, record] of [['A', comparison.a], ['B', comparison.b]] as const) { + if (record.outputIncomplete) notes.push(`${name}'s output is incomplete: part of it was not kept while no window was attached.`); + if (!record.output) notes.push(`${name} printed no output.`); + } + if (comparison.options.ignoreWhitespace) notes.push('Whitespace ignored: lines differing only in spacing count as the same.'); + if (comparison.unaligned) notes.push(`Too different to align line by line (more than ${MAX_EDIT_DISTANCE} edits): shown as all removed, then all added.`); + return notes; +} + +export function summary(comparison: Comparison): string { + if (comparison.identical) return comparison.options.ignoreWhitespace ? 'Same output (whitespace ignored)' : 'Identical output'; + const {added, removed, changed, unchanged} = comparison.stats; + return [changed && `${changed} changed`, added && `${added} added`, removed && `${removed} removed`, `${unchanged} unchanged`].filter(Boolean).join(' · '); +} + +/** A unified diff of the comparison (the format git and patch read), with each side's identity in its header. */ +export function unifiedDiff(comparison: Comparison): string { + const header = [`--- A: ${sideLabel(comparison.a)}`, `+++ B: ${sideLabel(comparison.b)}`]; + const body = comparison.hunks.flatMap(hunk => [ + `@@ -${hunk.aCount ? hunk.aStart + 1 : hunk.aStart},${hunk.aCount} +${hunk.bCount ? hunk.bStart + 1 : hunk.bStart},${hunk.bCount} @@`, + ...hunk.lines.map(line => `${line.kind === 'same' ? ' ' : line.kind === 'add' ? '+' : '-'}${line.text}`), + ]); + return `${[...header, ...body].join('\n')}\n`; +} + +/** The unified diff as it goes to the clipboard: the same characters a report may carry (no controls, no hidden ones). */ +export function copyableDiff(comparison: Comparison): string { + return reportText(unifiedDiff(comparison)); +} + +/** The comparison as a Markdown report: both sides, the summary, notes, and the diff. */ +export function comparisonReport(comparison: Comparison): string { + return buildComparisonReport(comparison, {redact: false}).text; +} + +/** Sanitize/redact data first so generated Markdown boundaries cannot be consumed by credential matching. */ +export function buildComparisonReport(comparison: Comparison, options: Pick): ReportResult { + const content = createReportContent(options); + // Cleaned before the fence is sized: removing hidden characters can join backtick runs or start new lines. + const diff = content.text(copyableDiff(comparison)); + const fence = markdownDelimiter(diff); + const lines = ['# Output comparison', '', `- **A (older):** ${inlineCode(content.text(sideLabel(comparison.a)))}`, `- **B (newer):** ${inlineCode(content.text(sideLabel(comparison.b)))}`, + `- **Result:** ${summary(comparison)}`, ...comparisonNotes(comparison).map(note => `- **Note:** ${note}`), '']; + lines.push(comparison.identical ? '_No differences._' : `${fence}diff\n${diff.replace(/\n$/u, '')}\n${fence}`); + return {text: `${lines.join('\n')}\n`, findings: content.findings()}; +} + +/** + * The run to compare with by default: the most recent earlier execution of exactly the same command (newest-first + * `recent`, as /copy numbers them). Undefined when there is none. + */ +export function previousRun(record: CompletedCommand, recent: readonly CompletedCommand[]): CompletedCommand | undefined { + const same = recent.indexOf(record); + const index = same >= 0 ? same : recent.findIndex(item => item.startId === record.startId); + return recent.slice(index + 1).find(item => item.command.trim() === record.command.trim()); +} diff --git a/src/output/references.ts b/src/output/references.ts new file mode 100644 index 00000000..a0076446 --- /dev/null +++ b/src/output/references.ts @@ -0,0 +1,127 @@ +import {findSourceReferences} from '../host/HostActions.js'; +import {stripAnsi} from '../util/text.js'; + +/** + * References in one command's stored output that can be acted on from its block menu: source locations, web links, + * Git commit ids (only in Git's own output) and Android serials (only in `adb devices`). Recognition is conservative, + * local and non-executing; it runs only when the person asks for a block's references, never while output arrives. + * + * Deterministic: file locations (`path:line[:column]`, the same finder `/open` uses), http(s) links without + * credentials. Heuristic, so limited to the command that prints them: commit ids and device serials. + */ +export type ReferenceKind = 'file' | 'url' | 'commit' | 'device'; + +export interface OutputReference { + /** Stable for this output: kind and value, so an action resolves the same reference again when it runs. */ + id: string; + kind: ReferenceKind; + /** The path as printed (files), the link, the commit id or the serial. */ + value: string; + /** What the menu shows: the reference as it was printed. */ + label: string; + line?: number; + column?: number; + /** Devices: the adb state printed next to the serial; only `device` can take a follow-up command. */ + state?: string; +} + +/** Output lines read, counted from the end, and references kept: a bound for any output size. */ +export const MAX_REFERENCE_LINES = 20_000; +export const MAX_REFERENCES = 50; +const MAX_VALUE_LENGTH = 2000; + +/** Nothing a terminal or a reader could misread: no control, format (zero-width, bidi) or line/paragraph separator characters. */ +const clean = (text: string) => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(text); + +const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`\u007f-\u009f]+/giu; +const PAIRS: Record = {')': '(', ']': '[', '}': '{'}; + +function linkReference(raw: string): OutputReference | undefined { + let text = raw; + // Sentence punctuation after a link is not part of it; a closing bracket is only when the link opened one. + for (;;) { + const last = text.at(-1) ?? ''; + const opener = PAIRS[last]; + const count = (char: string) => text.split(char).length - 1; + if (/[.,;:!?'">]/u.test(last) || (opener && count(opener) < count(last))) text = text.slice(0, -1); + else break; + } + if (!text || text.length > MAX_VALUE_LENGTH || !clean(text)) return undefined; + let url: URL; + try { url = new URL(text); } catch { return undefined; } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + // Credentials in a link are a secret, not a destination. + if (url.username || url.password || !url.hostname) return undefined; + // The normalised form shows an international name as punycode, so a look-alike host cannot pass as a familiar one. + return {id: `url:${url.href}`, kind: 'url', value: url.href, label: url.href}; +} + +const GIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:command\s+|noglob\s+)?git(?:\s|$)/u; +const ADB_DEVICES = /^\s*adb(?:\s+-\S+(?:\s+\S+)?)*\s+devices(?:\s+-l)?\s*$/u; +const COMMIT_ID = /^[0-9a-f]{7,40}$/u; +const isCommitId = (token: string) => COMMIT_ID.test(token) && /\d/u.test(token) && /[a-f]/u.test(token); +const COMMIT_CONTEXTS = [ + /^[\s*|\\/_]*commit\s+([0-9a-f]{7,40})\b/u, + /^[\s*|\\/_]*([0-9a-f]{7,40})(?:\s|$)/u, + /^\[[^\]\n]*\s([0-9a-f]{7,40})\]/u, + /^(?:HEAD is now at|Merge:)\s+([0-9a-f]{7,40})\b/u, +]; + +const SERIAL = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,79}$/u; +const DEVICE_LINE = /^(\S+)\s+(device|offline|unauthorized|recovery|sideload|bootloader|no permissions|authorizing|connecting|host)\b/u; + +export interface ReferenceSource { + command: string; + output: string; +} + +/** The references in this command's output, in the order they were printed, without duplicates. */ +export function findOutputReferences(record: ReferenceSource): OutputReference[] { + const found: OutputReference[] = []; + const seen = new Set(); + const add = (reference: OutputReference) => { + if (found.length >= MAX_REFERENCES || seen.has(reference.id)) return; + seen.add(reference.id); + found.push(reference); + }; + const isGit = GIT_COMMAND.test(record.command); + const isDevices = ADB_DEVICES.test(record.command); + const lines = stripAnsi(record.output).split(/\r\n?|\n/u); + for (const line of lines.slice(-MAX_REFERENCE_LINES)) { + if (found.length >= MAX_REFERENCES) break; + if (line.length > 4000) continue; + for (const match of line.matchAll(URL_PATTERN)) { + const reference = linkReference(match[0]); + if (reference) add(reference); + } + for (const source of findSourceReferences(line)) { + // Dotted numbers (versions, IP addresses with a port) look like "name.ext:line" but are not files. + if (/^\d+(?:\.\d+)+$/u.test(source.path) || !clean(source.path) || source.path.length > MAX_VALUE_LENGTH || !source.line || source.line < 1) continue; + add({id: `file:${source.path}:${source.line}:${source.column ?? ''}`, kind: 'file', value: source.path, label: `${source.path}:${source.line}${source.column ? `:${source.column}` : ''}`, + line: source.line, ...(source.column ? {column: source.column} : {})}); + } + if (isGit) { + for (const pattern of COMMIT_CONTEXTS) { + const id = pattern.exec(line)?.[1]; + if (id && isCommitId(id)) { add({id: `commit:${id}`, kind: 'commit', value: id, label: id}); break; } + } + } + if (isDevices) { + const device = DEVICE_LINE.exec(line); + if (device && SERIAL.test(device[1]!) && clean(device[1]!)) add({id: `device:${device[1]}`, kind: 'device', value: device[1]!, label: `${device[1]} (${device[2]})`, state: device[2]!}); + } + } + return found; +} + +/** + * A command to put in the composer for review (never run): inspect a commit, or open a shell on a connected device. + * undefined when the reference has no follow-up. + */ +export function referenceFollowUp(reference: OutputReference): string[] | undefined { + if (reference.kind === 'commit' && isCommitId(reference.value)) return ['git', 'show', reference.value]; + if (reference.kind === 'device' && reference.state === 'device' && SERIAL.test(reference.value)) return ['adb', '-s', reference.value, 'shell']; + return undefined; +} + +export const REFERENCE_KIND_LABELS: Record = {file: 'File', url: 'Link', commit: 'Commit', device: 'Device'}; diff --git a/src/pins/PinStore.ts b/src/pins/PinStore.ts new file mode 100644 index 00000000..fed3fb6b --- /dev/null +++ b/src/pins/PinStore.ts @@ -0,0 +1,224 @@ +import {closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync} from 'node:fs'; +import {randomBytes} from 'node:crypto'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {scanSensitive} from '../clipboard/report.js'; + +/** + * Pins and recipes: commands the person chose to keep, in one file they can read (`pins.json`, next to config.json). + * + * - Written only by an explicit save, rename, scope change or delete. Nothing is pinned by itself, and command + * output, clipboard text and history are never stored here. + * - A command that looks like it contains a secret is refused, not stored: use a {{placeholder}} for the secret part. + * - Every change re-reads the file under a lock and replaces it with one atomic rename, so two NMSh windows cannot + * overwrite each other's saves, and a crash leaves the old file or the new one, never half of one. + * - A file this version cannot read is reported and left alone, never replaced. + */ +export type PinScope = 'global' | 'directory'; + +export interface Pin { + id: string; + command: string; + /** Optional human name; empty when the person gave none. */ + name: string; + scope: PinScope; + /** The directory the command was pinned from; `directory` pins show first there. */ + cwd?: string; + createdAt: number; +} + +export interface Recipe { + id: string; + name: string; + description: string; + /** Commands in order; each runs as one queue entry. `{{name}}` marks a value asked for when the recipe is used. */ + steps: string[]; + scope: PinScope; + cwd?: string; + createdAt: number; +} + +export interface PinData { + version: 1; + pins: Pin[]; + recipes: Recipe[]; +} + +export const MAX_PINS = 500; +export const MAX_RECIPES = 200; +export const MAX_STEPS = 100; +export const MAX_COMMAND_LENGTH = 64 * 1024; +export const MAX_NAME_LENGTH = 80; + +export const pinsPath = (env: NodeJS.ProcessEnv = process.env): string => join(nmshConfigDirectory(env), 'pins.json'); + +export type PinResult = {ok: true; value: T} | {ok: false; reason: string}; +const failure = (reason: string): {ok: false; reason: string} => ({ok: false, reason}); + +/** One line of plain text for a name: controls, format and bidi characters removed, spaces collapsed, bounded. */ +export function cleanName(text: string): string { + return text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, MAX_NAME_LENGTH); +} + +/** Why a command cannot be kept (a secret in it, or a size), or undefined when it can. */ +export function keepProblem(command: string): string | undefined { + if (!command.trim()) return 'There is nothing to keep.'; + if (command.length > MAX_COMMAND_LENGTH) return 'That command is too long to keep.'; + const secrets = scanSensitive(command).filter(item => item.kind === 'token' || item.kind === 'credential' || item.kind === 'private key'); + if (secrets.length) return 'That command looks like it contains a secret, so it was not saved. Use a {{placeholder}} for the secret part.'; + return undefined; +} + +const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const scopeOf = (value: unknown): PinScope => value === 'directory' ? 'directory' : 'global'; +const absolute = (value: unknown): string | undefined => typeof value === 'string' && value.startsWith('/') && value.length < 4096 && !/[\p{Cc}]/u.test(value) ? value : undefined; + +function parsePin(raw: unknown): Pin | undefined { + if (!isRecord(raw) || typeof raw.id !== 'string' || !/^[\w-]{1,40}$/u.test(raw.id) || typeof raw.command !== 'string' || keepProblem(raw.command) === 'There is nothing to keep.' || raw.command.length > MAX_COMMAND_LENGTH) return undefined; + const cwd = absolute(raw.cwd); + const scope = scopeOf(raw.scope); + return {id: raw.id, command: raw.command, name: typeof raw.name === 'string' ? cleanName(raw.name) : '', scope: scope === 'directory' && !cwd ? 'global' : scope, ...(cwd ? {cwd} : {}), + createdAt: Number.isSafeInteger(raw.createdAt) ? raw.createdAt as number : 0}; +} + +function parseRecipe(raw: unknown): Recipe | undefined { + if (!isRecord(raw) || typeof raw.id !== 'string' || !/^[\w-]{1,40}$/u.test(raw.id) || !Array.isArray(raw.steps) || !raw.steps.length || raw.steps.length > MAX_STEPS) return undefined; + const steps = raw.steps.filter((step): step is string => typeof step === 'string' && step.trim() !== '' && step.length <= MAX_COMMAND_LENGTH); + const name = typeof raw.name === 'string' ? cleanName(raw.name) : ''; + if (steps.length !== raw.steps.length || !name) return undefined; + const cwd = absolute(raw.cwd); + const scope = scopeOf(raw.scope); + return {id: raw.id, name, description: typeof raw.description === 'string' ? cleanName(raw.description) : '', steps, scope: scope === 'directory' && !cwd ? 'global' : scope, ...(cwd ? {cwd} : {}), + createdAt: Number.isSafeInteger(raw.createdAt) ? raw.createdAt as number : 0}; +} + +/** The stored data, or why it cannot be used. A missing file is simply empty. */ +export function loadPins(path = pinsPath()): PinResult { + let text: string; + try { text = readFileSync(path, 'utf8'); } + catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? {ok: true, value: {version: 1, pins: [], recipes: []}} : failure(`Could not read ${path}.`); } + let raw: unknown; + try { raw = JSON.parse(text); } catch { return failure(`${path} is not valid JSON, so it was left alone.`); } + if (!isRecord(raw) || raw.version !== 1) return failure(`${path} is from a different version, so it was left alone.`); + const pins = (Array.isArray(raw.pins) ? raw.pins : []).map(parsePin).filter((pin): pin is Pin => Boolean(pin)).slice(0, MAX_PINS); + const recipes = (Array.isArray(raw.recipes) ? raw.recipes : []).map(parseRecipe).filter((recipe): recipe is Recipe => Boolean(recipe)).slice(0, MAX_RECIPES); + return {ok: true, value: {version: 1, pins, recipes}}; +} + +const LOCK_STALE_MS = 10_000; + +function withLock(path: string, work: () => T): T { + const lock = `${path}.lock`; + const deadline = Date.now() + 3000; + for (;;) { + try { const handle = openSync(lock, 'wx', 0o600); writeSync(handle, String(process.pid)); closeSync(handle); break; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) rmSync(lock, {force: true}); } catch { /* released meanwhile */ } + if (Date.now() > deadline) throw new Error('pins.json is being changed by another NMSh window; try again.'); + const until = Date.now() + 20; + while (Date.now() < until) { /* brief wait; the lock is held for a few milliseconds */ } + } + } + try { return work(); } finally { rmSync(lock, {force: true}); } +} + +/** + * Apply a change to the stored data and save it atomically. `change` receives the freshly re-read data and returns + * the new data, or a reason to refuse (nothing is written then). + */ +export function updatePins(change: (data: PinData) => PinResult<{data: PinData; value: T}>, path = pinsPath()): PinResult { + try { + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + return withLock(path, () => { + const loaded = loadPins(path); + if (!loaded.ok) return loaded; + const changed = change(structuredClone(loaded.value)); + if (!changed.ok) return changed; + const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(changed.value.data, null, 2)}\n`, {mode: 0o600}); + renameSync(temporary, path); + } catch (error) { rmSync(temporary, {force: true}); throw error; } + return {ok: true, value: changed.value.value}; + }); + } catch (error) { return failure(error instanceof Error ? error.message : String(error)); } +} + +const newId = (prefix: string) => `${prefix}${Date.now().toString(36)}${randomBytes(3).toString('hex')}`; + +export function addPin(input: {command: string; name?: string; scope?: PinScope; cwd?: string}, path = pinsPath(), now = Date.now()): PinResult { + const problem = keepProblem(input.command); + if (problem) return failure(problem); + return updatePins(data => { + if (data.pins.length >= MAX_PINS) return failure(`NMSh keeps at most ${MAX_PINS} pins; remove one first.`); + const existing = data.pins.find(pin => pin.command === input.command && pin.scope === (input.scope ?? 'global') && (pin.scope === 'global' || pin.cwd === input.cwd)); + if (existing) return failure('That command is already pinned.'); + const cwd = absolute(input.cwd); + const scope = input.scope === 'directory' && cwd ? 'directory' : 'global'; + const pin: Pin = {id: newId('p'), command: input.command, name: cleanName(input.name ?? ''), scope, ...(cwd ? {cwd} : {}), createdAt: now}; + return {ok: true, value: {data: {...data, pins: [pin, ...data.pins]}, value: pin}}; + }, path); +} + +export function addRecipe(input: {name: string; description?: string; steps: readonly string[]; scope?: PinScope; cwd?: string}, path = pinsPath(), now = Date.now()): PinResult { + const name = cleanName(input.name); + if (!name) return failure('A recipe needs a name.'); + if (!input.steps.length) return failure('A recipe needs at least one command.'); + if (input.steps.length > MAX_STEPS) return failure(`A recipe holds at most ${MAX_STEPS} commands.`); + for (const step of input.steps) { const problem = keepProblem(step); if (problem) return failure(problem); } + return updatePins(data => { + if (data.recipes.length >= MAX_RECIPES) return failure(`NMSh keeps at most ${MAX_RECIPES} recipes; remove one first.`); + if (data.recipes.some(recipe => recipe.name.toLowerCase() === name.toLowerCase())) return failure(`A recipe named "${name}" already exists.`); + const cwd = absolute(input.cwd); + const scope = input.scope === 'directory' && cwd ? 'directory' : 'global'; + const recipe: Recipe = {id: newId('r'), name, description: cleanName(input.description ?? ''), steps: [...input.steps], scope, ...(cwd ? {cwd} : {}), createdAt: now}; + return {ok: true, value: {data: {...data, recipes: [recipe, ...data.recipes]}, value: recipe}}; + }, path); +} + +export type PinChange = {name: string} | {scope: PinScope} | {steps: readonly string[]}; + +/** Rename, change scope, or (recipes) replace the steps of the pin or recipe with this id. */ +export function changeItem(id: string, change: PinChange, path = pinsPath()): PinResult { + return updatePins(data => { + const pin = data.pins.find(item => item.id === id); + const recipe = data.recipes.find(item => item.id === id); + const item = pin ?? recipe; + if (!item) return failure('That item no longer exists.'); + if ('name' in change) { + const name = cleanName(change.name); + if (recipe && !name) return failure('A recipe needs a name.'); + if (recipe && data.recipes.some(other => other.id !== id && other.name.toLowerCase() === name.toLowerCase())) return failure(`A recipe named "${name}" already exists.`); + item.name = name; + } else if ('scope' in change) { + if (change.scope === 'directory' && !item.cwd) return failure('It was not pinned from a directory, so it can only be global.'); + item.scope = change.scope; + } else { + if (!recipe) return failure('Only a recipe has steps.'); + if (!change.steps.length || change.steps.length > MAX_STEPS) return failure(`A recipe holds 1 to ${MAX_STEPS} commands.`); + for (const step of change.steps) { const problem = keepProblem(step); if (problem) return failure(problem); } + recipe.steps = [...change.steps]; + } + return {ok: true, value: {data, value: true as const}}; + }, path); +} + +export function removeItem(id: string, path = pinsPath()): PinResult { + return updatePins(data => { + const pins = data.pins.filter(item => item.id !== id); + const recipes = data.recipes.filter(item => item.id !== id); + if (pins.length === data.pins.length && recipes.length === data.recipes.length) return failure('That item no longer exists.'); + return {ok: true, value: {data: {...data, pins, recipes}, value: true as const}}; + }, path); +} + +/** A directory item applies in its own directory and inside it; a global one applies everywhere. */ +export const applies = (item: {scope: PinScope; cwd?: string}, cwd: string): boolean => + item.scope === 'global' || (item.cwd !== undefined && (cwd === item.cwd || cwd.startsWith(`${item.cwd.replace(/\/+$/u, '')}/`))); + +/** What to show in a directory: the items that apply there (this directory's own first), newest first within each; `all` shows every item. */ +export function inScope(items: readonly T[], cwd: string, all = false): T[] { + const here = (item: T) => item.scope === 'directory' && item.cwd !== undefined && applies(item, cwd); + return items.filter(item => all || applies(item, cwd)).sort((a, b) => Number(here(b)) - Number(here(a)) || b.createdAt - a.createdAt); +} diff --git a/src/pins/PinsPanel.ts b/src/pins/PinsPanel.ts new file mode 100644 index 00000000..43d588a9 --- /dev/null +++ b/src/pins/PinsPanel.ts @@ -0,0 +1,199 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {displaySafe} from '../input/PasteReview.js'; +import {inScope, type Pin, type PinData, type Recipe} from './PinStore.js'; +import {placeholdersIn, valueProblem} from './recipes.js'; +import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; + +/** + * `/pins`: the commands and recipes the person chose to keep, as one keyboard-first list. It stores nothing itself + * (every change is an action the app applies to pins.json) and runs nothing: Enter on a pin puts its command in the + * composer for review, and Enter on a recipe asks for its values, shows the expanded commands, and only queues them + * when the person confirms there. + */ +export type PanelItem = {kind: 'pin'; pin: Pin} | {kind: 'recipe'; recipe: Recipe}; + +export interface PinsPanelState { + data: PinData; + cwd: string; + /** Show items kept for other directories too. */ + all: boolean; + selected: number; + mode: 'list' | 'rename' | 'delete' | 'fill' | 'name'; + /** The text being typed in rename / fill / name. */ + input: string; + /** Asking for a recipe's values, one at a time. */ + fill?: {recipe: Recipe; names: string[]; index: number; values: Record}; + /** Steps waiting for a name (a queue saved as a recipe). */ + naming?: {steps: string[]}; + /** What the last action did or why it could not. */ + message?: {text: string; error: boolean}; + /** The composer has text a new pin could keep; an editor can edit a recipe. */ + composerText: string; + canEdit: boolean; +} + +export type PinsPanelAction = + | {kind: 'close'} + | {kind: 'stage'; command: string} + | {kind: 'use'; recipe: Recipe; values: Record} + | {kind: 'rename'; id: string; name: string} + | {kind: 'scope'; id: string; scope: 'global' | 'directory'} + | {kind: 'delete'; id: string} + | {kind: 'pinComposer'} + | {kind: 'edit'; recipe: Recipe} + | {kind: 'saveRecipe'; name: string; steps: string[]}; + +export function createPinsPanel(data: PinData, cwd: string, options: {composerText?: string; canEdit?: boolean; naming?: string[]} = {}): PinsPanelState { + const state: PinsPanelState = {data, cwd, all: false, selected: 0, mode: 'list', input: '', composerText: options.composerText ?? '', canEdit: options.canEdit ?? false}; + if (options.naming?.length) { state.mode = 'name'; state.naming = {steps: options.naming}; } + return state; +} + +export function panelItems(state: PinsPanelState): PanelItem[] { + const pins = inScope(state.data.pins, state.cwd, state.all).map((pin): PanelItem => ({kind: 'pin', pin})); + const recipes = inScope(state.data.recipes, state.cwd, state.all).map((recipe): PanelItem => ({kind: 'recipe', recipe})); + return [...recipes, ...pins]; +} + +/** Items kept for other directories and not shown, for the header. */ +export const hiddenCount = (state: PinsPanelState): number => state.data.pins.length + state.data.recipes.length - panelItems({...state, all: false}).length; + +const idOf = (item: PanelItem) => item.kind === 'pin' ? item.pin.id : item.recipe.id; +const text = (key: Key): string | undefined => key.kind === 'text' ? key.value : key.kind === 'paste' ? key.value.replace(/\s+/gu, ' ') : undefined; + +export function pinsPanelKey(state: PinsPanelState, key: Key): PinsPanelAction | undefined { + const items = panelItems(state); + state.selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const item = items[state.selected]; + if (state.mode === 'delete') { + state.mode = 'list'; + if (key.kind === 'text' && key.value.toLowerCase() === 'y' && item) return {kind: 'delete', id: idOf(item)}; + return undefined; + } + if (state.mode === 'rename' || state.mode === 'name' || state.mode === 'fill') return inputKey(state, key, item); + state.message = undefined; + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { + if (items.length) state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + items.length) % items.length; + return undefined; + } + if (key.kind === 'text' && key.value === 'a') { state.all = !state.all; state.selected = 0; return undefined; } + if (key.kind === 'text' && key.value === 'n') return state.composerText.trim() ? {kind: 'pinComposer'} : (state.message = {text: 'Type or recall a command in the composer first; n pins what is there.', error: false}, undefined); + if (!item) return undefined; + if (key.kind === 'enter') { + if (item.kind === 'pin') return {kind: 'stage', command: item.pin.command}; + const names = placeholdersIn(item.recipe.steps); + if (!names.length) return {kind: 'use', recipe: item.recipe, values: {}}; + state.mode = 'fill'; + state.fill = {recipe: item.recipe, names, index: 0, values: {}}; + state.input = ''; + return undefined; + } + if (key.kind === 'text' && key.value === 'r') { state.mode = 'rename'; state.input = item.kind === 'pin' ? item.pin.name : item.recipe.name; return undefined; } + if (key.kind === 'text' && key.value === 's' && item.kind === 'pin') { + return {kind: 'scope', id: item.pin.id, scope: item.pin.scope === 'global' ? 'directory' : 'global'}; + } + if (key.kind === 'text' && key.value === 's' && item.kind === 'recipe') return {kind: 'scope', id: item.recipe.id, scope: item.recipe.scope === 'global' ? 'directory' : 'global'}; + if (key.kind === 'text' && key.value === 'e' && item.kind === 'recipe' && state.canEdit) return {kind: 'edit', recipe: item.recipe}; + if (key.kind === 'delete' || key.kind === 'backspace' || (key.kind === 'text' && key.value === 'x')) { state.mode = 'delete'; return undefined; } + return undefined; +} + +function inputKey(state: PinsPanelState, key: Key, item: PanelItem | undefined): PinsPanelAction | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') { + state.mode = 'list'; + state.input = ''; + state.fill = undefined; + state.naming = undefined; + return undefined; + } + if (key.kind === 'backspace') { state.input = Array.from(state.input).slice(0, -1).join(''); return undefined; } + const typed = text(key); + if (typed !== undefined) { state.input = (state.input + typed.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')).slice(0, 500); return undefined; } + if (key.kind !== 'enter') return undefined; + const value = state.input.trim(); + if (state.mode === 'rename' && item) { + state.mode = 'list'; + state.input = ''; + return {kind: 'rename', id: idOf(item), name: value}; + } + if (state.mode === 'name' && state.naming) { + const steps = state.naming.steps; + state.mode = 'list'; + state.naming = undefined; + state.input = ''; + return {kind: 'saveRecipe', name: value, steps}; + } + if (state.mode === 'fill' && state.fill) { + const problem = valueProblem(value); + if (problem) { state.message = {text: problem, error: true}; return undefined; } + state.message = undefined; + const fill = state.fill; + fill.values[fill.names[fill.index]!] = value; + fill.index += 1; + state.input = ''; + if (fill.index >= fill.names.length) { state.mode = 'list'; state.fill = undefined; return {kind: 'use', recipe: fill.recipe, values: fill.values}; } + } + return undefined; +} + +function label(item: PanelItem): string { + // Drawn text only: control and format characters are made visible wherever the label is used. + return displaySafe(item.kind === 'recipe' ? item.recipe.name : item.pin.name || item.pin.command.split('\n')[0]!.trim()); +} + +/** The panel's rows (unframed). */ +export function renderPinsPanel(state: PinsPanelState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const failure = foreground(UI_COLORS.failure); + const reset = '\u001b[0m'; + const width = Math.max(10, columns - 4); + const items = panelItems(state); + const hidden = hiddenCount(state); + const out = [` ${primary}Pins and recipes${reset} ${subtle}${truncateText(`${items.length} here${hidden && !state.all ? `, ${hidden} kept for other folders (a)` : ''}`, Math.max(4, width - 20))}${reset}`, '']; + if (!items.length) out.push(` ${secondary}${truncateText(state.all ? 'Nothing kept yet.' : 'Nothing kept for this folder. a shows the other folders\' items.', width)}${reset}`, + ` ${subtle}${truncateText('Pin a command from its block (Actions → Pin command), or n pins what is in the composer. A recipe is saved from the queue.', width)}${reset}`); + const controls = renderControlRows(state.mode === 'list' + ? [['↑↓', 'select'], ['Enter', 'use'], ['r', 'rename'], ['s', 'scope'], ['x', 'remove'], ['n', 'pin composer'], ['a', 'all folders'], ...(state.canEdit ? [['e', 'edit recipe'] as const] : []), ['Esc', 'close']] + : [['Enter', state.mode === 'fill' ? 'next' : 'save'], ['Esc', 'cancel']], width); + const listRoom = Math.max(1, height - out.length - controls.length - 7); + const first = Math.max(0, Math.min(state.selected - Math.floor(listRoom / 2), items.length - listRoom)); + items.slice(first, first + listRoom).forEach((item, offset) => { + const index = first + offset; + const selected = index === state.selected; + const owner = item.kind === 'pin' ? item.pin : item.recipe; + const kind = item.kind === 'recipe' ? `recipe · ${item.recipe.steps.length} step${item.recipe.steps.length === 1 ? '' : 's'}` : 'pin'; + const where = owner.scope === 'directory' ? ' · this folder' : ''; + const tail = `${kind}${where}`; + const body = truncateText(displaySafe(label(item)), Math.max(8, width - displayWidth(tail) - 6)); + out.push(truncateAnsi(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${focusForeground(selected)}${padCells(body, Math.max(8, width - displayWidth(tail) - 4))}${reset} ${subtle}${tail}${reset}`, columns)); + }); + const selected = items[state.selected]; + if (selected && state.mode === 'list') { + out.push(''); + if (selected.kind === 'pin') { + out.push(` ${subtle}Stays as written; Enter puts it in the composer, it does not run:${reset}`); + for (const line of selected.pin.command.split('\n').slice(0, 4)) out.push(` ${secondary}${truncateText(displaySafe(line), width - 2)}${reset}`); + if (selected.pin.cwd) out.push(` ${subtle}${truncateText(`Pinned in ${selected.pin.cwd}`, width)}${reset}`); + } else { + const names = placeholdersIn(selected.recipe.steps); + out.push(` ${subtle}${truncateText(`Enter asks for ${names.length ? names.map(name => `{{${name}}}`).join(', ') : 'nothing'}, then shows the commands before anything is queued:`, width)}${reset}`); + for (const [index, step] of selected.recipe.steps.slice(0, 3).entries()) out.push(` ${secondary}${truncateText(`${index + 1} ${displaySafe(step.split('\n')[0]!)}`, width - 2)}${reset}`); + if (selected.recipe.steps.length > 3) out.push(` ${subtle}… ${selected.recipe.steps.length - 3} more${reset}`); + } + } + out.push(''); + if (state.mode === 'delete' && selected) out.push(` ${failure}Remove “${truncateText(label(selected), Math.max(4, width - 24))}”? y / n${reset}`); + else if (state.mode === 'rename') out.push(` ${accent}Name${reset} ${state.input}${accent}▏${reset}${subtle} (empty clears a pin's name)${reset}`); + else if (state.mode === 'name') out.push(` ${accent}Recipe name${reset} ${state.input}${accent}▏${reset}${subtle} (${state.naming?.steps.length ?? 0} command${state.naming?.steps.length === 1 ? '' : 's'})${reset}`); + else if (state.mode === 'fill' && state.fill) out.push(` ${accent}{{${state.fill.names[state.fill.index]}}}${reset} ${state.input}${accent}▏${reset}${subtle} (${state.fill.index + 1} of ${state.fill.names.length}; used as one quoted word)${reset}`); + if (state.message) out.push(` ${state.message.error ? failure : subtle}${truncateText(state.message.text, width)}${reset}`); + out.push(...controls.map(row => ` ${row}`)); + return out.map(row => truncateAnsi(row, columns)).slice(0, Math.max(4, height)); +} diff --git a/src/pins/recipes.ts b/src/pins/recipes.ts new file mode 100644 index 00000000..03788b0d --- /dev/null +++ b/src/pins/recipes.ts @@ -0,0 +1,64 @@ +import {posixQuote} from '../shell/adapters/ShellAdapter.js'; + +/** + * Recipe placeholders: `{{name}}` in a step marks a value asked for when the recipe is used. The value is put into + * the command as ONE shell word, quoted for the shell that will run it, so it can never add an option, a second + * command or an expansion. Nothing is taken from the environment, and nothing but the person's answer fills a + * placeholder. + */ +export const PLACEHOLDER = /\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/gu; +export const MAX_VALUE_LENGTH = 500; + +/** The placeholder names in these steps, in order of first use. */ +export function placeholdersIn(steps: readonly string[]): string[] { + const names: string[] = []; + for (const step of steps) for (const match of step.matchAll(PLACEHOLDER)) if (!names.includes(match[1]!)) names.push(match[1]!); + return names; +} + +/** + * A placeholder inside quotes would end up quoted twice. Returns the first such placeholder's name, or undefined. + * The scan follows shell quoting: single quotes, double quotes and backslashes (a backtick or `$( )` is not tracked, + * which can only miss, never invent, a problem). + */ +export function quotedPlaceholder(step: string): string | undefined { + let quote: '"' | '\'' | undefined; + for (let index = 0; index < step.length; index += 1) { + const char = step[index]!; + if (quote === '\'') { + if (char === '\'') quote = undefined; + else if (step.startsWith('{{', index)) { const match = /^\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/u.exec(step.slice(index)); if (match) return match[1]; } + continue; + } + if (char === '\\') { index += 1; continue; } + if (quote === '"') { if (char === '"') { quote = undefined; continue; } } + else if (char === '"' || char === '\'') { quote = char; continue; } + if (quote && step.startsWith('{{', index)) { const match = /^\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/u.exec(step.slice(index)); if (match) return match[1]; } + } + return undefined; +} + +/** Why an answer cannot be used as a command argument, or undefined when it can. */ +export function valueProblem(value: string): string | undefined { + if (!value) return 'Enter a value.'; + if (value.length > MAX_VALUE_LENGTH) return `Keep it under ${MAX_VALUE_LENGTH} characters.`; + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return 'It cannot contain line breaks or control characters.'; + if (value.startsWith('-')) return 'It cannot start with "-": the command would read it as an option. Use ./ in front of a file name.'; + return undefined; +} + +/** The steps with every placeholder replaced by its quoted value; a placeholder with no value is an error, never left in. */ +export function expandSteps(steps: readonly string[], values: Readonly>, quote: (value: string) => string = posixQuote): {ok: true; steps: string[]} | {ok: false; reason: string} { + const expanded: string[] = []; + for (const step of steps) { + let missing: string | undefined; + const text = step.replace(PLACEHOLDER, (_match, name: string) => { + const value = values[name]; + if (value === undefined || valueProblem(value)) { missing ??= name; return ''; } + return quote(value); + }); + if (missing) return {ok: false, reason: `No usable value for {{${missing}}}.`}; + expanded.push(text); + } + return {ok: true, steps: expanded}; +} diff --git a/src/prompt/ModulesPanel.ts b/src/prompt/ModulesPanel.ts index df205d2e..9605623d 100644 --- a/src/prompt/ModulesPanel.ts +++ b/src/prompt/ModulesPanel.ts @@ -44,7 +44,11 @@ export interface ModulesManagerState { search?: {query: string; editing: boolean}; /** Pack modules shown or hidden during this visit stay listed under Modules, so a row never vanishes under the cursor. */ touched?: string[]; - detail?: {kind: 'module' | 'pack'; id: string}; + detail?: { + kind: 'module' | 'pack'; id: string; scroll?: number; + /** Ephemeral bounds from the rendered detail window; never saved as settings. */ + viewport?: {maxScroll: number; page: number}; + }; /** A reviewed change awaiting Enter (Claude Code settings edit, pack removal). */ confirm?: {request: ModulesRequest; title: string; lines: string[]}; request?: ModulesRequest; @@ -69,7 +73,9 @@ export interface PackListing { } export interface ModulesContext { - status?(capability: string): {state: string; collectedAt?: number; evidence?: string; error?: string}; + /** Existing live display context; a false result is distinct from unavailable collection. */ + conditionMet?(module: ContextModuleConfig): boolean; + status?(capability: string): {state: string; collectedAt?: number; evidence?: string; error?: string; durationMs?: number; hasValue?: boolean}; packs?: readonly PackListing[]; recommendations?: readonly Recommendation[]; claudeBridge?: {state: string; detail?: string}; @@ -257,6 +263,13 @@ export function handleModulesManagerKey(key: Key, state: ModulesState, context: } if (state.detail) { if (key.kind === 'escape' || key.kind === 'interrupt') { state.detail = undefined; return true; } + if (key.kind === 'up' || key.kind === 'down' || key.kind === 'pageUp' || key.kind === 'pageDown') { + const detail = state.detail; + const amount = key.kind === 'pageUp' || key.kind === 'pageDown' ? detail.viewport?.page ?? 1 : 1; + const direction = key.kind === 'up' || key.kind === 'pageUp' ? -1 : 1; + detail.scroll = Math.max(0, Math.min(detail.viewport?.maxScroll ?? 0, (detail.scroll ?? 0) + direction * amount)); + return true; + } if (state.detail.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); if (pack && !pack.builtIn && key.kind === 'text' && /^[eE]$/u.test(key.value)) { state.request = {kind: 'packEnabled', id: pack.id, enabled: pack.state !== 'enabled'}; return true; } @@ -364,7 +377,7 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, rows.push(truncateAnsi(`${PRIMARY}${clean(definition.label, 48)}${RESET} ${SUBTLE}${CATEGORY_LABELS[definition.category]}${narrow ? '' : ` · ${origin}`}${RESET}`, columns), ...(narrow ? wrapped(origin, columns, SUBTLE) : []), ...wrapped(clean(definition.description, 240), columns, SECONDARY, '', 4), ''); const surface = routeModule({...module, visible: true}); - rows.push(...line('Shown', `${module.visible ? 'yes' : 'no'} · ${CONDITION_TEXT[module.condition]} · ${surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]}` + rows.push(...line('Enabled', `${module.visible ? 'yes' : 'no'} · ${CONDITION_TEXT[module.condition]} · ${surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]}` + `${module.surface === 'auto' || module.surface === undefined ? '' : ` (prefers ${MODULE_SURFACE_LABELS[definition.preferredSurface as ModuleSurface]})`}`)); if (surface === 'statusStrip') rows.push(...line('Strip group', `${({left: 'Left', center: 'Center', right: 'Right'})[module.stripZone ?? 'right']} · /strip arranges groups and order`)); if (surface !== 'hidden') { @@ -378,20 +391,35 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, return capability ? capability.reads.map(read => `${capability.title}: ${read}`) : LEGACY_READS[id] ? [LEGACY_READS[id]!] : []; }); rows.push(`${SUBTLE}Reads${RESET}`, ...[...new Set(reads)].flatMap(read => wrapped(read, columns - 2, SECONDARY, ' ', 4).map((text, index) => index ? ` ${text}` : text.replace(/^ {2}/u, ' · ')))); - rows.push(...wrapped('Nothing is executed from the workspace, no network is used, and secret values are never read.', columns, SECONDARY, ' ')); + rows.push(...wrapped('Nothing is executed from the workspace and no network is used. Only approved metadata is exposed; secret values do not belong in module output or history.', columns, SECONDARY, ' ')); const capabilities = [...definition.facts.keys()].map(id => coreCapability(id)).filter(Boolean); const history = capabilities.length && capabilities.every(capability => capability!.persistence === 'snapshot-safe'); const privateFields = capabilities.flatMap(capability => Object.entries(capability!.fieldPolicy ?? {}).filter(([, policy]) => policy.persistence !== 'snapshot-safe').map(([field]) => field)); rows.push(...line('History', definition.pack ? (history ? `may be kept with commands when shown in the Main Prompt${privateFields.length ? ` (never: ${privateFields.join(', ')})` : ''}` : 'live only; never kept in command history') : 'kept with commands when shown in the Main Prompt')); for (const capability of capabilities) { + // Declarative policy and existing snapshots only: opening details never demands a collector. + rows.push(...line('Source', clean(`${capability!.id} · ${capability!.scope} scope · ${capability!.trust}`, 200))); + const ttl = `${capability!.ttlMs} ms freshness TTL`; + const invalidation = capability!.invalidateOn.length ? `invalidated by ${capability!.invalidateOn.join(', ')}` : 'no event invalidation'; + const refresh = capability!.refreshMs === undefined ? '' : ` · refresh every ${capability!.refreshMs} ms while demanded`; + rows.push(...line('Cache', clean(`${ttl} · ${invalidation}${refresh}`, 240))); const status = context.status?.(capability!.id); if (!status) continue; const age = status.collectedAt !== undefined && context.now !== undefined ? ` · ${Math.max(0, Math.round((context.now - status.collectedAt) / 1000))}s ago` : ''; - const text = status.state === 'fresh' ? `fresh${age}` : status.state === 'stale' ? `last known${age}, refreshing` : status.state === 'absent' ? 'nothing to show here' - : status.state === 'pending' ? 'checking…' : status.state === 'timeout' || status.state === 'failed' ? `unavailable (${status.error ?? status.state})` : 'not checked (module not in use)'; - rows.push(...line(narrow ? capability!.title : capability!.title.slice(0, 11), clean(`${text}${status.evidence ? ` · ${status.evidence}` : ''}`, 200))); + const text = status.state === 'fresh' ? `fresh${age}` : status.state === 'stale' ? `last known${age}, refresh waits for demand` : status.state === 'absent' ? 'nothing to show here' + : status.state === 'pending' ? `checking…${status.hasValue ? ' · last known data retained' : ''}` + : status.state === 'timeout' || status.state === 'failed' ? `unavailable (${status.state})${status.hasValue ? ' · last known data retained' : ''}` : 'not checked'; + // How long the last collection took is measured by the engine; nothing is collected to show it. + const took = status.durationMs === undefined ? '' : ` · took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; + rows.push(...line(narrow ? capability!.title : capability!.title.slice(0, 11), clean(`${text}${took}${status.evidence ? ` · ${status.evidence}` : ''}`, 200))); } + if (capabilities.length) rows.push(...line('Timing', 'last collection wall time; excludes queue delay. Opening details does not collect data.')); + const availability = surface === 'hidden' ? undefined : surfaceAvailability(surface, configuration); + rows.push(...line('Why', !module.visible ? 'turned off in Modules' + : availability && !availability.available ? availability.reason + : context.conditionMet?.(module) === false ? `hidden: current condition does not match (${CONDITION_TEXT[module.condition]})` + : whyText(module, surface, capabilities.map(capability => context.status?.(capability!.id)).filter((status): status is NonNullable => Boolean(status)), definition.triggers))); if (definition.facts.has('agent.claude')) { const bridge = context.claudeBridge; rows.push('', ...line('Claude Code', bridge?.state === 'configured' ? 'reports to NMSh through its status line · B to review removal' @@ -401,6 +429,28 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, return rows; } +/** One sentence on why this module is or is not on screen right now, from its settings and what the engine reported. */ +export function whyText(module: ContextModuleConfig, surface: string, statuses: ReadonlyArray<{state: string; error?: string; hasValue?: boolean}>, triggers?: readonly string[]): string { + if (!module.visible) return 'turned off in Modules'; + if (surface === 'hidden') return 'routed to Hidden'; + const failed = statuses.filter(status => status.state === 'timeout' || status.state === 'failed'); + if (failed.length) { + const reason = failed.some(status => status.state === 'timeout') ? 'its data timed out' : 'its data could not be read; collection failed'; + const cache = failed.some(status => status.hasValue) ? 'last known data is retained; no new value was collected' + : failed.every(status => status.hasValue === false) ? 'no cached value is available' : 'cached-data availability is unknown'; + return `${reason}; ${cache}`; + } + if (statuses.length && statuses.every(status => status.state === 'absent')) return 'there is nothing for it to show in this directory'; + if (statuses.length && statuses.every(status => status.state === 'idle') && module.condition === 'onCommand') { + return `shown after a command that uses it${triggers?.length ? ` (${clean(triggers.slice(0, 4).join(', '), 60)}…)` : ''}; no collection evidence is available yet`; + } + if (statuses.some(status => status.state === 'pending')) return 'being collected now'; + if (statuses.some(status => status.state === 'stale')) return 'last known data is available; refresh waits for demand. Visibility follows the module condition and available space'; + if (statuses.length && statuses.every(status => status.state === 'idle')) return 'not collected yet; only modules in use demand collection'; + if (statuses.some(status => status.state === 'fresh')) return 'data is available; visibility follows the module condition and available space'; + return 'collection evidence is not available; visibility follows the module condition and available space'; +} + function packDetailRows(pack: PackListing, columns: number): string[] { const rows = [truncateAnsi(`${PRIMARY}${clean(pack.name, 48)}${RESET} ${SUBTLE}${clean(pack.id, 64)} ${clean(pack.version, 48)} · ${pack.builtIn ? 'bundled with NMSh' : clean(pack.state, 24)}${RESET}`, columns), truncateAnsi(`${SECONDARY}${clean(pack.description, 240)}${RESET}`, columns), '']; @@ -434,17 +484,34 @@ function whereText(module: ContextModuleConfig, configuration: PromptConfigurati const label = (id: string) => clean(moduleDefinition(id)?.label ?? `${id.slice(0, 40)} (missing pack)`, 64); /** A window of list rows around the selection, with counts of what is above and below. */ -function windowed(rows: string[], selectedRow: number, budget: number): string[] { - if (rows.length <= budget || budget < 5) return rows; +function windowed(rows: string[], selectedRow: number, budget: number, fromStart = false): string[] { + if (rows.length <= budget) return rows; + if (budget < 5) { + const start = Math.max(0, Math.min(rows.length - Math.max(0, budget), selectedRow)); + return rows.slice(start, start + Math.max(0, budget)); + } const size = budget - 2; - const start = Math.max(0, Math.min(rows.length - size, selectedRow - Math.floor(size / 2))); + const start = Math.max(0, Math.min(rows.length - size, selectedRow - (fromStart ? 0 : Math.floor(size / 2)))); const end = start + size; return [start ? `${SUBTLE} ↑ ${start} more${RESET}` : '', ...rows.slice(start, end), end < rows.length ? `${SUBTLE} ↓ ${rows.length - end} more${RESET}` : '']; } export function renderModulesManager(state: ModulesState, columns: number, context: ModulesContext = state.context ?? {}, budget = Infinity): string[] { const all = renderManagerRows(state, columns, context); - if (state.detail || state.confirm || all.rows.length <= budget) return all.rows; + if (state.detail) { + // Only ephemeral view bounds change here. Facts, preferences and demand do + // not change when the terminal is resized or the reader pages through data. + const head = Math.min(all.head, Math.max(0, budget - 1)); + const content = all.rows.slice(head); + const available = Math.max(0, budget - head); + const page = Math.max(1, Math.min(content.length, available < 5 ? available : available - 2)); + const maxScroll = Math.max(0, content.length - page); + const scroll = state.detail.scroll ?? 0; + state.detail.scroll = Number.isSafeInteger(scroll) ? Math.max(0, Math.min(maxScroll, scroll)) : 0; + state.detail.viewport = {maxScroll, page}; + return [...all.rows.slice(0, head), ...windowed(content, state.detail.scroll, available, true)]; + } + if (state.confirm || all.rows.length <= budget) return all.rows; return [...all.rows.slice(0, all.head), ...windowed(all.rows.slice(all.head), all.selectedRow - all.head, budget - all.head)]; } @@ -470,6 +537,7 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module return {rows, head: rows.length, selectedRow: 0}; } if (state.detail) { + const head = rows.length; if (state.detail.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); rows.push(...(pack ? packDetailRows(pack, columns) : [`${SECONDARY}That pack is no longer available.${RESET}`])); @@ -477,7 +545,7 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module const module = state.draft.modules.find(item => item.id === state.detail!.id); rows.push(...(module ? moduleDetailRows(module, context, columns, state.draft) : [`${SECONDARY}That module is no longer configured.${RESET}`])); } - return {rows, head: rows.length, selectedRow: 0}; + return {rows, head: Math.min(rows.length, head + 1), selectedRow: head}; } const item = (index: number, text: string) => { if (index === state.selectedIndex) selectedRow = rows.length; @@ -534,9 +602,10 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module export function modulesManagerControls(state: ModulesState, context: ModulesContext = state.context ?? {}): Array<[string, string]> { if (state.confirm) return [['Enter', 'apply'], ['Esc', 'cancel']]; + const read: Array<[string, string]> = state.detail?.viewport?.maxScroll ? [['↑↓', 'scroll'], ['PgUp/PgDn', 'page']] : []; if (state.detail?.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); - return pack && !pack.builtIn ? [['E', pack.state === 'enabled' ? 'disable' : 'enable'], ['D', 'remove'], ['Esc', 'back']] : [['Esc', 'back']]; + return pack && !pack.builtIn ? [...read, ['E', pack.state === 'enabled' ? 'disable' : 'enable'], ['D', 'remove'], ['Esc', 'back']] : [...read, ['Esc', 'back']]; } const done: [string, string] = ['Esc', state.standalone ? 'close' : 'back to /prompt']; const apply: Array<[string, string]> = !state.standalone && modulesDirty(state) ? [['A', 'save modules now']] : []; @@ -546,7 +615,7 @@ export function modulesManagerControls(state: ModulesState, context: ModulesCont const module = state.draft.modules.find(item => item.id === state.detail!.id); if (!module || !moduleDefinition(module.id)) return [['Esc', 'back']]; const claude = moduleDefinition(state.detail.id)?.facts.has('agent.claude'); - return [['Space', 'show/hide'], ...(conditionChoosable(module) ? [['←→', 'option'] as [string, string]] : []), ['S', 'surface'], + return [...read, ['Space', 'show/hide'], ...(conditionChoosable(module) ? [['←→', 'option'] as [string, string]] : []), ['S', 'surface'], ...(claude ? [['B', 'Claude Code bridge'] as [string, string]] : []), ...apply, ['Esc', 'back']]; } const tab = state.modulesTab ?? 'modules'; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 3f05f234..56434ace 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -550,14 +550,20 @@ export function normalizeIdleVisuals(value: unknown): IdleVisualSettings { export const LIVE_ACTIVITY_COLORS = ['appearance', 'lavender', 'grayscale', 'custom'] as const; export type LiveActivityColors = typeof LIVE_ACTIVITY_COLORS[number]; export const LIVE_ACTIVITY_COLOR_LABELS: Record = {appearance: 'Follow appearance', lavender: 'Native Lavender', grayscale: 'Grayscale', custom: 'Custom'}; -export interface LiveActivitySettings {colors: LiveActivityColors; customStops: string[]} -export const DEFAULT_LIVE_ACTIVITY: LiveActivitySettings = {colors: 'appearance', customStops: []}; +/** Expressive: a playful working phrase and its past-tense counterpart on success. Classic: plain "Running" and "Completed". Failures and interrupts read the same in both. */ +export const ACTIVITY_STYLES = ['expressive', 'classic'] as const; +export type ActivityStyle = typeof ACTIVITY_STYLES[number]; +export const ACTIVITY_STYLE_LABELS: Record = {expressive: 'Expressive', classic: 'Classic'}; + +export interface LiveActivitySettings {colors: LiveActivityColors; customStops: string[]; style: ActivityStyle} +export const DEFAULT_LIVE_ACTIVITY: LiveActivitySettings = {colors: 'appearance', customStops: [], style: 'expressive'}; export function normalizeLiveActivity(value: unknown): LiveActivitySettings { const v = isRecord(value) ? value : {}; const customStops = validCustomStops(v.customStops) ? v.customStops.map(stop => stop.toLowerCase()) : []; const colors = LIVE_ACTIVITY_COLORS.includes(v.colors as LiveActivityColors) ? v.colors as LiveActivityColors : 'appearance'; - return {colors: colors === 'custom' && !customStops.length ? 'appearance' : colors, customStops}; + const style = ACTIVITY_STYLES.includes(v.style as ActivityStyle) ? v.style as ActivityStyle : DEFAULT_LIVE_ACTIVITY.style; + return {colors: colors === 'custom' && !customStops.length ? 'appearance' : colors, customStops, style}; } export interface PromptConfiguration { @@ -628,6 +634,8 @@ export interface PromptConfiguration { uiChrome: UiChromeSettings; /** Compact cross-session notices above the composer (other sessions finished, failed, ended...). */ sessionNotices: boolean; + /** What /copy does without a selection, and whether copies include NMSh's completion status. */ + copy: CopySettings; /** Named agent launch profiles (provider-specific, never credentials); see src/agents/sessions/manager.ts. */ agentProfiles: AgentProfile[]; /** Keep Ask questions and replies with the session transcript. Approved actions follow their own history rules either way. */ @@ -638,6 +646,8 @@ export interface PromptConfiguration { localUnderstanding: LocalUnderstandingSettings; /** Local-only agent CLI activity stats (durations and counts; never content). */ agentActivity: boolean; + /** Enter while a command runs queues the next command (Ctrl+S sends to the program); off: Enter sends to the program. */ + commandQueue: boolean; /** Shell backend for new sessions; /shell switches only the current session unless saved as default. */ shellBackend: ShellId; /** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */ @@ -708,6 +718,27 @@ export interface PromptConfiguration { spacing: number; } +/** Quick Copy: `/copy` copies the latest output at once. Interactive Picker: `/copy` opens a list to choose from. */ +export const COPY_MODES = ['quick', 'picker'] as const; +export type CopyMode = typeof COPY_MODES[number]; +export const COPY_MODE_LABELS: Record = {quick: 'Quick Copy', picker: 'Interactive Picker'}; + +export interface CopySettings { + /** What `/copy` with no selection does. */ + mode: CopyMode; + /** Append each command's completion status (Completed/failed/interrupted, time, exit code, waits) after its output. */ + includeStatus: boolean; + /** After a successful copy, unfold the blocks whose output was copied. Never changes what is copied. */ + autoExpand: boolean; +} + +export const DEFAULT_COPY: CopySettings = {mode: 'quick', includeStatus: false, autoExpand: false}; + +export function normalizeCopy(value: unknown): CopySettings { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + return {mode: raw.mode === 'picker' ? 'picker' : 'quick', includeStatus: raw.includeStatus === true, autoExpand: raw.autoExpand === true}; +} + export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { presentation: {...DEFAULT_TREATMENT_SETTINGS, customStops: []}, motion: {...DEFAULT_MOTION}, @@ -739,10 +770,12 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, agentProfiles: [], sessionNotices: true, + copy: {...DEFAULT_COPY}, askRecord: true, askPresentation: 'chat' as const, localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, + commandQueue: true, shellBackend: 'zsh', openWith: 'auto', terminalTitle: 'off', @@ -838,7 +871,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); const tooling = {motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), - sessionNotices: value.sessionNotices !== false, agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index 5a6555fb..906bc8d9 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -304,14 +304,21 @@ export function isCleanWorkingTree(git: NonNullable): bool return !git.staged && !git.modified && !git.untracked && !git.conflicts && !git.operation; } +/** The shared display-condition check, also used by diagnostics; no data is collected here. */ +export function moduleConditionMet(config: ContextModuleConfig, context: PromptContext): boolean { + if (config.condition === 'inRepository' && !context.branch) return false; + if (config.condition === 'nonzeroExit' && (context.exitStatus ?? 0) === 0) return false; + // Toolchains match only detected toolchains, as their segments do below. + if (config.condition === 'onCommand' && !(config.id === 'toolchain' + ? (context.toolchains ?? []).some(id => matchesCommand(TOOLCHAIN_TRIGGERS[id], context.commandWords)) + : isOnCommandRelevant(config.id, context.commandWords ?? []))) return false; + if (config.condition === 'shellDiffers' && !context.shell?.differs) return false; + return true; +} + function moduleSegments(config: ContextModuleConfig, context: PromptContext, icons: NativeIconMode, richGit: boolean, pathLevel: number): Array<{text: string; role: PromptRole; compact?: boolean}> { const status = context.exitStatus ?? 0; - if (!config.visible) return []; - if (config.condition === 'inRepository' && !context.branch) return []; - if (config.condition === 'nonzeroExit' && status === 0) return []; - // Toolchains filter per toolchain below; other on-command modules need a matching command. - if (config.condition === 'onCommand' && config.id !== 'toolchain' && !isOnCommandRelevant(config.id, context.commandWords ?? [])) return []; - if (config.condition === 'shellDiffers' && !context.shell?.differs) return []; + if (!config.visible || !moduleConditionMet(config, context)) return []; switch (config.id) { case 'project': return [{text: safePromptText(context.project), role: 'project'}]; @@ -367,9 +374,7 @@ function moduleSegments(config: ContextModuleConfig, context: PromptContext, ico function packModuleSegments(config: ContextModuleConfig, context: PromptContext, facts: ReturnType, icons: NativeIconMode, purpose: 'display' | 'snapshot'): Array<{text: string; role: PromptRole; compact?: boolean}> { const definition = moduleDefinition(config.id); - if (!definition?.pack || !config.visible) return []; - if (config.condition === 'inRepository' && !context.branch) return []; - if (config.condition === 'onCommand' && !matchesCommand(definition.triggers ?? [], context.commandWords)) return []; + if (!definition?.pack || !config.visible || !moduleConditionMet(config, context)) return []; return declarativeSegments(definition, facts, {icons, glyphs: getCurrentGlyphMode(), now: context.now ?? presentationNow().getTime(), purpose}); } diff --git a/src/queue/QueuePanel.ts b/src/queue/QueuePanel.ts new file mode 100644 index 00000000..78ba9906 --- /dev/null +++ b/src/queue/QueuePanel.ts @@ -0,0 +1,107 @@ +import type {Key} from '../terminal/keys.js'; +import type {QueueCondition, QueueEntry, QueueOp, QueueState} from '../session/CommandQueue.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {pauseReason, pausedGlyph, queueGlyph, queuePreview} from '../status/queueStatus.js'; +import {displaySafe} from '../input/PasteReview.js'; + +/** + * `/queue`: the session's command queue as a keyboard-first list. It holds no queue of its own: every change is a + * QueueOp sent to the queue's owner, and the list redraws from the owner's next state, so what you see is what will + * run. + */ +export interface QueuePanelState { + selected: number; + /** Waiting for y/n before clearing everything. */ + confirmClear?: boolean; +} + +export type QueuePanelAction = + | {kind: 'close'} + | {kind: 'change'; change: QueueOp} + /** Load the entry into the composer for editing in place. */ + | {kind: 'edit'; id: number} + /** Keep the queued commands, in order, as a named recipe (asks for the name). */ + | {kind: 'saveRecipe'}; + +/** The condition `w` moves to next: after success (the default), also after a failure, or only once approved. */ +export const nextCondition = (condition: QueueCondition | undefined): QueueCondition | undefined => condition === undefined ? 'always' : condition === 'always' ? 'approve' : undefined; + +export const conditionNote = (entry: QueueEntry): string => + entry.condition === 'always' ? 'runs even after a failure' : entry.condition === 'approve' ? (entry.approved ? 'approved' : 'asks first') : ''; + +/** `conditions`: the queue's owner understands conditions and approval (older session services do not, and must never be sent them). */ +export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueState, conditions = false): QueuePanelAction | undefined { + const entries = queue.entries; + panel.selected = Math.max(0, Math.min(panel.selected, entries.length - 1)); + if (panel.confirmClear) { + panel.confirmClear = false; + if (key.kind === 'text' && key.value.toLowerCase() === 'y') return {kind: 'change', change: {op: 'clear'}}; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'}; + const entry = entries[panel.selected]; + if (key.kind === 'up' || key.kind === 'down') { + if (entries.length) panel.selected = (panel.selected + (key.kind === 'up' ? -1 : 1) + entries.length) % entries.length; + return undefined; + } + if (key.kind === 'text' && key.value === 's' && entries.length) return {kind: 'saveRecipe'}; + if (key.kind === 'text' && key.value === 'p') return {kind: 'change', change: {op: queue.paused ? 'resume' : 'pause'}}; + if (!entry) return undefined; + if (key.kind === 'selectUp' || key.kind === 'selectDown') { + const to = Math.max(0, Math.min(entries.length - 1, panel.selected + (key.kind === 'selectUp' ? -1 : 1))); + if (to === panel.selected) return undefined; + panel.selected = to; + return {kind: 'change', change: {op: 'move', id: entry.id, to}}; + } + if (key.kind === 'enter' || (key.kind === 'text' && key.value === 'e')) return {kind: 'edit', id: entry.id}; + if (key.kind === 'delete' || key.kind === 'backspace' || (key.kind === 'text' && key.value === 'd')) return {kind: 'change', change: {op: 'remove', id: entry.id}}; + if (key.kind === 'text' && key.value === 'c') { panel.confirmClear = true; return undefined; } + if (conditions && key.kind === 'text' && key.value === 'w') return {kind: 'change', change: {op: 'condition', id: entry.id, ...(nextCondition(entry.condition) ? {condition: nextCondition(entry.condition)!} : {})}}; + if (conditions && key.kind === 'text' && key.value === 'a' && entry.condition === 'approve' && !entry.approved) return {kind: 'change', change: {op: 'approve', id: entry.id}}; + return undefined; +} + +/** The panel's rows (unframed): what runs now, what runs next, and every queued command in order. */ +export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, running: string | undefined, columns: number, height: number, conditions = false): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const width = Math.max(10, columns - 4); + const entries = queue.entries; + const status = queue.paused ? `${pausedGlyph()} paused · ${pauseReason(queue.paused)}` : entries.length ? `${queueGlyph()} runs in order, one at a time` : 'empty'; + const out = [` ${primary}Command queue${reset} ${queue.paused ? accent : subtle}${truncateText(status, Math.max(4, width - 15))}${reset}`]; + out.push(` ${subtle}${truncateText(running ? `Running: ${queuePreview(running, width)}` : 'Nothing running', width)}${reset}`, ''); + if (!entries.length) { + out.push(` ${secondary}Nothing queued. While a command runs, type the next one and press Enter (or Ctrl+Q).${reset}`); + } + const listRoom = Math.max(1, height - 12); + const first = Math.max(0, Math.min(panel.selected - Math.floor(listRoom / 2), entries.length - listRoom)); + entries.slice(first, first + listRoom).forEach((entry, offset) => { + const index = first + offset; + const selected = index === panel.selected; + const label = index === 0 && !queue.paused ? 'next' : `${index + 1}`; + const note = conditionNote(entry); + const editing = (queue.editing === entry.id ? ' (editing)' : '') + (note ? ` · ${note}` : ''); + const text = queuePreview(entry.text, Math.max(4, width - displayWidth(label) - displayWidth(editing) - 4)); + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${label.padStart(4)}${reset} ${focusForeground(selected)}${text}${reset}${entry.condition === 'approve' && !entry.approved ? accent : subtle}${editing}${reset}`); + }); + const selected = entries[panel.selected]; + if (selected && selected.text.includes('\n') && height >= out.length + 8) { + const lines = selected.text.split('\n'); + out.push('', ` ${subtle}Runs as one block:${reset}`, ...lines.slice(0, 5).map(line => ` ${secondary}${truncateText(displaySafe(line), width)}${reset}`)); + if (lines.length > 5) out.push(` ${subtle}… ${lines.length - 5} more line${lines.length === 6 ? '' : 's'}${reset}`); + } + out.push(''); + if (panel.confirmClear) out.push(` ${accent}Clear ${entries.length} queued command${entries.length === 1 ? '' : 's'}? y / n${reset}`); + else { + const approvable = conditions && selected?.condition === 'approve' && !selected.approved; + out.push(renderControls([['↑↓', 'select'], ['Shift+↑↓', 'move'], ['Enter', 'edit'], ['Del', 'remove'], ...(conditions ? [['w', 'when it runs'] as const] : []), ...(approvable ? [['a', 'approve'] as const] : []), + ['s', 'save as recipe'], ['p', queue.paused ? 'resume' : 'pause'], ['c', 'clear'], ['Esc', 'close']])); + } + return out.map(line => truncateAnsi(line, columns)); +} diff --git a/src/session/CommandQueue.ts b/src/session/CommandQueue.ts new file mode 100644 index 00000000..74afab5c --- /dev/null +++ b/src/session/CommandQueue.ts @@ -0,0 +1,204 @@ +import {EventEmitter} from 'node:events'; + +/** + * Commands prepared while another runs, owned by one session and run by its own shell, one at a time. + * + * The queue is data, never a shell construct: no `;`, `&&` or background jobs. Its owner (the session service, or + * the in-process client) is the only place entries are taken for running (see QueueDispatcher), so an entry runs at + * most once, and only in the shell of the session that owns it. + */ +/** + * When an entry may run, beyond "the queue is not paused". Absent is the default: after the commands before it + * succeeded (a failure or interrupt pauses the queue before it). `always` also runs after a failure (never after an + * interrupt: Ctrl+C still stops the queue). `approve` waits for an explicit approval of this exact entry. + */ +export type QueueCondition = 'always' | 'approve'; + +export interface QueueEntry { + /** Unique within the session, never reused. */ + id: number; + /** Exactly what is submitted to the shell, multi-line included. */ + text: string; + addedAt: number; + condition?: QueueCondition; + /** `approve` entries only: approved by the person for this text; editing the entry withdraws it. */ + approved?: boolean; +} + +/** Why the queue does not run its next entry by itself. */ +export type QueuePause = + | {reason: 'user'; at: number} + | {reason: 'failed'; at: number; command: string; exitCode: number} + | {reason: 'interrupted'; at: number; command: string} + | {reason: 'shell-switched'; at: number} + | {reason: 'restored'; at: number}; + +export interface QueueState { + entries: readonly QueueEntry[]; + paused?: QueuePause; + /** An entry being edited holds the queue at that point until the edit is saved or cancelled. */ + editing?: number; +} + +export type QueueOp = + | {op: 'add'; text: string; condition?: QueueCondition} + /** Set (or with no condition, clear) when an entry may run. Withdraws any approval. */ + | {op: 'condition'; id: number; condition?: QueueCondition} + /** Approve the entry that is waiting for approval, exactly as it stands. */ + | {op: 'approve'; id: number} + | {op: 'edit'; id: number; text: string} + | {op: 'edit-begin'; id: number} + | {op: 'edit-cancel'; id: number} + | {op: 'remove'; id: number} + | {op: 'move'; id: number; to: number} + | {op: 'clear'} + | {op: 'pause'} + | {op: 'resume'}; + +/** Bounds: a queue is for prepared commands, not bulk data. */ +export const QUEUE_LIMIT = 100; +export const ENTRY_LIMIT = 64 * 1024; +/** All queued text together: keeps every queue snapshot well inside one protocol frame. */ +export const QUEUE_BYTES_LIMIT = 1024 * 1024; + +export class CommandQueue extends EventEmitter<{change: [QueueState]}> { + private entries: QueueEntry[] = []; + private nextId = 1; + private pause?: QueuePause; + private editing?: number; + + constructor(private readonly now: () => number = Date.now) { super(); } + + get state(): QueueState { + return {entries: this.entries.map(entry => ({...entry})), ...(this.pause ? {paused: {...this.pause}} : {}), + ...(this.editing === undefined ? {} : {editing: this.editing})}; + } + + get size(): number { return this.entries.length; } + + /** Applies an operation; returns why it was refused, if it was. */ + apply(change: QueueOp): string | undefined { + switch (change.op) { + case 'add': { + if (!change.text.trim()) return 'Nothing to queue.'; + if (change.text.length > ENTRY_LIMIT) return 'That command is too long to queue.'; + if (this.entries.length >= QUEUE_LIMIT) return `The queue holds at most ${QUEUE_LIMIT} commands.`; + if (this.bytes() + Buffer.byteLength(change.text) > QUEUE_BYTES_LIMIT) return 'The queue is full; run or remove some commands first.'; + this.entries.push({id: this.nextId++, text: change.text, addedAt: this.now(), ...(change.condition ? {condition: change.condition} : {})}); + break; + } + case 'edit': { + const entry = this.entries.find(item => item.id === change.id); + if (!entry) return 'That command already ran or was removed.'; + if (!change.text.trim()) return 'A queued command cannot be empty; remove it instead.'; + if (change.text.length > ENTRY_LIMIT) return 'That command is too long to queue.'; + if (this.bytes() - Buffer.byteLength(entry.text) + Buffer.byteLength(change.text) > QUEUE_BYTES_LIMIT) return 'The queue is full; run or remove some commands first.'; + // Approval is for the exact text that was approved. + if (entry.text !== change.text) delete entry.approved; + entry.text = change.text; + if (this.editing === change.id) this.editing = undefined; + break; + } + case 'condition': { + const entry = this.entries.find(item => item.id === change.id); + if (!entry) return 'That command already ran or was removed.'; + if (change.condition) entry.condition = change.condition; else delete entry.condition; + delete entry.approved; + break; + } + case 'approve': { + const entry = this.entries.find(item => item.id === change.id); + if (!entry) return 'That command already ran or was removed.'; + if (entry.condition !== 'approve') return 'That command does not ask for approval.'; + entry.approved = true; + break; + } + case 'edit-begin': { + if (!this.entries.some(item => item.id === change.id)) return 'That command already ran or was removed.'; + this.editing = change.id; + break; + } + case 'edit-cancel': { + if (this.editing !== change.id) return undefined; + this.editing = undefined; + break; + } + case 'remove': { + const index = this.entries.findIndex(item => item.id === change.id); + if (index === -1) return 'That command already ran or was removed.'; + this.entries.splice(index, 1); + if (this.editing === change.id) this.editing = undefined; + break; + } + case 'move': { + const index = this.entries.findIndex(item => item.id === change.id); + if (index === -1) return 'That command already ran or was removed.'; + const to = Math.max(0, Math.min(this.entries.length - 1, change.to)); + const [entry] = this.entries.splice(index, 1); + this.entries.splice(to, 0, entry!); + break; + } + case 'clear': + this.entries = []; + this.editing = undefined; + this.pause = undefined; + break; + case 'pause': + this.pause = {reason: 'user', at: this.now()}; + break; + case 'resume': + this.pause = undefined; + break; + } + this.changed(); + return undefined; + } + + /** Stops automatic running; entries stay. */ + hold(pause: QueuePause): void { + if (!this.entries.length) return; + this.pause = pause; + this.changed(); + } + + /** + * The next entry, removed from the queue, when it may run now: not paused and not held by an edit. Taking is the + * only way an entry leaves for the shell, so it can never run twice. + */ + take(): QueueEntry | undefined { + if (this.pause || !this.entries.length) return undefined; + if (this.editing === this.entries[0]!.id) return undefined; + // An entry that asks first waits for its own approval, whatever else is true. + if (this.entries[0]!.condition === 'approve' && !this.entries[0]!.approved) return undefined; + const entry = this.entries.shift()!; + this.changed(); + return entry; + } + + /** The entry that is next but waiting for the person's approval, if that is why the queue is not running. */ + get awaitingApproval(): QueueEntry | undefined { + const next = this.entries[0]; + return next && !this.pause && next.condition === 'approve' && !next.approved ? {...next} : undefined; + } + + /** The next entry, without taking it. */ + get next(): QueueEntry | undefined { return this.entries[0] ? {...this.entries[0]} : undefined; } + + /** Everything still queued, removed (the shell ended or the session went away). */ + drain(): QueueEntry[] { + const entries = this.entries; + this.entries = []; + this.editing = undefined; + this.pause = undefined; + if (entries.length) this.changed(); + return entries; + } + + private bytes(): number { + return this.entries.reduce((sum, entry) => sum + Buffer.byteLength(entry.text), 0); + } + + private changed(): void { + this.emit('change', this.state); + } +} diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index d5618d3c..a06f274d 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -4,6 +4,8 @@ import {ShellSession} from '../shell/ShellSession.js'; import {knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; import {shellAdapter} from '../shell/adapters/registry.js'; import {SERVICE_FEATURES, type ServiceFeature} from './SessionProtocol.js'; +import {QueueDispatcher, type QueueShell} from './QueueDispatcher.js'; +import type {QueueOp} from './CommandQueue.js'; import {SESSION_ID_ENV, SESSION_MODE_ENV, type SessionClient, type SessionClientEvents, type SessionOptions} from './SessionClient.js'; type ShellLike = Pick @@ -19,6 +21,8 @@ export class InProcessSessionClient extends EventEmitter im private size: {columns: number; rows: number}; private running = false; private knowledge?: string; + /** The session's command queue, run in this process exactly as the service runs it. */ + private readonly dispatcher: QueueDispatcher; /** This in-process session's NMSH_SESSION_ID (there is no service session id). */ readonly contextId = `local-${randomUUID()}`; @@ -27,22 +31,47 @@ export class InProcessSessionClient extends EventEmitter im this.shellId = options.shell ?? 'zsh'; this.size = {columns: options.columns, rows: options.rows}; this.shell = factory({...options, contextId: this.contextId}); + this.dispatcher = new QueueDispatcher(this.queueShell()); + this.dispatcher.on('state', (state, event) => this.emit('queueState', state, { + ...(event.dispatched ? {dispatched: event.dispatched.id} : {}), ...(event.refused ? {refused: event.refused} : {}), + ...(event.dropped?.length ? {dropped: event.dropped.length} : {})})); this.wire(this.shell); } private wire(shell: ShellLike): void { shell.on('data', data => this.emit('data', data, NO_STAMP)); - shell.on('prompt', marker => { this.running = false; this.knowledge = marker.knowledge; this.emit('prompt', marker, NO_STAMP); }); - shell.on('exec', (command, historyAllowed) => { this.running = true; this.emit('exec', command, historyAllowed === undefined ? NO_STAMP : {historyAllowed}); }); + shell.on('prompt', marker => { + this.running = false; + this.knowledge = marker.knowledge; + this.emit('prompt', marker, NO_STAMP); + this.dispatcher.onPrompt(marker.exitCode); + }); + shell.on('exec', (command, historyAllowed) => { + this.running = true; + const queued = this.dispatcher.onExec(command)?.text; + this.emit('exec', command, historyAllowed === undefined && queued === undefined ? NO_STAMP + : {...(historyAllowed === undefined ? {} : {historyAllowed}), ...(queued === undefined ? {} : {queued})}); + }); shell.on('startup', tail => this.emit('startup', tail)); shell.on('inputRejected', (data, submission) => this.emit('inputRejected', data, submission)); - shell.on('exit', event => this.emit('exit', event)); + shell.on('inputState', state => this.emit('inputState', state)); + shell.on('exit', event => { this.dispatcher.onShellExit(); this.emit('exit', event); }); } start(): void {} submit(command: string): void { this.shell.submit(command); } - write(data: string): void { this.shell.write(data); } - interrupt(): void { this.shell.interrupt(); } + write(data: string): void { + if (data.includes('\u0003')) this.dispatcher.onInterrupt(); + this.shell.write(data); + } + interrupt(): void { this.dispatcher.onInterrupt(); this.shell.interrupt(); } + queue(change: QueueOp): void { this.dispatcher.request(change); } + + private queueShell(): QueueShell { + const client = this; + return {submit: command => client.shell.submit(command), get isReady() { return client.shell.isReady ?? true; }, + get adapter() { return {id: client.shellId}; }}; + } endInput(): void { this.shell.endInput(); } resize(columns: number, rows: number): void { this.size = {columns, rows}; this.shell.resize(columns, rows); } kill(): void { this.shell.kill(); } @@ -65,6 +94,7 @@ export class InProcessSessionClient extends EventEmitter im this.shell = next; this.shellId = shell; this.knowledge = undefined; + this.dispatcher.onShellSwitched(this.queueShell()); this.wire(next); return {shell, pid: next.pid ?? 0}; } 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/QueueDispatcher.ts b/src/session/QueueDispatcher.ts new file mode 100644 index 00000000..0293ce43 --- /dev/null +++ b/src/session/QueueDispatcher.ts @@ -0,0 +1,125 @@ +import {shellSubmission} from '../shell/submission.js'; +import {EventEmitter} from 'node:events'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {CommandQueue, type QueueEntry, type QueueOp, type QueueState} from './CommandQueue.js'; + +/** What the dispatcher needs from a shell session (ShellSession implements it). */ +export interface QueueShell { + submit(command: string): void; + readonly isReady: boolean; + readonly adapter: {readonly id: ShellId}; +} + +export interface QueueEvent { + /** The entry just submitted to the shell (it has left the queue). */ + dispatched?: QueueEntry; + /** Why the last requested change was refused. */ + refused?: string; + /** Entries that will never run because the shell ended. */ + dropped?: readonly QueueEntry[]; +} + +/** + * Runs a session's queue in its own shell, one entry at a time, only on the shell's own evidence: + * - an entry is submitted only while the shell is at its prompt (after its prompt marker, before the next exec); + * - the entry leaves the queue in the same step (`CommandQueue.take`), so it can never be submitted twice; + * - after a command that failed (non-zero status) or was interrupted, the queue pauses with that reason instead of + * running what was prepared to follow it; the person resumes or clears it. Only an entry the person marked + * `always` goes on after a plain failure (never after an interrupt), and one marked `approve` waits for approval; + * - a shell switch pauses it (entries were written for the previous shell), and a shell exit drops what is left, + * reporting it, never replaying it elsewhere. + * No timers: silence is never taken as completion. + */ +export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEvent]}> { + readonly queue: CommandQueue; + private shell: QueueShell; + /** Between a prompt marker and the next exec, with nothing submitted yet. */ + private atPrompt = false; + /** A command started (exec) since the last prompt. */ + private ran = false; + private lastCommand = ''; + /** Ctrl+C was sent while this command ran. */ + private interrupted = false; + /** Set while taking an entry, so the change is announced once, together with what was dispatched. */ + private taking = false; + /** The entry submitted at the last prompt, until the shell reports the command it started. */ + private sent?: QueueEntry; + + constructor(shell: QueueShell, queue = new CommandQueue()) { + super(); + this.shell = shell; + this.queue = queue; + this.queue.on('change', state => { if (!this.taking) this.emit('state', state, {}); }); + } + + get state(): QueueState { return this.queue.state; } + + /** A change requested by the person; the queue may run at once if the shell is idle. */ + request(change: QueueOp): void { + const refused = this.queue.apply(change); + if (refused) { this.emit('state', this.queue.state, {refused}); return; } + this.dispatch(); + } + + /** The shell started a command; returns the queued entry it is, when it is the one just submitted. */ + onExec(command: string): QueueEntry | undefined { + this.atPrompt = false; + this.ran = true; + this.lastCommand = command; + const entry = this.sent; + this.sent = undefined; + return entry; + } + + onInterrupt(): void { + if (!this.atPrompt) this.interrupted = true; + } + + onPrompt(exitCode: number): void { + // A submission that started no command (a comment, an empty block) is not owed to the next command. + this.sent = undefined; + const finished = this.ran; + this.ran = false; + this.atPrompt = true; + if (finished && this.queue.size) { + const at = Date.now(); + if (this.interrupted || exitCode === 130) this.queue.hold({reason: 'interrupted', at, command: this.lastCommand}); + // An entry that is to run regardless of the result before it is not held back by a plain failure. + else if (exitCode !== 0 && this.queue.next?.condition !== 'always') this.queue.hold({reason: 'failed', at, command: this.lastCommand, exitCode}); + } + this.interrupted = false; + this.dispatch(); + } + + /** The session now runs a different shell: nothing queued for the old one runs until the person resumes. */ + onShellSwitched(shell: QueueShell): void { + this.shell = shell; + this.atPrompt = false; + this.ran = false; + this.sent = undefined; + this.queue.hold({reason: 'shell-switched', at: Date.now()}); + } + + /** The shell ended: what is left is reported and never runs. */ + onShellExit(): void { + this.atPrompt = false; + this.taking = true; + let dropped: QueueEntry[]; + try { dropped = this.queue.drain(); } finally { this.taking = false; } + if (dropped.length) this.emit('state', this.queue.state, {dropped}); + } + + private dispatch(): void { + if (!this.atPrompt || !this.shell.isReady) return; + this.taking = true; + let entry: QueueEntry | undefined; + try { entry = this.queue.take(); } finally { this.taking = false; } + if (!entry) return; + // Leaves the prompt now: a second dispatch cannot happen before this entry's own prompt. + this.atPrompt = false; + this.sent = entry; + this.emit('state', this.queue.state, {dispatched: entry}); + // Exactly as the composer submits it: a multi-line entry runs as one block. + this.shell.submit(shellSubmission(entry.text, this.shell.adapter.id)); + } +} diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index a1244bb3..04db71cd 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -3,11 +3,22 @@ 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'; +import type {QueueOp, QueueState} from './CommandQueue.js'; + +/** What happened with a queue change, by entry id (the text is in the previous state). */ +export interface QueueNews { + dispatched?: number; + refused?: string; + dropped?: number; +} /** Position of an event in a service session's stream, when it has one. */ export interface StreamStamp { /** Explicit zsh history eligibility; absent from older services. */ historyAllowed?: number; + /** The command is this queued entry, run by the session's queue (its own text, as the person prepared it). */ + queued?: string; seq?: number; /** When the service observed the event (epoch ms). */ at?: number; @@ -24,6 +35,10 @@ 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 session's command queue changed: its entries, and what just ran, was refused or was dropped. */ + queueState: [QueueState, QueueNews]; /** The backlog sent after a reattach has been delivered. */ replayed: [{truncatedBytes: number}]; /** The managed shell ended. */ @@ -60,6 +75,8 @@ export interface SessionClient extends EventEmitter { * event); rejects with a factual reason when switching would lose anything. */ switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}>; + /** Change this session's command queue (only when `features` has 'queue'). */ + queue(change: QueueOp): void; /** Optional capabilities of whatever owns the shell (the service's welcome, or this build in-process). */ readonly features: ReadonlySet; /** Build of the session service, when it reported one. */ @@ -77,6 +94,8 @@ export interface AttachedSession { modes?: string; running?: string; runningSince?: number; + /** The running command is this queued entry (its own text). */ + runningQueued?: string; /** Journal the previous frontend kept for this session, and how far it got. */ journalId?: string; ackedSeq: number; 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..d539073b 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -1,3 +1,5 @@ +import {QUEUE_LIMIT, type QueueCondition, type QueueEntry, type QueueOp, type QueuePause, type QueueState} from './CommandQueue.js'; +import type {QueueEvent} from './QueueDispatcher.js'; /** * Versioned wire protocol between an NMSh frontend and whatever owns the * PTY + managed zsh (in-process today, a local session service later). @@ -10,6 +12,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 +24,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', 'queue', 'queue-conditions'] 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', 'queue'] 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} @@ -48,6 +64,8 @@ export type ClientMessage = | {type: 'switch-shell'; shell: string; cwd: string} /** Rename a live session (display only; its id never changes). An empty name returns to its signature. */ | {type: 'rename'; sessionId: string; name: string} + /** One change to this session's command queue (QueueOp as JSON); only from frontends that listed 'queue'. */ + | {type: 'queue'; change: string} | {type: 'terminate'}; export type ServerMessage = @@ -62,7 +80,7 @@ export type ServerMessage = /** shell: the backend actually started (absent from older services: zsh). */ | {type: 'created'; sessionId: string; pid: number; shell?: string} | {type: 'shell-switched'; shell: string; pid: number} - | {type: 'attached'; sessionId: string; pid: number; cwd: string; fullscreen: number; modes?: string; running?: string; runningSince?: number; + | {type: 'attached'; sessionId: string; pid: number; cwd: string; fullscreen: number; modes?: string; running?: string; runningSince?: number; runningQueued?: string; journalId?: string; ackedSeq: number; knowledge?: string; shell?: string; /** Present only while the shell has not reached its first prompt: the sanitized, bounded tail of its startup output. */ startup?: string} @@ -79,8 +97,15 @@ export type ServerMessage = * missed exactly once with the original timing. */ | {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: 'exec'; command: string; seq: number; at: number; historyAllowed?: number; queued?: string} + | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number; inputWaitMs?: number; inputWaits?: number} + /** + * 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. + */ + /** The session's command queue (QueueState as JSON) and what just happened to it. */ + | {type: 'queue-state'; state: string; dispatched?: number; refused?: string; dropped?: number} + | {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 +149,14 @@ 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; + /** Commands queued in this session, and why they wait, if paused (only to frontends that listed 'queue'). */ + queued?: number; + queuePaused?: string; } export type ProtocolMessage = ClientMessage | ServerMessage; @@ -140,7 +173,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'}, @@ -153,11 +186,13 @@ const SHAPES: Record = { ack: {seq: 'int', journalId: 'string'}, kill: {sessionId: 'string'}, dismiss: {sessionId: 'string'}, + queue: {change: 'string'}, + 'queue-state': {state: 'string', dispatched: 'int?', refused: 'string?', dropped: 'int?'}, terminate: {}, welcome: {version: 'int', service: 'string', startupSafety: 'int?', features: 'string?', build: 'string?'}, error: {code: 'string', message: 'string'}, created: {sessionId: 'string', pid: 'int', shell: 'string?'}, - attached: {sessionId: 'string', pid: 'int', cwd: 'string', fullscreen: 'int', modes: 'string?', running: 'string?', runningSince: 'int?', + attached: {sessionId: 'string', pid: 'int', cwd: 'string', fullscreen: 'int', modes: 'string?', running: 'string?', runningSince: 'int?', runningQueued: 'string?', journalId: 'string?', ackedSeq: 'int', knowledge: 'string?', startup: 'string?', shell: 'string?'}, startup: {output: 'string'}, 'input-rejected': {data: 'string', submission: 'int?'}, @@ -165,8 +200,9 @@ const SHAPES: Record = { sessions: {sessions: 'sessions', ended: 'notices?'}, 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?'}, + exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?', queued: 'string?'}, + prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?', inputWaitMs: 'int?', inputWaits: 'int?'}, + '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 +215,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?', queued: 'int?', queuePaused: '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 +294,86 @@ 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}; +} + +/** A queue change from a frontend, or undefined when it is not one: every field checked, nothing guessed. */ +export function queueOpFrom(text: string): QueueOp | undefined { + let raw: unknown; + try { raw = JSON.parse(text); } catch { return undefined; } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + const id = Number.isSafeInteger(value.id) ? value.id as number : undefined; + const text_ = typeof value.text === 'string' ? value.text : undefined; + switch (value.op) { + case 'add': { + if (text_ === undefined) return undefined; + const condition = queueConditionFrom(value.condition); + return value.condition !== undefined && !condition ? undefined : {op: 'add', text: text_, ...(condition ? {condition} : {})}; + } + case 'condition': { + const condition = queueConditionFrom(value.condition); + return id === undefined || (value.condition !== undefined && !condition) ? undefined : {op: 'condition', id, ...(condition ? {condition} : {})}; + } + case 'approve': return id === undefined ? undefined : {op: 'approve', id}; + case 'edit': return id === undefined || text_ === undefined ? undefined : {op: 'edit', id, text: text_}; + case 'edit-begin': case 'edit-cancel': case 'remove': return id === undefined ? undefined : {op: value.op, id}; + case 'move': return id === undefined || !Number.isSafeInteger(value.to) ? undefined : {op: 'move', id, to: value.to as number}; + case 'clear': case 'pause': case 'resume': return {op: value.op}; + default: return undefined; + } +} + +const queueConditionFrom = (value: unknown): QueueCondition | undefined => value === 'always' || value === 'approve' ? value : undefined; + +const PAUSE_REASONS = ['user', 'failed', 'interrupted', 'shell-switched', 'restored'] as const; + +/** The queue a queue-state message describes; anything malformed is dropped, never guessed. */ +export function queueStateFrom(text: string): QueueState | undefined { + let raw: unknown; + try { raw = JSON.parse(text); } catch { return undefined; } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + if (!Array.isArray(value.entries) || value.entries.length > QUEUE_LIMIT) return undefined; + const entries: QueueEntry[] = []; + for (const entry of value.entries as unknown[]) { + const item = entry as Record | null; + if (!item || !Number.isSafeInteger(item.id) || typeof item.text !== 'string' || !Number.isSafeInteger(item.addedAt)) return undefined; + const condition = queueConditionFrom(item.condition); + entries.push({id: item.id as number, text: item.text, addedAt: item.addedAt as number, ...(condition ? {condition} : {}), ...(condition === 'approve' && item.approved === true ? {approved: true} : {})}); + } + const pause = value.paused as Record | undefined; + let paused: QueuePause | undefined; + if (pause && typeof pause === 'object' && (PAUSE_REASONS as readonly unknown[]).includes(pause.reason) && Number.isSafeInteger(pause.at)) { + const at = pause.at as number; + const command = typeof pause.command === 'string' ? pause.command.slice(0, 400) : ''; + paused = pause.reason === 'failed' ? {reason: 'failed', at, command, exitCode: Number.isSafeInteger(pause.exitCode) ? pause.exitCode as number : 1} + : pause.reason === 'interrupted' ? {reason: 'interrupted', at, command} + : {reason: pause.reason as 'user' | 'shell-switched' | 'restored', at}; + } + const editing = Number.isSafeInteger(value.editing) ? value.editing as number : undefined; + return {entries, ...(paused ? {paused} : {}), ...(editing === undefined ? {} : {editing})}; +} + +/** The queue-state message for a state and its event. */ +export function queueStateMessage(state: QueueState, event: QueueEvent): Extract { + return {type: 'queue-state', state: JSON.stringify(state), ...(event.dispatched ? {dispatched: event.dispatched.id} : {}), + ...(event.refused ? {refused: event.refused.slice(0, 200)} : {}), ...(event.dropped?.length ? {dropped: event.dropped.length} : {})}; +} diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index bef57336..be4f5e5a 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -1,3 +1,4 @@ +import {QueueDispatcher} from './QueueDispatcher.js'; import {assignSignature} from './signatures.js'; import {randomUUID} from 'node:crypto'; import {chmodSync, lstatSync, unlinkSync} from 'node:fs'; @@ -6,10 +7,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, queueOpFrom, queueStateMessage, 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,7 +42,13 @@ interface ManagedSession { size: {columns: number; rows: number}; /** The one writable frontend; undefined while detached. */ controller?: Send; - running?: {command: string; since: number}; + /** 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; queued?: string}; /** When zsh last returned to its prompt. */ idleSince: number; screen: AlternateScreenTracker; @@ -52,6 +61,8 @@ interface ManagedSession { /** This session's cross-session notice; cleared when the session is focused. */ notices: SessionNoticeTracker; knowledge?: string; + /** This session's command queue; it runs only in this session's shell (see QueueDispatcher). */ + queue: QueueDispatcher; } export {AlternateScreenTracker} from './TerminalModes.js'; @@ -77,9 +88,10 @@ function toMessage(event: BacklogEvent): ServerMessage { switch (event.kind) { case 'output': return {type: 'output', data: event.data, seq: event.seq, at: event.at}; case 'exec': return {type: 'exec', command: event.command, seq: event.seq, at: event.at, - ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed})}; + ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed}), ...(event.queued === undefined ? {} : {queued: event.queued})}; case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at, - ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge})}; + ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge}), + ...(event.inputWaits ? {inputWaitMs: event.inputWaitMs ?? 0, inputWaits: event.inputWaits} : {})}; } } @@ -120,15 +132,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 +153,11 @@ 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)} : {})} : {}), + ...(features.has('queue') && session.queue.queue.size ? {queued: session.queue.queue.size, + ...(session.queue.state.paused ? {queuePaused: session.queue.state.paused.reason} : {})} : {})}; } async start(): Promise { @@ -174,6 +192,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 +210,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 +226,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,15 +238,16 @@ 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()} : {}), ...(info.running ? {running: info.running, runningSince: info.runningSince} : {}), + ...(features.has('queue') && session.running?.queued !== undefined ? {runningQueued: session.running.queued} : {}), ...(backlog.journalId ? {journalId: backlog.journalId} : {}), ackedSeq: backlog.ackedSeq, ...(session.knowledge === undefined ? {} : {knowledge: session.knowledge}), shell: session.backend, ...(session.shell.isReady ? {} : {startup: session.shell.startupTail() ?? ''})}); @@ -233,6 +255,9 @@ 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)); + if (features.has('queue')) send(queueStateMessage(session.queue.state, {})); this.redraw(session, message.columns, message.rows); break; } @@ -245,14 +270,23 @@ 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(); this.endedNotices.dismiss(message.sessionId); send({type: 'dismissed', sessionId: message.sessionId}); break; + case 'queue': { + const change = queueOpFrom(message.change); + if (!owned || !features.has('queue')) send({type: 'error', code: 'state', message: 'no session queue for this connection'}); + else if (!change) send({type: 'error', code: 'queue', message: 'not a queue change'}); + else owned.queue.request(change); + break; + } case 'input': + // Ctrl+C reaching a running command: what was queued after it waits (see QueueDispatcher). + if (message.data.includes('\u0003')) owned?.queue.onInterrupt(); owned?.evidence.onInput(); owned?.notices.clear(); if (owned) { @@ -279,7 +313,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 +343,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 @@ -358,7 +421,10 @@ export class SessionService { state: 'attached', protocolVersion: PROTOCOL_VERSION, signature}; const session: ManagedSession = {record, shell, backend, env, size: {columns, rows}, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0, seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits), - evidence: new SessionEvidence(), notices: new SessionNoticeTracker(record.id)}; + evidence: new SessionEvidence(), notices: new SessionNoticeTracker(record.id), queue: new QueueDispatcher(shell)}; + session.queue.on('state', (state, event) => { + if (session.controllerFeatures?.has('queue')) session.controller?.(queueStateMessage(state, event)); + }); this.sessions.set(record.id, session); this.wire(session); return session; @@ -380,12 +446,15 @@ 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}; + // The queue says whether this is the entry it just submitted: every window, live or later, learns it from here. + const queued = session.queue.onExec(command)?.text; + session.running = {command, since: at, ...(queued === undefined ? {} : {queued})}; session.evidence.onExec(); session.notices.onExec(command, at); - emit({kind: 'exec', seq: ++session.seq, at, command, ...(historyAllowed === undefined ? {} : {historyAllowed})}); + emit({kind: 'exec', seq: ++session.seq, at, command, ...(historyAllowed === undefined ? {} : {historyAllowed}), ...(queued === undefined ? {} : {queued})}); }); shell.on('prompt', marker => { session.knowledge = marker.knowledge; @@ -396,10 +465,15 @@ 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} : {})}); + // After the prompt is on its way: the next queued command, if any, starts from this evidence only. + session.queue.onPrompt(marker.exitCode); }); shell.on('exit', event => { + session.queue.onShellExit(); 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: @@ -449,6 +523,7 @@ export class SessionService { session.running = undefined; session.idleSince = Date.now(); session.screen.reset(); + session.queue.onShellSwitched(next); this.wire(session); } diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 810d4b20..d830502a 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -1,6 +1,9 @@ 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 type {QueueOp} from './CommandQueue.js'; +import {CLIENT_FEATURES, FrameDecoder, PROTOCOL_VERSION, encodeMessage, inputStateFrom, parseFeatures, queueStateFrom, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; import type {SessionInfo} from './SessionProtocol.js'; import type {SessionNotice} from './SessionNotices.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; @@ -33,6 +36,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 +84,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,8 +239,15 @@ 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}); - else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at, historyAllowed: message.historyAllowed}); + ...(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 === 'queue-state') { + const state = queueStateFrom(message.state); + if (state) this.emit('queueState', state, {...(message.dispatched === undefined ? {} : {dispatched: message.dispatched}), + ...(message.refused ? {refused: message.refused} : {}), ...(message.dropped ? {dropped: message.dropped} : {})}); + } + else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at, historyAllowed: message.historyAllowed, ...(message.queued === undefined ? {} : {queued: message.queued})}); else if (message.type === 'input-rejected') this.emit('inputRejected', message.data, message.submission === 1); else if (message.type === 'startup') this.emit('startup', message.output); else if (message.type === 'replayed') this.emit('replayed', {truncatedBytes: message.truncatedBytes}); @@ -259,17 +272,29 @@ export class SocketSessionClient extends EventEmitter imple submit(command: string): void { this.send({type: 'input', data: `${command}\r`, submission: 1}); } write(data: string): void { this.send({type: 'input', data}); } + queue(change: QueueOp): void { if (this.features.has('queue')) this.send({type: 'queue', change: JSON.stringify(change)}); } 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..d8874db2 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -5,8 +5,8 @@ import {MAX_SHELL_KNOWLEDGE_BYTES} from '../shell/ShellKnowledge.js'; /** One shell stream event as retained for replay. */ 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: 'exec'; seq: number; at: number; command: string; historyAllowed?: number; queued?: string} + | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string; inputWaitMs?: number; inputWaits?: number}; /** 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..acc742ec 100644 --- a/src/session/liveStatus.ts +++ b/src/session/liveStatus.ts @@ -33,25 +33,36 @@ 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) { const parts = [`idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`]; if (session.lastExit !== undefined) parts.push(session.lastExit === 0 ? 'last command succeeded' : `last command failed (exit ${session.lastExit})`); + if (session.queued) parts.push(queueStatus(session)); return parts; } const parts = [`running ${session.running.replace(/\s+/gu, ' ').slice(0, 60)} · ${formatAge(now - (session.runningSince ?? now))}`]; 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)}`); } if (session.fullscreen) parts.push('fullscreen'); if (session.title) parts.push(`“${session.title.slice(0, 40)}”`); + if (session.queued) parts.push(queueStatus(session)); return parts; } + +/** The session's queue as the service reports it: how many wait, and whether they are paused. */ +function queueStatus(session: SessionInfo): string { + return `${session.queued} queued${session.queuePaused ? ' (paused)' : ''}`; +} 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..2257ab32 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -41,8 +41,10 @@ export interface LiveLink { sessionId: string; /** Last shell stream event reflected in this transcript. */ seq: number; + /** Presentation-only dictionary index; never execution status. */ + previousActivityIndex?: number; /** The command in flight when this checkpoint was taken. */ - running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number}; + running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number; activityIndex?: number}; } function parseLive(value: unknown): LiveLink | undefined { @@ -52,8 +54,13 @@ function parseLive(value: unknown): LiveLink | undefined { const running = live.running as Record | undefined; const validRunning = running && typeof running.command === 'string' && typeof running.cwd === 'string' && ['startedAt', 'startId', 'outputStartId'].every(key => Number.isSafeInteger(running[key])); + const index = (value: unknown): number | undefined => Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) < 10_000 ? value as number : undefined; + const previousActivityIndex = index(live.previousActivityIndex); + const activityIndex = index(running?.activityIndex); + const {activityIndex: rawActivityIndex, ...runningFacts} = running ?? {}; return {sessionId: live.sessionId, seq: live.seq as number, - ...(validRunning ? {running: running as unknown as NonNullable} : {})}; + ...(previousActivityIndex === undefined ? {} : {previousActivityIndex}), + ...(validRunning ? {running: {...runningFacts, ...(activityIndex === undefined ? {} : {activityIndex})} as unknown as NonNullable} : {})}; } export type TranscriptSummary = Omit & { @@ -85,8 +92,11 @@ 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' + && (record.outputIncomplete === undefined || record.outputIncomplete === true) && typeof record.exitCode === 'number' && typeof record.startId === 'number' && typeof record.outputStartId === 'number' && (record.historicalContext === undefined diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 590f95a3..e746c034 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -308,6 +308,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('composerPosition')}, {row: configRow('composerDividers')}, {row: configRow('pastePreview')}, + {row: configRow('copyMode'), note: draft => draft.copy.mode === 'picker' ? '/copy opens a list of recent outputs to choose from; /copy latest still copies at once' : '/copy copies the latest output at once; /copy ui opens the picker'}, + {row: configRow('copyAutoExpand'), note: draft => draft.copy.autoExpand ? 'A copied block unfolds so you can see what was copied' : 'Copying never changes what is folded'}, + {row: configRow('copyIncludeStatus'), note: draft => draft.copy.includeStatus ? 'Copies end each output with its completion status (✔ Completed · 1.4s · 10:04)' : 'Copies hold only what the command printed'}, {row: configRow('syntaxHighlighting')}, {row: configRow('syntaxColors'), note: draft => draft.syntax.colors === 'followPrompt' ? 'Follows the Native prompt theme, even with Starship or Powerlevel10k' : draft.syntax.colors === 'grayscale' ? 'Brightness and weight only; no hue' : undefined}, {row: configRow('syntaxThemeFamily')}, @@ -351,6 +354,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Session notices are short events about another session (a finished command, a failure, a request for attention); /sessions keeps the state.'], rows: [ {row: configRow('liveSessionStartup')}, {row: configRow('liveSessionMultiple')}, + {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Enter queues the next one; a program waiting for input still gets your answer' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, {row: configRow('sessionNotices'), note: draft => draft.sessionNotices ? 'Brief: a success fades in seconds, a failure lingers a little, a request for attention stays until you look' : 'Off: other sessions never add lines above the composer'}, {row: configRow('terminalTitle'), note: draft => draft.terminalTitle === 'off' ? 'Off: the terminal keeps its own window title' : 'NMSh titles the window while it owns the screen; programs keep their own title while they run'}, @@ -395,6 +399,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('idleRunBusy')}, {row: configRow('idleColor')}, routeRow('setupIdleColors', 'Edit idle colors', 'The idle visuals\' own gradient stops, with a live preview', 'idleColors', 'Idle visuals'), + {row: configRow('activityStyle'), note: draft => draft.liveActivity.style === 'expressive' ? 'Cooking… then Cooked for 18s; failures and interrupts keep their plain wording and exit code' : 'Running and Completed, as before'}, {row: configRow('activityColors'), note: () => 'The running-command line only; finished commands show their plain result'}, routeRow('setupActivityColors', 'Edit live activity colors', 'Gradient stops for the live activity line', 'activityColors', 'Live activity'), ]}, 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/shell/submission.ts b/src/shell/submission.ts new file mode 100644 index 00000000..3ed197ed --- /dev/null +++ b/src/shell/submission.ts @@ -0,0 +1,11 @@ +import type {ShellId} from './adapters/ShellAdapter.js'; + +/** + * The text NMSh submits to the shell for a command typed (or queued) in the composer. A multi-line command is + * submitted as one native group, so the shell reads it whole and runs it as one command: lines never start one by one, + * and a heredoc or an `if` block keeps its meaning. One rule for the composer and the queue. + */ +export function shellSubmission(command: string, shell: ShellId): string { + if (!command.includes('\n')) return command; + return shell === 'fish' ? `begin\n${command}\nend` : `{ ${command}\n}`; +} diff --git a/src/status/activity.ts b/src/status/activity.ts index f9872cb4..ce5ae159 100644 --- a/src/status/activity.ts +++ b/src/status/activity.ts @@ -1,6 +1,7 @@ import stringWidth from 'string-width'; import {formatDuration, formatLocalTime} from './commandTiming.js'; import {GLYPHS, SPINNER_FRAMES} from '../ui/glyphs.js'; +import type {ActivityVerbPair} from './activityVerbs.js'; export const ACTIVITY_GLYPH_INTERVAL_MS = 165; @@ -9,7 +10,12 @@ export function activityGlyph(elapsedMs: number): string { return frames[Math.floor(Math.max(0, elapsedMs) / ACTIVITY_GLYPH_INTERVAL_MS) % frames.length]; } -export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs): {phrase: string; duration: string} { +/** + * The working line. Classic names what runs ("Running ls -la"); Expressive (a pair given) shows only the decorative + * phrase, since the command is already on the line above it. Neither says anything about the work being verified. + */ +export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs, pair?: ActivityVerbPair): {phrase: string; duration: string} { + if (pair) return {phrase: `${activityGlyph(animationElapsedMs)} ${pair.active}…`, duration: ` · ${formatDuration(elapsedMs)}`}; let flattenedCommand = command.trim().replace(/\r?\n/g, ' ⏎ '); if (flattenedCommand.length > 50) { flattenedCommand = flattenedCommand.slice(0, 49) + '…'; @@ -20,7 +26,15 @@ export function liveActivityParts(command: string, elapsedMs: number, animationE }; } -export function completedActivity(command: string, elapsedMs: number, completedAt: Date, exitCode: number, interrupted: boolean, facts?: string[]): {main: string; detail: string} { +/** + * The finished line. With a pair (Expressive), only a clean exit uses the decorative wording ("✔ Cooked for 18s · + * done 14:46"); a failure or interrupt keeps the factual text with its exit code, so it can never read as success. + */ +export function completedActivity(command: string, elapsedMs: number, completedAt: Date, exitCode: number, interrupted: boolean, facts?: string[], pair?: ActivityVerbPair): {main: string; detail: string} { + if (pair && !interrupted && exitCode === 0) { + const detailFacts = facts && facts.length > 0 ? ` · ${facts.join(' · ')} · ${formatDuration(elapsedMs)}` : ` for ${formatDuration(elapsedMs)}`; + return {main: `${GLYPHS.success} ${pair.complete}${detailFacts}`, detail: ` · done ${formatLocalTime(completedAt)}`}; + } let statusText = 'Completed'; let icon = GLYPHS.success; diff --git a/src/status/activityVerbs.ts b/src/status/activityVerbs.ts new file mode 100644 index 00000000..bfba28b3 --- /dev/null +++ b/src/status/activityVerbs.ts @@ -0,0 +1,682 @@ +/** Decorative activity phrases only. They never describe verified work or determine success/failure. + * Restored from the pre-PR #29 261-pair dictionary; reviewed and expanded. + * Actual exit status, interrupts and process ownership come from structured execution state. + */ +export interface ActivityVerbPair { readonly active: string; readonly complete: string } + +/** Paired phrases: one chosen at execution start and reused only for successful completion. */ +export const ACTIVITY_VERBS: readonly ActivityVerbPair[] = [ + {active: "Cogitating", complete: "Cogitated"}, + {active: "Cerebrating", complete: "Cerebrated"}, + {active: "Pondering", complete: "Pondered"}, + {active: "Ruminating", complete: "Ruminated"}, + {active: "Mulling", complete: "Mulled"}, + {active: "Ideating", complete: "Ideated"}, + {active: "Imagining", complete: "Imagined"}, + {active: "Contemplating", complete: "Contemplated"}, + {active: "Deliberating", complete: "Deliberated"}, + {active: "Brainstorming", complete: "Brainstormed"}, + {active: "Reflecting", complete: "Reflected"}, + {active: "Wondering", complete: "Wondered"}, + {active: "Meditating", complete: "Meditated"}, + {active: "Speculating", complete: "Speculated"}, + {active: "Theorizing", complete: "Theorized"}, + {active: "Figuring", complete: "Figured"}, + {active: "Conceiving", complete: "Conceived"}, + {active: "Envisioning", complete: "Envisioned"}, + {active: "Visualizing", complete: "Visualized"}, + {active: "Daydreaming", complete: "Daydreamed"}, + {active: "Philosophizing", complete: "Philosophized"}, + {active: "Crafting", complete: "Crafted"}, + {active: "Assembling", complete: "Assembled"}, + {active: "Forging", complete: "Forged"}, + {active: "Fabricating", complete: "Fabricated"}, + {active: "Tinkering", complete: "Tinkered"}, + {active: "Mending", complete: "Mended"}, + {active: "Polishing", complete: "Polished"}, + {active: "Refining", complete: "Refined"}, + {active: "Weaving", complete: "Wove"}, + {active: "Sculpting", complete: "Sculpted"}, + {active: "Carving", complete: "Carved"}, + {active: "Molding", complete: "Molded"}, + {active: "Shaping", complete: "Shaped"}, + {active: "Designing", complete: "Designed"}, + {active: "Drafting", complete: "Drafted"}, + {active: "Sketching", complete: "Sketched"}, + {active: "Drawing", complete: "Drew"}, + {active: "Painting", complete: "Painted"}, + {active: "Sewing", complete: "Sewed"}, + {active: "Knitting", complete: "Knitted"}, + {active: "Stitching", complete: "Stitched"}, + {active: "Tailoring", complete: "Tailored"}, + {active: "Welding", complete: "Welded"}, + {active: "Soldering", complete: "Soldered"}, + {active: "Riveting", complete: "Riveted"}, + {active: "Gluing", complete: "Glued"}, + {active: "Taping", complete: "Taped"}, + {active: "Binding", complete: "Bound"}, + {active: "Erecting", complete: "Erected"}, + {active: "Cooking", complete: "Cooked"}, + {active: "Baking", complete: "Baked"}, + {active: "Brewing", complete: "Brewed"}, + {active: "Simmering", complete: "Simmered"}, + {active: "Stirring", complete: "Stirred"}, + {active: "Whisking", complete: "Whisked"}, + {active: "Kneading", complete: "Kneaded"}, + {active: "Roasting", complete: "Roasted"}, + {active: "Seasoning", complete: "Seasoned"}, + {active: "Marinating", complete: "Marinated"}, + {active: "Zesting", complete: "Zested"}, + {active: "Frying", complete: "Fried"}, + {active: "Sautéing", complete: "Sautéed"}, + {active: "Boiling", complete: "Boiled"}, + {active: "Steaming", complete: "Steamed"}, + {active: "Grilling", complete: "Grilled"}, + {active: "Broiling", complete: "Broiled"}, + {active: "Toasting", complete: "Toasted"}, + {active: "Microwaving", complete: "Microwaved"}, + {active: "Chopping", complete: "Chopped"}, + {active: "Dicing", complete: "Diced"}, + {active: "Mincing", complete: "Minced"}, + {active: "Slicing", complete: "Sliced"}, + {active: "Peeling", complete: "Peeled"}, + {active: "Grating", complete: "Grated"}, + {active: "Blending", complete: "Blended"}, + {active: "Pureeing", complete: "Pureed"}, + {active: "Mashing", complete: "Mashed"}, + {active: "Whipping", complete: "Whipped"}, + {active: "Glazing", complete: "Glazed"}, + {active: "Frolicking", complete: "Frolicked"}, + {active: "Moonwalking", complete: "Moonwalked"}, + {active: "Wandering", complete: "Wandered"}, + {active: "Roaming", complete: "Roamed"}, + {active: "Meandering", complete: "Meandered"}, + {active: "Zooming", complete: "Zoomed"}, + {active: "Shuffling", complete: "Shuffled"}, + {active: "Juggling", complete: "Juggled"}, + {active: "Spelunking", complete: "Spelunked"}, + {active: "Herding", complete: "Herded"}, + {active: "Wrangling", complete: "Wrangled"}, + {active: "Vibing", complete: "Vibed"}, + {active: "Dancing", complete: "Danced"}, + {active: "Prancing", complete: "Pranced"}, + {active: "Hopping", complete: "Hopped"}, + {active: "Jumping", complete: "Jumped"}, + {active: "Leaping", complete: "Leaped"}, + {active: "Bounding", complete: "Bounded"}, + {active: "Sprinting", complete: "Sprinted"}, + {active: "Jogging", complete: "Jogged"}, + {active: "Walking", complete: "Walked"}, + {active: "Strolling", complete: "Strolled"}, + {active: "Sauntering", complete: "Sauntered"}, + {active: "Ambling", complete: "Ambled"}, + {active: "Strutting", complete: "Strutted"}, + {active: "Marching", complete: "Marched"}, + {active: "Pacing", complete: "Paced"}, + {active: "Crawling", complete: "Crawled"}, + {active: "Creeping", complete: "Crept"}, + {active: "Sneaking", complete: "Sneaked"}, + {active: "Sliding", complete: "Slid"}, + {active: "Gliding", complete: "Glided"}, + {active: "Skating", complete: "Skated"}, + {active: "Skiing", complete: "Skied"}, + {active: "Surfing", complete: "Surfed"}, + {active: "Swimming", complete: "Swam"}, + {active: "Diving", complete: "Dived"}, + {active: "Floating", complete: "Floated"}, + {active: "Crunching", complete: "Crunched"}, + {active: "Combobulating", complete: "Combobulated"}, + {active: "Noodling", complete: "Noodled"}, + {active: "Doodling", complete: "Doodled"}, + {active: "Fiddling", complete: "Fiddled"}, + {active: "Finessing", complete: "Finessed"}, + {active: "Whirring", complete: "Whirred"}, + {active: "Wibbling", complete: "Wibbled"}, + {active: "Wobbling", complete: "Wobbled"}, + {active: "Conjuring", complete: "Conjured"}, + {active: "Untangling", complete: "Untangled"}, + {active: "Unfurling", complete: "Unfurled"}, + {active: "Percolating", complete: "Percolated"}, + {active: "Bubbling", complete: "Bubbled"}, + {active: "Fizzing", complete: "Fizzed"}, + {active: "Sizzling", complete: "Sizzled"}, + {active: "Popping", complete: "Popped"}, + {active: "Crackling", complete: "Crackled"}, + {active: "Snapping", complete: "Snapped"}, + {active: "Rustling", complete: "Rustled"}, + {active: "Humming", complete: "Hummed"}, + {active: "Buzzing", complete: "Buzzed"}, + {active: "Beeping", complete: "Beeped"}, + {active: "Booping", complete: "Booped"}, + {active: "Clicking", complete: "Clicked"}, + {active: "Clacking", complete: "Clacked"}, + {active: "Tapping", complete: "Tapped"}, + {active: "Rattling", complete: "Rattled"}, + {active: "Jingling", complete: "Jingled"}, + {active: "Tinkling", complete: "Tinkled"}, + {active: "Chiming", complete: "Chimed"}, + {active: "Gurgling", complete: "Gurgled"}, + {active: "Slurping", complete: "Slurped"}, + {active: "Munching", complete: "Munched"}, + {active: "Sniffing", complete: "Sniffed"}, + {active: "Snooping", complete: "Snooped"}, + {active: "Sleuthing", complete: "Sleuthed"}, + {active: "Meowing", complete: "Meowed"}, + {active: "Purring", complete: "Purred"}, + {active: "Pawing", complete: "Pawed"}, + {active: "Whiskering", complete: "Whiskered"}, + {active: "Loafing", complete: "Loafed"}, + {active: "Catnapping", complete: "Catnapped"}, + {active: "Pouncing", complete: "Pounced"}, + {active: "Prowling", complete: "Prowled"}, + {active: "Scratching", complete: "Scratched"}, + {active: "Spitting", complete: "Spat"}, + {active: "Yowling", complete: "Yowled"}, + {active: "Caterwauling", complete: "Caterwauled"}, + {active: "Chirping", complete: "Chirped"}, + {active: "Chattering", complete: "Chattered"}, + {active: "Trilling", complete: "Trilled"}, + {active: "Snuggling", complete: "Snuggled"}, + {active: "Cuddling", complete: "Cuddled"}, + {active: "Nuzzling", complete: "Nuzzled"}, + {active: "Rubbing", complete: "Rubbed"}, + {active: "Grooming", complete: "Groomed"}, + {active: "Licking", complete: "Licked"}, + {active: "Washing", complete: "Washed"}, + {active: "Bathing", complete: "Bathed"}, + {active: "Sleeping", complete: "Slept"}, + {active: "Dozing", complete: "Dozed"}, + {active: "Snoozing", complete: "Snoozed"}, + {active: "Resting", complete: "Rested"}, + {active: "Relaxing", complete: "Relaxed"}, + {active: "Lounging", complete: "Lounged"}, + {active: "Stretching", complete: "Stretched"}, + {active: "Yawning", complete: "Yawned"}, + {active: "Basting", complete: "Basted"}, + {active: "Caramelizing", complete: "Caramelized"}, + {active: "Poaching", complete: "Poached"}, + {active: "Braising", complete: "Braised"}, + {active: "Barbecuing", complete: "Barbecued"}, + {active: "Parboiling", complete: "Parboiled"}, + {active: "Infusing", complete: "Infused"}, + {active: "Sifting", complete: "Sifted"}, + {active: "Sprinkling", complete: "Sprinkled"}, + {active: "Drizzling", complete: "Drizzled"}, + {active: "Scooping", complete: "Scooped"}, + {active: "Ladling", complete: "Ladled"}, + {active: "Dunking", complete: "Dunked"}, + {active: "Nibbling", complete: "Nibbled"}, + {active: "Snacking", complete: "Snacked"}, + {active: "Feasting", complete: "Feasted"}, + {active: "Grazing", complete: "Grazed"}, + {active: "Plating", complete: "Plated"}, + {active: "Garnishing", complete: "Garnished"}, + {active: "Crumbing", complete: "Crumbed"}, + {active: "Frosting", complete: "Frosted"}, + {active: "Icing", complete: "Iced"}, + {active: "Dusting", complete: "Dusted"}, + {active: "Tempering", complete: "Tempered"}, + {active: "Fermenting", complete: "Fermented"}, + {active: "Pickling", complete: "Pickled"}, + {active: "Churning", complete: "Churned"}, + {active: "Candying", complete: "Candied"}, + {active: "Stir-frying", complete: "Stir-fried"}, + {active: "Flambéing", complete: "Flambéed"}, + {active: "Dabbling", complete: "Dabbled"}, + {active: "Spritzing", complete: "Spritzed"}, + {active: "Zigzagging", complete: "Zigzagged"}, + {active: "Nourishing", complete: "Nourished"}, + {active: "Browning", complete: "Browned"}, + {active: "Dipping", complete: "Dipped"}, + {active: "Savoring", complete: "Savored"}, + {active: "Scampering", complete: "Scampered"}, + {active: "Galloping", complete: "Galloped"}, + {active: "Trotting", complete: "Trotted"}, + {active: "Cantering", complete: "Cantered"}, + {active: "Nesting", complete: "Nested"}, + {active: "Foraging", complete: "Foraged"}, + {active: "Fluttering", complete: "Fluttered"}, + {active: "Flapping", complete: "Flapped"}, + {active: "Swooping", complete: "Swooped"}, + {active: "Soaring", complete: "Soared"}, + {active: "Skittering", complete: "Skittered"}, + {active: "Scurrying", complete: "Scurried"}, + {active: "Burrowing", complete: "Burrowed"}, + {active: "Digging", complete: "Dug"}, + {active: "Snuffling", complete: "Snuffled"}, + {active: "Whinnying", complete: "Whinnied"}, + {active: "Barking", complete: "Barked"}, + {active: "Woofing", complete: "Woofed"}, + {active: "Yipping", complete: "Yipped"}, + {active: "Howling", complete: "Howled"}, + {active: "Growling", complete: "Growled"}, + {active: "Chirruping", complete: "Chirruped"}, + {active: "Tweeting", complete: "Tweeted"}, + {active: "Squawking", complete: "Squawked"}, + {active: "Hooting", complete: "Hooted"}, + {active: "Cooing", complete: "Cooed"}, + {active: "Quacking", complete: "Quacked"}, + {active: "Honking", complete: "Honked"}, + {active: "Croaking", complete: "Croaked"}, + {active: "Ribbiting", complete: "Ribbited"}, + {active: "Bellowing", complete: "Bellowed"}, + {active: "Roaring", complete: "Roared"}, + {active: "Trumpeting", complete: "Trumpeted"}, + {active: "Wagging", complete: "Wagged"}, + {active: "Slinking", complete: "Slunk"}, + {active: "Curling up", complete: "Curled up"}, + {active: "Paddling", complete: "Paddled"}, + {active: "Perching", complete: "Perched"}, + {active: "Preening", complete: "Preened"}, + {active: "Sunbathing", complete: "Sunbathed"}, + {active: "Hibernating", complete: "Hibernated"}, + {active: "Murmuring", complete: "Murmured"}, + {active: "Waddling", complete: "Waddled"}, + {active: "Calligraphing", complete: "Calligraphed"}, + {active: "Lettering", complete: "Lettered"}, + {active: "Inking", complete: "Inked"}, + {active: "Coloring", complete: "Colored"}, + {active: "Shading", complete: "Shaded"}, + {active: "Crosshatching", complete: "Crosshatched"}, + {active: "Outlining", complete: "Outlined"}, + {active: "Stippling", complete: "Stippled"}, + {active: "Blotting", complete: "Blotted"}, + {active: "Dabbing", complete: "Dabbed"}, + {active: "Sponging", complete: "Sponged"}, + {active: "Smudging", complete: "Smudged"}, + {active: "Blurring", complete: "Blurred"}, + {active: "Etching", complete: "Etched"}, + {active: "Engraving", complete: "Engraved"}, + {active: "Embossing", complete: "Embossed"}, + {active: "Hammering", complete: "Hammered"}, + {active: "Sawing", complete: "Sawed"}, + {active: "Sanding", complete: "Sanded"}, + {active: "Varnishing", complete: "Varnished"}, + {active: "Lacquering", complete: "Lacquered"}, + {active: "Beading", complete: "Beaded"}, + {active: "Crocheting", complete: "Crocheted"}, + {active: "Macraméing", complete: "Macraméed"}, + {active: "Quilting", complete: "Quilted"}, + {active: "Patchworking", complete: "Patchworked"}, + {active: "Felting", complete: "Felted"}, + {active: "Origami-making", complete: "Made origami"}, + {active: "Pottering", complete: "Pottered"}, + {active: "Wheel-throwing", complete: "Threw pots"}, + {active: "Potting", complete: "Potted"}, + {active: "Kiln-firing", complete: "Fired the kiln"}, + {active: "Bookbinding", complete: "Bound books"}, + {active: "Screenprinting", complete: "Screenprinted"}, + {active: "Collaging", complete: "Collaged"}, + {active: "Knotting", complete: "Knotted"}, + {active: "Harmonizing", complete: "Harmonized"}, + {active: "Improvising", complete: "Improvised"}, + {active: "Serenading", complete: "Serenaded"}, + {active: "Crooning", complete: "Crooned"}, + {active: "Singing", complete: "Sang"}, + {active: "Whistling", complete: "Whistled"}, + {active: "Warbling", complete: "Warbled"}, + {active: "Riffing", complete: "Riffed"}, + {active: "Strumming", complete: "Strummed"}, + {active: "Plucking", complete: "Plucked"}, + {active: "Pizzicato playing", complete: "Played pizzicato"}, + {active: "Drumming", complete: "Drummed"}, + {active: "Beatboxing", complete: "Beatboxed"}, + {active: "Scatting", complete: "Scatted"}, + {active: "Rapping", complete: "Rapped"}, + {active: "Yodeling", complete: "Yodeled"}, + {active: "Jamming", complete: "Jammed"}, + {active: "Grooving", complete: "Grooved"}, + {active: "Headbanging", complete: "Headbanged"}, + {active: "Swaying", complete: "Swayed"}, + {active: "Twisting", complete: "Twisted"}, + {active: "Twirling", complete: "Twirled"}, + {active: "Pirouetting", complete: "Pirouetted"}, + {active: "Waltzing", complete: "Waltzed"}, + {active: "Tangoing", complete: "Tangoed"}, + {active: "Salsa dancing", complete: "Salsa danced"}, + {active: "Two-stepping", complete: "Two-stepped"}, + {active: "Toe-tapping", complete: "Toe-tapped"}, + {active: "Finger-snapping", complete: "Finger-snapped"}, + {active: "Rhyming", complete: "Rhymed"}, + {active: "Composing", complete: "Composed"}, + {active: "Arranging", complete: "Arranged"}, + {active: "Conducting", complete: "Conducted"}, + {active: "Rambling", complete: "Rambled"}, + {active: "Trekking", complete: "Trekked"}, + {active: "Hiking", complete: "Hiked"}, + {active: "Clambering", complete: "Clambered"}, + {active: "Scrambling", complete: "Scrambled"}, + {active: "Scaling", complete: "Scaled"}, + {active: "Rappelling", complete: "Rappelled"}, + {active: "Belaying", complete: "Belayed"}, + {active: "Abseiling", complete: "Abseiled"}, + {active: "Rowing", complete: "Rowed"}, + {active: "Canoeing", complete: "Canoed"}, + {active: "Kayaking", complete: "Kayaked"}, + {active: "Rafting", complete: "Rafted"}, + {active: "Sailing", complete: "Sailed"}, + {active: "Yachting", complete: "Yachted"}, + {active: "Snorkeling", complete: "Snorkeled"}, + {active: "Drifting", complete: "Drifted"}, + {active: "Cruising", complete: "Cruised"}, + {active: "Trundling", complete: "Trundled"}, + {active: "Rolling", complete: "Rolled"}, + {active: "Scooting", complete: "Scooted"}, + {active: "Skedaddling", complete: "Skedaddled"}, + {active: "Darting", complete: "Darted"}, + {active: "Dashing", complete: "Dashed"}, + {active: "Zipping", complete: "Zipped"}, + {active: "Tiptoeing", complete: "Tiptoed"}, + {active: "Tumbling", complete: "Tumbled"}, + {active: "Somersaulting", complete: "Somersaulted"}, + {active: "Cartwheeling", complete: "Cartwheeled"}, + {active: "Backflipping", complete: "Backflipped"}, + {active: "Vaulting", complete: "Vaulted"}, + {active: "Pole-vaulting", complete: "Pole-vaulted"}, + {active: "Bouldering", complete: "Bouldered"}, + {active: "Geocaching", complete: "Geocached"}, + {active: "Stargazing", complete: "Stargazed"}, + {active: "Cloudwatching", complete: "Cloudwatched"}, + {active: "Birdwatching", complete: "Birdwatched"}, + {active: "People-watching", complete: "People-watched"}, + {active: "Observing", complete: "Observed"}, + {active: "Magnifying", complete: "Magnified"}, + {active: "Peering", complete: "Peered"}, + {active: "Squinting", complete: "Squinted"}, + {active: "Graphing", complete: "Graphed"}, + {active: "Plotting", complete: "Plotted"}, + {active: "Triangulating", complete: "Triangulated"}, + {active: "Extrapolating", complete: "Extrapolated"}, + {active: "Interpolating", complete: "Interpolated"}, + {active: "Tabulating", complete: "Tabulated"}, + {active: "Number-crunching", complete: "Crunched numbers"}, + {active: "Abacus-twirling", complete: "Twirled an abacus"}, + {active: "Counting stars", complete: "Counted stars"}, + {active: "Planet-spotting", complete: "Spotted planets"}, + {active: "Comet-watching", complete: "Watched comets"}, + {active: "Microscoping", complete: "Used a microscope"}, + {active: "Spectating", complete: "Spectated"}, + {active: "Field-noting", complete: "Took field notes"}, + {active: "Star-charting", complete: "Charted stars"}, + {active: "Collecting pebbles", complete: "Collected pebbles"}, + {active: "Measuring shadows", complete: "Measured shadows"}, + {active: "Seashell-arranging", complete: "Arranged seashells"}, + {active: "Blossoming", complete: "Blossomed"}, + {active: "Blooming", complete: "Bloomed"}, + {active: "Budding", complete: "Budded"}, + {active: "Sprouting", complete: "Sprouted"}, + {active: "Germinating", complete: "Germinated"}, + {active: "Growing", complete: "Grew"}, + {active: "Rooting", complete: "Rooted"}, + {active: "Branching", complete: "Branched"}, + {active: "Photosynthesizing", complete: "Photosynthesized"}, + {active: "Pollinating", complete: "Pollinated"}, + {active: "Seedling-tending", complete: "Tended seedlings"}, + {active: "Watering", complete: "Watered"}, + {active: "Mulching", complete: "Mulched"}, + {active: "Raking", complete: "Raked"}, + {active: "Pruning", complete: "Pruned"}, + {active: "Planting", complete: "Planted"}, + {active: "Sowing", complete: "Sowed"}, + {active: "Harvesting", complete: "Harvested"}, + {active: "Weeding", complete: "Weeded"}, + {active: "Composting", complete: "Composted"}, + {active: "Tending roses", complete: "Tended roses"}, + {active: "Raining", complete: "Rained"}, + {active: "Snowing", complete: "Snowed"}, + {active: "Hailing", complete: "Hailed"}, + {active: "Thundering", complete: "Thundered"}, + {active: "Breezing", complete: "Breezed"}, + {active: "Gusting", complete: "Gusted"}, + {active: "Rippling", complete: "Rippled"}, + {active: "Shimmering", complete: "Shimmered"}, + {active: "Glittering", complete: "Glittered"}, + {active: "Glistening", complete: "Glistened"}, + {active: "Sparkling", complete: "Sparkled"}, + {active: "Twinkling", complete: "Twinkled"}, + {active: "Narrating", complete: "Narrated"}, + {active: "Storytelling", complete: "Told stories"}, + {active: "Whispering", complete: "Whispered"}, + {active: "Browsing books", complete: "Browsed books"}, + {active: "Page-turning", complete: "Turned pages"}, + {active: "Leafing through", complete: "Leafed through"}, + {active: "Bookmarking", complete: "Bookmarked"}, + {active: "Scribbling", complete: "Scribbled"}, + {active: "Journaling", complete: "Journaled"}, + {active: "Freewriting", complete: "Freewrote"}, + {active: "Annotating", complete: "Annotated"}, + {active: "Dramatizing", complete: "Dramatized"}, + {active: "Acting", complete: "Acted"}, + {active: "Rehearsing", complete: "Rehearsed"}, + {active: "Reciting", complete: "Recited"}, + {active: "Quoting", complete: "Quoted"}, + {active: "Monologuing", complete: "Monologued"}, + {active: "Dialoguing", complete: "Dialogued"}, + {active: "Captioning", complete: "Captioned"}, + {active: "Alliterating", complete: "Alliterated"}, + {active: "Pun-making", complete: "Made puns"}, + {active: "Riddling", complete: "Riddled"}, + {active: "Riddle-solving", complete: "Solved riddles"}, + {active: "Plot-twisting", complete: "Twisted the plot"}, + {active: "Character-sketching", complete: "Sketched characters"}, + {active: "Worldbuilding", complete: "Built worlds"}, + {active: "Imagining dragons", complete: "Imagined dragons"}, + {active: "Fable-spinning", complete: "Spun fables"}, + {active: "Lore-building", complete: "Built lore"}, + {active: "Confetti-tossing", complete: "Tossed confetti"}, + {active: "Bubble-blowing", complete: "Blew bubbles"}, + {active: "Bubble-popping", complete: "Popped bubbles"}, + {active: "Balloon-twisting", complete: "Twisted balloons"}, + {active: "Party-hatting", complete: "Wore a party hat"}, + {active: "Hula-hooping", complete: "Hula-hooped"}, + {active: "Limboing", complete: "Limboed"}, + {active: "Pogo-sticking", complete: "Pogo-sticked"}, + {active: "Sock-sliding", complete: "Slid in socks"}, + {active: "Pillow-forting", complete: "Built pillow forts"}, + {active: "Blanket-burritoing", complete: "Became a blanket burrito"}, + {active: "Couch-surfing", complete: "Couch-surfed"}, + {active: "Silly-walking", complete: "Silly-walked"}, + {active: "Air-guitaring", complete: "Air-guitared"}, + {active: "Finger-gunning", complete: "Finger-gunned"}, + {active: "Jazz-handing", complete: "Jazz-handed"}, + {active: "Jazz-stepping", complete: "Jazz-stepped"}, + {active: "Eyebrow-raising", complete: "Raised an eyebrow"}, + {active: "Hat-tipping", complete: "Tipped a hat"}, + {active: "Winking", complete: "Winked"}, + {active: "Grinning", complete: "Grinned"}, + {active: "Giggling", complete: "Giggled"}, + {active: "Chuckling", complete: "Chuckled"}, + {active: "Snickering", complete: "Snickered"}, + {active: "Chortling", complete: "Chortled"}, + {active: "Guffawing", complete: "Guffawed"}, + {active: "Cackling", complete: "Cackled"}, + {active: "Tee-heeing", complete: "Tee-heed"}, + {active: "Whimsy-wrangling", complete: "Wrangled whimsy"}, + {active: "Mischief-making", complete: "Made mischief"}, + {active: "Shenaniganizing", complete: "Made shenanigans"}, + {active: "Goofing off", complete: "Goofed off"}, + {active: "Loitering", complete: "Loitered"}, + {active: "Lollygagging", complete: "Lollygagged"}, + {active: "Dillydallying", complete: "Dillydallied"}, + {active: "Dawdling", complete: "Dawdled"}, + {active: "Faffing", complete: "Faffed"}, + {active: "Bumbling", complete: "Bumbled"}, + {active: "Fumbling", complete: "Fumbled"}, + {active: "Winging it", complete: "Winged it"}, + {active: "Wand-waving", complete: "Waved a wand"}, + {active: "Spellcasting", complete: "Cast a spell"}, + {active: "Potion-mixing", complete: "Mixed potions"}, + {active: "Cauldron-stirring", complete: "Stirred a cauldron"}, + {active: "Crystal-gazing", complete: "Gazed into crystals"}, + {active: "Charm-making", complete: "Made charms"}, + {active: "Enchanting", complete: "Enchanted"}, + {active: "Bewitching", complete: "Bewitched"}, + {active: "Transfiguring", complete: "Transfigured"}, + {active: "Transmuting", complete: "Transmuted"}, + {active: "Levitation-practicing", complete: "Practiced levitation"}, + {active: "Teleporting", complete: "Teleported"}, + {active: "Summoning", complete: "Summoned"}, + {active: "Incanting", complete: "Incanted"}, + {active: "Runecarving", complete: "Carved runes"}, + {active: "Rune-reading", complete: "Read runes"}, + {active: "Star-summoning", complete: "Summoned stars"}, + {active: "Dragon-taming", complete: "Tamed dragons"}, + {active: "Phoenix-rising", complete: "Rose like a phoenix"}, + {active: "Cloak-swirling", complete: "Swirled a cloak"}, + {active: "Hat-conjuring", complete: "Conjured a hat"}, + {active: "Rabbit-pulling", complete: "Pulled a rabbit"}, + {active: "Smoke-puffing", complete: "Puffed smoke"}, + {active: "Sparkle-casting", complete: "Cast sparkles"}, + {active: "Dice-rolling", complete: "Rolled dice"}, + {active: "Card-shuffling", complete: "Shuffled cards"}, + {active: "Card-dealing", complete: "Dealt cards"}, + {active: "Puzzle-solving", complete: "Solved puzzles"}, + {active: "Maze-wandering", complete: "Wandered a maze"}, + {active: "Questing", complete: "Quested"}, + {active: "Loot-gathering", complete: "Gathered loot"}, + {active: "Treasure-hunting", complete: "Hunted treasure"}, + {active: "Chest-opening", complete: "Opened chests"}, + {active: "Token-moving", complete: "Moved tokens"}, + {active: "Level-grinding", complete: "Ground levels"}, + {active: "Speedrunning", complete: "Speedran"}, + {active: "Parkouring", complete: "Parkoured"}, + {active: "Button-mashing", complete: "Mashed buttons"}, + {active: "Joystick-wiggling", complete: "Wiggled a joystick"}, + {active: "Victory-dancing", complete: "Victory-danced"}, + {active: "High-fiving", complete: "High-fived"}, + {active: "Fist-bumping", complete: "Fist-bumped"}, + {active: "Coin-flipping", complete: "Flipped coins"}, + {active: "Marble-counting", complete: "Counted marbles"}, + {active: "Chess-thinking", complete: "Thought about chess"}, + {active: "Checkers-hopping", complete: "Hopped checkers"}, + {active: "Domino-stacking", complete: "Stacked dominoes"}, + {active: "Tower-building", complete: "Built towers"}, + {active: "Jenga-juggling", complete: "Juggled Jenga"}, + {active: "Orbiting", complete: "Orbited"}, + {active: "Drifting in space", complete: "Drifted in space"}, + {active: "Rocketing", complete: "Rocketed"}, + {active: "Star-hopping", complete: "Star-hopped"}, + {active: "Moon-hopping", complete: "Moon-hopped"}, + {active: "Satellite-spotting", complete: "Spotted satellites"}, + {active: "Nebula-gazing", complete: "Gazed at nebulae"}, + {active: "Comet-chasing", complete: "Chased comets"}, + {active: "Meteor-watching", complete: "Watched meteors"}, + {active: "Constellation-hunting", complete: "Hunted constellations"}, + {active: "Asteroid-dodging", complete: "Dodged asteroids"}, + {active: "Spacesuit-checking", complete: "Checked a spacesuit"}, + {active: "Galaxy-roaming", complete: "Roamed galaxies"}, + {active: "Blackhole-peeking", complete: "Peeked at black holes"}, + {active: "Cosmic-doodling", complete: "Doodled cosmos"}, + {active: "Gravity-bending", complete: "Bent gravity"}, + {active: "Time-warping", complete: "Warped time"}, + {active: "Wormhole-hopping", complete: "Hopped wormholes"}, + {active: "Star-dusting", complete: "Dusted stars"}, + {active: "Moonbeam-catching", complete: "Caught moonbeams"}, + {active: "Solar-sailing", complete: "Solar-sailed"}, + {active: "Lantern-lighting", complete: "Lit lanterns"}, + {active: "Candle-making", complete: "Made candles"}, + {active: "Firefly-chasing", complete: "Chased fireflies"}, + {active: "Kite-flying", complete: "Flew kites"}, + {active: "Paper-plane-making", complete: "Made paper planes"}, + {active: "Sandcastle-building", complete: "Built sandcastles"}, + {active: "Stone-skimming", complete: "Skimmed stones"}, + {active: "Pebble-stacking", complete: "Stacked pebbles"}, + {active: "Tea-sipping", complete: "Sipped tea"}, + {active: "Coffee-slurping", complete: "Slurped coffee"}, + {active: "Cocoa-stirring", complete: "Stirred cocoa"}, + {active: "Cloud-counting", complete: "Counted clouds"}, + {active: "Shadow-chasing", complete: "Chased shadows"}, + {active: "Sunset-watching", complete: "Watched sunsets"}, + {active: "Sunrise-catching", complete: "Caught sunrises"}, + {active: "Breeze-catching", complete: "Caught a breeze"}, + {active: "Puddle-jumping", complete: "Jumped puddles"}, + {active: "Snowflake-catching", complete: "Caught snowflakes"}, + {active: "Leaf-crunching", complete: "Crunched leaves"}, + {active: "Acorn-collecting", complete: "Collected acorns"}, + {active: "Shell-hunting", complete: "Hunted shells"}, + {active: "Pebble-polishing", complete: "Polished pebbles"}, + {active: "Thread-spooling", complete: "Spooled thread"}, + {active: "Yarn-winding", complete: "Wound yarn"}, + {active: "Ribbon-curling", complete: "Curled ribbons"}, + {active: "Bow-tying", complete: "Tied bows"}, + {active: "Knuckle-cracking", complete: "Cracked knuckles"}, + {active: "Desk-doodling", complete: "Doodled on a desk"}, + {active: "Window-gazing", complete: "Gazed out a window"}, + {active: "Clock-watching", complete: "Watched the clock"}, + {active: "Sundial-reading", complete: "Read a sundial"}, + {active: "Map-unfolding", complete: "Unfolded a map"}, + {active: "Compass-twirling", complete: "Twirled a compass"}, + {active: "Bread-proofing", complete: "Proofed bread"}, + {active: "Noodle-slurping", complete: "Slurped noodles"}, + {active: "Dumpling-pleating", complete: "Pleated dumplings"}, + {active: "Pancake-flipping", complete: "Flipped pancakes"}, + {active: "Waffle-ironing", complete: "Ironed waffles"}, + {active: "Pie-crimping", complete: "Crimped a pie"}, + {active: "Jam-simmering", complete: "Simmered jam"}, + {active: "Sourdough-feeding", complete: "Fed the sourdough"}, + {active: "Lemonade-stirring", complete: "Stirred lemonade"}, + {active: "Popcorn-popping", complete: "Popped popcorn"}, + {active: "Marshmallow-toasting", complete: "Toasted marshmallows"}, + {active: "Kettle-whistling", complete: "Whistled like a kettle"}, + {active: "Lighthouse-keeping", complete: "Kept a lighthouse"}, + {active: "Harbor-watching", complete: "Watched the harbor"}, + {active: "Paper-crane-making", complete: "Made paper cranes"}, + {active: "Lantern-floating", complete: "Floated lanterns"}, + {active: "Pinwheel-spinning", complete: "Spun pinwheels"}, + {active: "Yo-yoing", complete: "Yo-yoed"}, + {active: "Cat's-cradling", complete: "Played cat's cradle"}, + {active: "Hopscotching", complete: "Hopscotched"}, + {active: "Jump-roping", complete: "Jump-roped"}, + {active: "Marble-rolling", complete: "Rolled marbles"}, + {active: "Chalk-drawing", complete: "Drew with chalk"}, + {active: "Hand-shadowing", complete: "Cast hand shadows"}, + {active: "Treehouse-building", complete: "Built a treehouse"}, + {active: "Hammock-swinging", complete: "Swung in a hammock"}, + {active: "Campfire-tending", complete: "Tended a campfire"}, + {active: "S'more-making", complete: "Made s'mores"}, + {active: "Trail-mixing", complete: "Mixed trail mix"}, + {active: "Pond-skimming", complete: "Skimmed a pond"}, + {active: "Lily-padding", complete: "Hopped lily pads"}, + {active: "Tadpole-watching", complete: "Watched tadpoles"}, + {active: "Beekeeping", complete: "Kept bees"}, + {active: "Butterfly-netting", complete: "Netted butterflies"}, + {active: "Moss-gathering", complete: "Gathered moss"}, + {active: "Fern-unfurling", complete: "Unfurled ferns"}, + {active: "Mushroom-spotting", complete: "Spotted mushrooms"}, + {active: "Orchard-wandering", complete: "Wandered an orchard"}, + {active: "Windmill-turning", complete: "Turned a windmill"}, + {active: "Weathervane-spinning", complete: "Spun a weathervane"}, + {active: "Snow-angeling", complete: "Made snow angels"}, + {active: "Snowman-rolling", complete: "Rolled a snowman"}, + {active: "Sled-riding", complete: "Rode a sled"}, + {active: "Mitten-knitting", complete: "Knitted mittens"}, + {active: "Scarf-winding", complete: "Wound a scarf"}, + {active: "Gift-wrapping", complete: "Wrapped gifts"}, + {active: "Card-writing", complete: "Wrote cards"}, + {active: "Letter-sealing", complete: "Sealed a letter"}, + {active: "Stamp-collecting", complete: "Collected stamps"}, + {active: "Typewriter-clacking", complete: "Clacked a typewriter"}, + {active: "Record-spinning", complete: "Spun records"}, + {active: "Metronome-ticking", complete: "Ticked a metronome"}, + {active: "Music-box-winding", complete: "Wound a music box"}, + {active: "Lullaby-humming", complete: "Hummed a lullaby"}, + {active: "Harp-strumming", complete: "Strummed a harp"}, + {active: "Bell-ringing", complete: "Rang bells"}, + {active: "Whistle-carving", complete: "Carved whistles"}, + {active: "Sail-mending", complete: "Mended sails"}, + {active: "Rope-coiling", complete: "Coiled rope"}, + {active: "Net-casting", complete: "Cast a net"}, + {active: "Anchor-dropping", complete: "Dropped anchor"}, + {active: "Tide-pooling", complete: "Explored tide pools"}, + {active: "Sea-glass-hunting", complete: "Hunted sea glass"}, +]; + +/** Stable across redraw, resize, replay and reattachment: it depends only on the command text and when it started, never on a window's own numbering. */ +export function selectActivityVerb(command: string, startedAt: number, previous?: ActivityVerbPair): ActivityVerbPair { + const seed = `${Math.trunc(startedAt)}:${command}`; + let hash = 2166136261; + for (let i = 0; i < seed.length; i++) hash = Math.imul(hash ^ seed.charCodeAt(i), 16777619) >>> 0; + let index = hash % ACTIVITY_VERBS.length; + if (previous && ACTIVITY_VERBS.length > 1 && ACTIVITY_VERBS[index]!.active === previous.active) index = (index + 1) % ACTIVITY_VERBS.length; + return ACTIVITY_VERBS[index]!; +} 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/status/queueStatus.ts b/src/status/queueStatus.ts new file mode 100644 index 00000000..4cae9781 --- /dev/null +++ b/src/status/queueStatus.ts @@ -0,0 +1,79 @@ +import {displayWidth, truncateText} from '../util/text.js'; +import {displaySafe} from '../input/PasteReview.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import type {QueuePause, QueueState} from '../session/CommandQueue.js'; + +/** + * How NMSh shows the command queue. The queue's owner (the session service, or the in-process client) is the one + * source; this module only words and paints it. No animation: a queue waits, it does not work. + */ +const RESET = '\u001b[0m'; + +export function queueGlyph(): string { return getCurrentGlyphMode() === 'safe' ? '=' : '≡'; } +export function pausedGlyph(): string { return getCurrentGlyphMode() === 'safe' ? '||' : '⏸'; } + +/** One line for a queued command: its first line, and how many more it has. */ +export function queuePreview(text: string, limit = 80): string { + const lines = text.split('\n'); + const more = lines.length > 1 ? ` (+${lines.length - 1} line${lines.length === 2 ? '' : 's'})` : ''; + const room = Math.max(1, limit - displayWidth(more)); + // Command text is drawn, never interpreted: a pasted escape sequence shows as ␛, it does not reach the terminal. + return `${truncateText(displaySafe(lines[0]!.trim()) || '(blank first line)', room)}${more}`; +} + +/** Why the queue waits, in words. */ +export function pauseReason(pause: QueuePause): string { + switch (pause.reason) { + case 'failed': return `${commandName(pause.command)} failed (exit ${pause.exitCode})`; + case 'interrupted': return `${commandName(pause.command)} was interrupted`; + case 'shell-switched': return 'the shell changed'; + case 'restored': return 'restored, not resumed'; + case 'user': return 'paused'; + } +} + +function commandName(command: string): string { + return truncateText(displaySafe(command.split('\n')[0]!.trim()) || 'the command', 32); +} + +/** + * The queue's one line under the running command (or at idle while paused), widest form first: + * ≡ Queued (2) · next: adb devices · /queue + * ⏸ Queue paused · make failed (exit 2) · 2 waiting · /queue + * Narrow widths drop the hint, then the reason or preview; the count always stays. + */ +export function queueRow(state: QueueState, columns: number): string | undefined { + const count = state.entries.length; + if (!count || columns <= 0) return undefined; + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const accent = foreground(UI_COLORS.accent); + if (state.paused) { + const head = `${pausedGlyph()} Queue paused`; + const parts = [pauseReason(state.paused), `${count} waiting`, '/queue']; + const forms = [parts, parts.slice(0, 2), [parts[1]!]]; + for (const form of forms) { + const plain = [head, ...form].join(' · '); + if (displayWidth(plain) <= columns) return `${accent}${head}${RESET}${secondary} · ${form.join(' · ')}${RESET}`; + } + return `${accent}${truncateText(`${head} · ${count}`, columns)}${RESET}`; + } + const next = state.entries[0]!; + // The queue is waiting for the person, not for the shell: say so, in the one line that is always there. + if (next.condition === 'approve' && !next.approved) { + const asks = `${queueGlyph()} Queued (${count}) · next needs your approval`; + for (const hint of [' · /queue', '']) { + const room = columns - displayWidth(`${asks}: `) - displayWidth(hint); + if (room >= 8) return `${accent}${asks}${RESET}${subtle}: ${queuePreview(next.text, room)}${hint}${RESET}`; + } + return `${accent}${truncateText(`${queueGlyph()} Queued (${count}) · needs approval`, columns)}${RESET}`; + } + const head = `${queueGlyph()} Queued (${count})`; + const editing = state.editing === next.id ? ' (editing)' : ''; + for (const hint of [' · /queue', '']) { + const room = columns - displayWidth(`${head} · next: `) - displayWidth(editing) - displayWidth(hint); + if (room >= 8) return `${secondary}${head}${RESET}${subtle} · next: ${queuePreview(next.text, room)}${editing}${hint}${RESET}`; + } + return `${secondary}${truncateText(head, columns)}${RESET}`; +} 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/src/terminal/keys.ts b/src/terminal/keys.ts index f6485946..c10baa2f 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -6,10 +6,15 @@ export type Key = | {kind: 'left' | 'right' | 'up' | 'down' | 'lineHome' | 'lineEnd' | 'backspace' | 'delete' | 'enter' | 'newline' | 'complete' | 'escape' | 'selectAll'} | {kind: 'selectLeft' | 'selectRight' | 'selectUp' | 'selectDown' | 'selectLineHome' | 'selectLineEnd'} | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} - | {kind: 'historySearch' | 'historyDelete' | 'find'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails'} & {x?: number; y?: number}; + | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' + /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ + | 'queueCommand' | 'sendToProgram'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ + // F8 walks backwards through failures; Shift+F8 returns towards the newest. + ['\u001B[19~', 'previousFailure'], ['\u001B[19;1~', 'previousFailure'], ['\u001B[57371u', 'previousFailure'], + ['\u001B[19;2~', 'nextFailure'], ['\u001B[57371;2u', 'nextFailure'], ['\u001B[I', 'focusIn'], ['\u001B[O', 'focusOut'], // Kitty keyboard protocol (CSI > 1 u, enabled by TerminalRenderer on entry; @@ -99,6 +104,8 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001B[P', 'palette'], ['\u001B[110;5u', 'suggestNext'], // Kitty Ctrl+N ['\u001B[112;5u', 'suggestPrevious'], // Kitty Ctrl+P + ['\u001B[113;5u', 'queueCommand'], // Kitty Ctrl+Q + ['\u001B[115;5u', 'sendToProgram'], // Kitty Ctrl+S ['\u001B[111;5u', 'toggleDetails'], // Kitty Ctrl+O (lowercase o) ['\u001B[79;5u', 'toggleDetails'], // Kitty Ctrl+O (uppercase O) ]; @@ -171,6 +178,8 @@ export function decodeKeys(input: string): Key[] { else if (value === '\u0012') keys.push({kind: 'historySearch'} as Key); else if (value === '\u0006') keys.push({kind: 'find'} as Key); // Ctrl+F: NMSh transcript find else if (value === '\u000F') keys.push({kind: 'toggleDetails'} as Key); // Ctrl+O + else if (value === '\u0011') keys.push({kind: 'queueCommand'} as Key); // Ctrl+Q: queue (the TTY is raw; no flow control) + else if (value === '\u0013') keys.push({kind: 'sendToProgram'} as Key); // Ctrl+S: send to the running program else if (value === '\u000E') keys.push({kind: 'suggestNext'} as Key); // Ctrl+N else if (value === '\u0010') keys.push({kind: 'suggestPrevious'} as Key); // Ctrl+P else if (codePoint >= 0x20 && codePoint !== 0x7f) keys.push({kind: 'text', value} as Key); diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index 662a724f..ab7ec107 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -2,38 +2,80 @@ import type {CompletedCommand} from '../output/OutputBuffer.js'; import type {PaletteItem} from './CommandPalette.js'; import type {WrappedRow} from '../output/viewport.js'; import {displayWidth} from '../util/text.js'; +import {recordCopyText} from '../clipboard/copySelection.js'; +import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; /** One registry for both pointer-opened and keyboard-opened block palettes. */ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: string}[] = [ {id: 'copyCommand', label: 'Copy command', detail: 'Stored plain command'}, {id: 'copyOutput', label: 'Copy output', detail: 'Full stored output, including folded lines'}, {id: 'copyBoth', label: 'Copy command + output', detail: 'Stored plain command and full output'}, + {id: 'copyReport', label: 'Copy as report…', detail: 'Command, status, exit code, duration and output as Markdown or plain text; reviewed first'}, + {id: 'compare', label: 'Compare output…', detail: 'With the previous run of this command, or another block you choose'}, {id: 'pager', label: 'Open in pager', detail: 'Command and full stored output in your pager (less), read-only'}, {id: 'rerun', label: 'Rerun command', detail: 'Explicitly submit a visible command in the current shell directory'}, {id: 'edit', label: 'Edit & rerun', detail: 'Put command in composer; Enter executes it'}, + {id: 'pin', label: 'Pin command', detail: 'Keep this command to reuse later (/pins); it is never run for you'}, + {id: 'clipCommand', label: 'Keep command as clip', detail: 'Keep text in memory for this frontend; /clips reviews it before insertion'}, + {id: 'clipOutput', label: 'Keep output as clip', detail: 'Keep full plain output in memory, including folded lines; /clips reviews it before insertion'}, {id: 'fold', label: 'Fold / unfold output', detail: 'Same stored disclosure state as Ctrl+O'}, + {id: 'references', label: 'Files, links and more…', detail: 'Open or copy the files, links, commits and devices found in this output'}, {id: 'explain', label: 'Explain failure', detail: "Why it failed, from this block's own output (read-only)"}, ]; export function blockPaletteItems(record: CompletedCommand): PaletteItem[] { - // Explain failure is offered only on failed blocks. - return BLOCK_ACTIONS.filter(action => action.id !== 'explain' || (record.exitCode ?? 0) !== 0).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, + // Explain failure is offered only on failed blocks, and references only when the output has some. + const hasReferences = findOutputReferences(record).length > 0; + return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, detail: action.detail, category: 'Transcript', action: {kind: 'block', id: action.id, startId: record.startId}})); } -export function blockCopyPayload(record: CompletedCommand, action: BlockActionId): string | undefined { +/** What a block's Copy action puts on the clipboard. Output follows "Include completion status"; Copy command never does. */ +export function blockCopyPayload(record: CompletedCommand, action: BlockActionId, includeStatus = false): string | undefined { if (action === 'copyCommand') return record.command; - if (action === 'copyOutput') return record.output; - if (action === 'copyBoth') return [record.command, record.output].filter(Boolean).join('\n'); + if (action === 'copyOutput') return recordCopyText(record, includeStatus); + if (action === 'copyBoth') return [record.command, recordCopyText(record, includeStatus)].filter(Boolean).join('\n'); return undefined; } +const ACTIONS_LABEL = '[Actions]'; +/** The Copy slot is as wide as its confirmation, so "Copied" never moves [Actions]. */ +const copyLabel = (copied: boolean, safe: boolean) => copied ? `[${safe ? '+' : '✓'} Copied]` : '[Copy]'; +const COPY_SLOT = displayWidth(copyLabel(true, false)); + +export interface BlockControls { + /** Plain text appended after the row: padding, then the controls. */ + suffix: string; + /** 1-based column where [Actions] starts. */ + column: number; + /** 1-based columns of the Copy control, when there is room for it. */ + copy?: {column: number; end: number}; +} + +/** Where the controls sit in a row of `columns` cells whose own text is `used` cells wide; undefined when they do not fit. */ +export function blockControlsLayout(used: number, columns: number, options: {copied?: boolean; safe?: boolean} = {}): BlockControls | undefined { + if (columns - used < ACTIONS_LABEL.length + 2) return undefined; + const actionsColumn = columns - ACTIONS_LABEL.length + 1; + // Copy only where both fit with the same two-cell margin; [Actions] keeps its place either way. + if (columns - used >= ACTIONS_LABEL.length + 1 + COPY_SLOT + 2) { + const label = copyLabel(Boolean(options.copied), Boolean(options.safe)); + const slot = ' '.repeat(COPY_SLOT - displayWidth(label)) + label; + const copyColumn = actionsColumn - 1 - COPY_SLOT + (COPY_SLOT - displayWidth(label)); + return {suffix: ' '.repeat(columns - used - ACTIONS_LABEL.length - 1 - COPY_SLOT) + slot + ' ' + ACTIONS_LABEL, column: actionsColumn, + copy: {column: copyColumn, end: actionsColumn - 2}}; + } + return {suffix: ' '.repeat(columns - used - ACTIONS_LABEL.length) + ACTIONS_LABEL, column: actionsColumn}; +} + /** Decorate only an owning visible row, never cover any command or output cell. */ -export function blockAffordance(row: WrappedRow, columns: number): {suffix: string; column: number} | undefined { +export function blockAffordance(row: WrappedRow, columns: number, options: {copied?: boolean; safe?: boolean} = {}): BlockControls | undefined { if (row.blockStartId === undefined || row.isHistoricalHeader || row.isLiveActivity) return undefined; - const label = '[Actions]'; - const used = displayWidth(row.plain); - if (columns - used < label.length + 2) return undefined; - return {suffix: ' '.repeat(columns - used - label.length) + label, column: columns - label.length + 1}; + return blockControlsLayout(displayWidth(row.plain), columns, options); +} + +/** The sticky header's controls: the same layout, on a row whose header text is cut to leave room for them. */ +export function stickyControlsWidth(columns: number): number { + const full = ACTIONS_LABEL.length + 1 + COPY_SLOT + 2; + return columns >= full + 16 ? full : columns >= ACTIONS_LABEL.length + 2 + 16 ? ACTIONS_LABEL.length + 2 : 0; } diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index a48efc51..f1e0e8d1 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -24,7 +24,15 @@ export type PaletteAction = | {kind: 'latest'} | {kind: 'toggleDetails'} | {kind: 'toggleInspector'} - | {kind: 'block'; id: BlockActionId; startId: number}; + | {kind: 'block'; id: BlockActionId; startId: number} + /** An action on one reference found in a block's output; resolved again from the block when it runs. */ + | {kind: 'reference'; startId: number; refId: string; verb: 'open' | 'copy' | 'stage'} + | {kind: 'failure'; direction: 'previous' | 'next'} + | {kind: 'pinHistory'; id: string} + | {kind: 'keepComposerClip'} + | {kind: 'keepHistoryClip'; id: string} + | {kind: 'clip'; id: number; verb: 'review' | 'remove'} + | {kind: 'clearClips'}; export interface PaletteItem { id: string; diff --git a/src/ui/ComparePanel.ts b/src/ui/ComparePanel.ts new file mode 100644 index 00000000..5bf7ed1c --- /dev/null +++ b/src/ui/ComparePanel.ts @@ -0,0 +1,174 @@ +import type {Key} from '../terminal/keys.js'; +import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {renderControlRows} from './controls.js'; +import {focusForeground, foreground, UI_COLORS} from './palette.js'; +import {GLYPHS} from './glyphs.js'; +import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; +import {displaySafe} from '../input/PasteReview.js'; +import {compareRecords, comparisonNotes, previousRun, sideLabel, summary, type Comparison, type DiffLine} from '../output/compare.js'; + +/** + * Compare output…: choose the other block (the previous run of the same command is suggested), then read the + * difference. Records are identified by startId from a snapshot taken when the panel opened; the comparison is + * computed only when both sides are chosen, and again only when an option changes. + */ +export interface ComparePanelState { + base: CompletedCommand; + /** Newest first, as /copy numbers them, without the base block. */ + candidates: Array<{record: CompletedCommand; number: number}>; + suggested?: number; + cursor: number; + comparison?: Comparison; + ignoreWhitespace: boolean; + /** Side-by-side when the window is wide enough and the person has not turned it off. */ + sideBySide: boolean; + scroll: number; + /** A brief note shown in the panel (copy feedback: notices stay out of the way while a panel is open). */ + note?: string; +} + +export function createComparePanel(base: CompletedCommand, recent: readonly CompletedCommand[], partner?: CompletedCommand): ComparePanelState { + const candidates = recent.map((record, index) => ({record, number: index + 1})).filter(item => item.record !== base); + const previous = previousRun(base, recent); + const suggested = previous ? candidates.findIndex(item => item.record === previous) : -1; + const state: ComparePanelState = {base, candidates, ...(suggested >= 0 ? {suggested} : {}), cursor: Math.max(0, suggested), ignoreWhitespace: false, sideBySide: true, scroll: 0}; + if (partner) choose(state, partner); + return state; +} + +function choose(state: ComparePanelState, other: CompletedCommand): void { + state.comparison = compareRecords(state.base, other, {ignoreWhitespace: state.ignoreWhitespace, context: 3}); + state.scroll = 0; +} + +export type ComparePanelAction = {kind: 'close'} | {kind: 'copy'; what: 'diff' | 'report'; comparison: Comparison}; + +/** Rows of the diff view, in order, with where each change starts (for n / N). */ +interface ViewRow { text: string; change: boolean } + +export function comparePanelKey(state: ComparePanelState, key: Key, columns: number, height: number): ComparePanelAction | undefined { + if (!state.comparison) { + const count = state.candidates.length; + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'}; + if (!count) return undefined; + if (key.kind === 'up' || key.kind === 'down') state.cursor = (state.cursor + (key.kind === 'up' ? -1 : 1) + count) % count; + else if (key.kind === 'enter') choose(state, state.candidates[state.cursor]!.record); + return undefined; + } + const comparison = state.comparison; + const rows = viewRows(state, columns); + const room = Math.max(1, height - headerRows(state).length - 3); + const changes = rows.map((row, index) => row.change && !rows[index - 1]?.change ? index : -1).filter(index => index >= 0); + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) { + // Esc goes back to choosing the other side; a second Esc closes. + state.comparison = undefined; + return undefined; + } + if (key.kind === 'up' || key.kind === 'down') state.scroll += key.kind === 'up' ? -1 : 1; + else if (key.kind === 'pageUp' || key.kind === 'pageDown') state.scroll += (key.kind === 'pageUp' ? -1 : 1) * Math.max(1, room - 1); + else if (key.kind === 'text' && key.value === 'n') state.scroll = changes.find(index => index > state.scroll) ?? state.scroll; + else if (key.kind === 'text' && key.value === 'N') state.scroll = [...changes].reverse().find(index => index < state.scroll) ?? state.scroll; + else if (key.kind === 'text' && key.value === 'w') { state.ignoreWhitespace = !state.ignoreWhitespace; choose(state, comparison.a === state.base ? comparison.b : comparison.a); } + else if (key.kind === 'text' && key.value === 's') state.sideBySide = !state.sideBySide; + else if (key.kind === 'text' && key.value === 'c') return {kind: 'copy', what: 'diff', comparison}; + else if (key.kind === 'text' && key.value === 'r') return {kind: 'copy', what: 'report', comparison}; + state.scroll = Math.max(0, Math.min(state.scroll, Math.max(0, rows.length - room))); + return undefined; +} + +/** Side by side needs room for two readable columns. */ +export const SIDE_BY_SIDE_COLUMNS = 140; + +function viewRows(state: ComparePanelState, columns: number): ViewRow[] { + const comparison = state.comparison!; + const width = Math.max(10, columns - 4); + const added = foreground(UI_COLORS.success); + const removed = foreground(UI_COLORS.failure); + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const reset = '\u001b[0m'; + const rows: ViewRow[] = []; + if (comparison.identical) return [{text: `${secondary}No differences.${reset}`, change: false}]; + const sideBySide = state.sideBySide && columns >= SIDE_BY_SIDE_COLUMNS; + const half = Math.floor((width - 3) / 2); + const cell = (line: DiffLine | undefined, side: 'a' | 'b') => { + if (!line) return ' '.repeat(half); + const number = side === 'a' ? (line.kind !== 'add' ? line.a + 1 : undefined) : (line.kind !== 'remove' ? line.b + 1 : undefined); + const color = line.kind === 'same' ? secondary : line.kind === 'add' ? added : removed; + const text = `${String(number ?? '').padStart(5)} ${truncateText(displaySafe(line.text), Math.max(1, half - 6))}`; + return `${color}${padCells(text, half)}${reset}`; + }; + comparison.hunks.forEach((hunk, index) => { + const label = `@@ A ${hunk.aStart + 1}–${hunk.aStart + hunk.aCount} · B ${hunk.bStart + 1}–${hunk.bStart + hunk.bCount}`; + if (index > 0) rows.push({text: `${subtle}⋯${reset}`, change: false}); + rows.push({text: `${subtle}${truncateText(label, width)}${reset}`, change: false}); + if (!sideBySide) { + for (const line of hunk.lines) { + const mark = line.kind === 'same' ? ' ' : line.kind === 'add' ? '+' : '-'; + const color = line.kind === 'same' ? secondary : line.kind === 'add' ? added : removed; + rows.push({text: `${color}${mark} ${truncateText(displaySafe(line.text), width - 2)}${reset}`, change: line.kind !== 'same'}); + } + return; + } + // Side by side: a run of removals pairs with the additions that follow it. + for (let at = 0; at < hunk.lines.length;) { + const line = hunk.lines[at]!; + if (line.kind === 'same') { rows.push({text: `${cell(line, 'a')} ${subtle}│${reset} ${cell(line, 'b')}`, change: false}); at += 1; continue; } + const removes: DiffLine[] = []; + const adds: DiffLine[] = []; + while (hunk.lines[at]?.kind === 'remove') removes.push(hunk.lines[at++]!); + while (hunk.lines[at]?.kind === 'add') adds.push(hunk.lines[at++]!); + for (let pair = 0; pair < Math.max(removes.length, adds.length); pair += 1) { + rows.push({text: `${cell(removes[pair], 'a')} ${subtle}│${reset} ${cell(adds[pair], 'b')}`, change: true}); + } + } + }); + return rows; +} + +function headerRows(state: ComparePanelState): string[] { + const comparison = state.comparison!; + return ['title', 'a', 'b', 'summary', ...comparisonNotes(comparison).map(() => 'note'), 'gap']; +} + +export function renderComparePanel(state: ComparePanelState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const width = Math.max(10, columns - 4); + const out: string[] = []; + if (!state.comparison) { + out.push(` ${primary}Compare output${reset} ${subtle}${truncateText(`with ${displaySafe(sideLabel(state.base))}`, Math.max(4, width - 16))}${reset}`, ''); + if (!state.candidates.length) out.push(` ${secondary}No other completed command to compare with.${reset}`); + const controls = renderControlRows([['↑↓', 'choose'], ['Enter', 'compare'], ['Esc', 'cancel']], width); + const room = Math.max(1, height - out.length - controls.length - 1); + const first = Math.max(0, Math.min(state.cursor - Math.floor(room / 2), state.candidates.length - room)); + state.candidates.slice(first, first + room).forEach(({record, number}, offset) => { + const index = first + offset; + const focused = index === state.cursor; + const note = index === state.suggested ? ' previous run' : ''; + const label = truncateText(displaySafe(sideLabel(record)), Math.max(4, width - 8 - displayWidth(note))); + out.push(`${focused ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${String(number).padStart(3)}${reset} ${focusForeground(focused)}${label}${reset}${accent}${note}${reset}`); + }); + out.push('', ...controls.map(row => ` ${row}`)); + return out.map(line => truncateAnsi(line, columns)); + } + const comparison = state.comparison; + const sideBySideAvailable = columns >= SIDE_BY_SIDE_COLUMNS; + out.push(` ${primary}Compare output${reset} ${subtle}${truncateText(sideBySideAvailable && state.sideBySide ? 'side by side' : 'unified', width - 16)}${reset}`); + out.push(` ${foreground(UI_COLORS.failure)}A${reset} ${secondary}${truncateText(displaySafe(sideLabel(comparison.a)), width - 2)}${reset}`); + out.push(` ${foreground(UI_COLORS.success)}B${reset} ${secondary}${truncateText(displaySafe(sideLabel(comparison.b)), width - 2)}${reset}`); + out.push(` ${accent}${truncateText(summary(comparison), width)}${reset}${state.note ? `${subtle} · ${truncateText(state.note, Math.max(4, width - displayWidth(summary(comparison)) - 5))}${reset}` : ''}`); + for (const note of comparisonNotes(comparison)) out.push(` ${subtle}${truncateText(note, width)}${reset}`); + out.push(''); + const controls = renderControlRows([['↑↓', 'scroll'], ['n/N', 'next/previous change'], ['w', state.ignoreWhitespace ? 'exact' : 'ignore spaces'], + ...(sideBySideAvailable ? [['s', state.sideBySide ? 'unified' : 'side by side'] as const] : []), ['c', 'copy diff'], ['r', 'report…'], ['Esc', 'back']], width); + const rows = viewRows(state, columns); + const room = Math.max(1, height - out.length - controls.length - 1); + state.scroll = Math.max(0, Math.min(state.scroll, Math.max(0, rows.length - room))); + for (const row of rows.slice(state.scroll, state.scroll + room)) out.push(` ${row.text}`); + out.push('', ...controls.map(row => ` ${row}`)); + return out.map(line => truncateAnsi(line, columns)).slice(0, Math.max(1, height)); +} diff --git a/src/ui/CopyPicker.ts b/src/ui/CopyPicker.ts new file mode 100644 index 00000000..5fe068d1 --- /dev/null +++ b/src/ui/CopyPicker.ts @@ -0,0 +1,148 @@ +import type {Key} from '../terminal/keys.js'; +import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {renderControlRows} from './controls.js'; +import {focusForeground, foreground, UI_COLORS} from './palette.js'; +import {GLYPHS} from './glyphs.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {copyStats} from '../clipboard/clipboard.js'; +import {COPY_MAX_RECORDS} from '../clipboard/copySelection.js'; +import {displaySafe} from '../input/PasteReview.js'; + +/** + * `/copy` as an Interactive Picker: recent completed commands, newest first and numbered as /copy numbers them, to + * copy one or several. It holds a snapshot of the records taken when it opened (identified by startId, never by + * screen position), so output that arrives meanwhile cannot change what a row means. Copying happens in the caller, + * through the same payload builder as /copy N; the picker only decides which records, and whether completion + * statuses are included. + */ +export interface CopyPickerState { + /** Newest first, as /copy numbers them (records[0] is /copy 1). */ + records: CompletedCommand[]; + /** Search text (`/` starts typing it); rows whose command or output contain it, ignoring case. */ + query: string; + searching: boolean; + /** Position within the visible (filtered) rows. */ + cursor: number; + /** Chosen records by startId; kept while the search changes. Enter with none chosen copies the highlighted row. */ + chosen: Set; + /** Starts from the setting (or the --status / --no-status given with /copy); `s` toggles it for this copy. */ + includeStatus: boolean; + /** The highlighted output's first lines below the list. */ + preview: boolean; +} + +export function createCopyPicker(recent: readonly CompletedCommand[], includeStatus: boolean): CopyPickerState { + return {records: recent.slice(0, COPY_MAX_RECORDS), query: '', searching: false, cursor: 0, chosen: new Set(), includeStatus, preview: true}; +} + +export type CopyPickerAction = {kind: 'close'} | {kind: 'copy'; records: CompletedCommand[]; includeStatus: boolean} + /** r: the same selection as a report, through its review. */ + | {kind: 'report'; records: CompletedCommand[]}; + +/** The rows the search leaves, newest first, each with its /copy number. */ +export function copyPickerRows(state: CopyPickerState): Array<{record: CompletedCommand; number: number}> { + const query = state.query.trim().toLowerCase(); + return state.records.map((record, index) => ({record, number: index + 1})) + .filter(({record}) => !query || record.command.toLowerCase().includes(query) || record.output.toLowerCase().includes(query)); +} + +/** The records Enter would copy, oldest first (the order they ran). */ +export function copyPickerSelection(state: CopyPickerState): CompletedCommand[] { + const picked = state.chosen.size ? state.records.filter(record => state.chosen.has(record.startId)) + : copyPickerRows(state).slice(state.cursor, state.cursor + 1).map(row => row.record); + return [...picked].reverse(); +} + +export function copyPickerKey(state: CopyPickerState, key: Key): CopyPickerAction | undefined { + if (state.searching) { + if (key.kind === 'escape' || key.kind === 'enter') { state.searching = false; return undefined; } + if (key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'backspace') { state.query = [...state.query].slice(0, -1).join(''); state.cursor = 0; return undefined; } + if (key.kind === 'text' || key.kind === 'paste') { + // One line of plain text: control characters never enter the query. + state.query = (state.query + key.value).replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 120); + state.cursor = 0; + return undefined; + } + if (key.kind !== 'up' && key.kind !== 'down') return undefined; + } + const rows = copyPickerRows(state); + const count = rows.length; + state.cursor = Math.max(0, Math.min(state.cursor, count - 1)); + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) { + // Esc clears a search first, then closes. + if (key.kind === 'escape' && state.query) { state.query = ''; state.cursor = 0; return undefined; } + return {kind: 'close'}; + } + if (key.kind === 'text' && key.value === '/') { state.searching = true; return undefined; } + if (key.kind === 'text' && key.value === 's') { state.includeStatus = !state.includeStatus; return undefined; } + if (key.kind === 'text' && key.value === 'p') { state.preview = !state.preview; return undefined; } + if (!count) return undefined; + if (key.kind === 'up' || key.kind === 'down') { state.cursor = (state.cursor + (key.kind === 'up' ? -1 : 1) + count) % count; return undefined; } + if (key.kind === 'text' && key.value === ' ') { + const id = rows[state.cursor]!.record.startId; + if (state.chosen.has(id)) state.chosen.delete(id); else state.chosen.add(id); + // Moving on after a choice makes selecting a run of outputs one key per row. + if (state.cursor < count - 1) state.cursor += 1; + return undefined; + } + if (key.kind === 'text' && key.value === 'a') { + // All the visible rows, or none of them when they are all chosen already. + const ids = rows.map(row => row.record.startId); + if (ids.every(id => state.chosen.has(id))) for (const id of ids) state.chosen.delete(id); + else for (const id of ids) state.chosen.add(id); + return undefined; + } + if (key.kind === 'enter') return {kind: 'copy', records: copyPickerSelection(state), includeStatus: state.includeStatus}; + if (key.kind === 'text' && key.value === 'r') return {kind: 'report', records: copyPickerSelection(state)}; + return undefined; +} + +function lineCount(output: string): string { + const {lines} = copyStats(output); + return lines === 0 ? 'no output' : `${lines.toLocaleString()} line${lines === 1 ? '' : 's'}`; +} + +/** The picker's rows (unframed). The status line says, before Enter, whether completion statuses will be copied. */ +export function renderCopyPicker(state: CopyPickerState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const width = Math.max(10, columns - 4); + const rows = copyPickerRows(state); + const count = rows.length; + state.cursor = Math.max(0, Math.min(state.cursor, count - 1)); + const target = state.chosen.size ? `${state.chosen.size} selected` : count ? 'the highlighted output' : 'nothing'; + const out = [` ${primary}Copy output${reset} ${subtle}${truncateText(`Enter copies ${target}, oldest first`, Math.max(4, width - 13))}${reset}`]; + out.push(` ${state.includeStatus ? accent : subtle}${truncateText(`Completion status: ${state.includeStatus ? 'included after each output' : 'not included'} (s)`, width)}${reset}`); + if (state.searching || state.query) out.push(` ${state.searching ? accent : subtle}${truncateText(`Search: ${state.query}${state.searching ? '▏' : ''} ${count} of ${state.records.length}`, width)}${reset}`); + out.push(''); + if (!state.records.length) out.push(` ${secondary}No completed command output yet.${reset}`); + else if (!count) out.push(` ${secondary}No output matches "${truncateText(state.query, Math.max(4, width - 24))}".${reset}`); + const controls = renderControlRows(state.searching + ? [['type', 'search'], ['↑↓', 'move'], ['Enter', 'done'], ['Esc', 'done']] + : [['↑↓', 'move'], ['Space', 'select'], ['a', 'all'], ['/', 'search'], ['s', 'status'], ['p', 'preview'], ['Enter', 'copy'], ['r', 'report'], ['Esc', 'cancel']], width); + const focused = rows[state.cursor]?.record; + const previewLines = state.preview && focused ? focused.output.split('\n').slice(0, 4) : []; + const previewRoom = previewLines.length && height - out.length - controls.length >= 12 ? previewLines.length + 2 : 0; + const listRoom = Math.max(1, height - out.length - controls.length - 2 - previewRoom); + const first = Math.max(0, Math.min(state.cursor - Math.floor(listRoom / 2), count - listRoom)); + rows.slice(first, first + listRoom).forEach(({record, number}, offset) => { + const isCursor = first + offset === state.cursor; + const box = state.chosen.has(record.startId) ? '[x]' : '[ ]'; + const outcome = record.exitCode === 0 ? '' : ` · exit ${record.exitCode}`; + const detail = `${lineCount(record.output)}${outcome}`; + const label = String(number).padStart(3); + const commandRoom = Math.max(4, width - displayWidth(label) - displayWidth(detail) - 9); + const command = truncateText(displaySafe(record.command.split('\n')[0]!) + (record.command.includes('\n') ? ' …' : ''), commandRoom); + out.push(`${isCursor ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${isCursor ? accent : subtle}${box}${reset} ${subtle}${label}${reset} ${focusForeground(isCursor)}${command}${reset} ${subtle}${detail}${reset}`); + }); + if (previewRoom) { + out.push('', ...previewLines.map(line => ` ${subtle}│${reset} ${secondary}${truncateText(displaySafe(line), Math.max(4, width - 2))}${reset}`)); + if (focused!.output.split('\n').length > previewLines.length) out.push(` ${subtle}│ …${reset}`); + } + out.push('', ...controls.map(row => ` ${row}`)); + return out.map(line => truncateAnsi(line, columns)); +} diff --git a/src/ui/ReferenceMenu.ts b/src/ui/ReferenceMenu.ts new file mode 100644 index 00000000..13af19b6 --- /dev/null +++ b/src/ui/ReferenceMenu.ts @@ -0,0 +1,28 @@ +import type {PaletteItem} from './CommandPalette.js'; +import {referenceFollowUp, REFERENCE_KIND_LABELS, type OutputReference} from '../output/references.js'; + +/** + * The menu for one block's references: for each, the action that fits it and Copy. Every action resolves the + * reference again from the block when it runs, so a menu never acts on stale coordinates or text. + */ +export function referencePaletteItems(startId: number, references: readonly OutputReference[]): PaletteItem[] { + const items: PaletteItem[] = []; + for (const reference of references) { + const kind = REFERENCE_KIND_LABELS[reference.kind]; + const id = `reference:${startId}:${reference.id}`; + const primary = reference.kind === 'file' ? {verb: 'open' as const, label: `Open ${reference.label}`, detail: `${kind} · in your editor${reference.line ? ` at line ${reference.line}` : ''}`} + : reference.kind === 'url' ? {verb: 'open' as const, label: `Open ${reference.label}`, detail: `${kind} · in your browser`} + : referenceFollowUp(reference) ? {verb: 'stage' as const, label: reference.kind === 'commit' ? `Inspect commit ${reference.label}` : `Open a shell on ${reference.label}`, + detail: `${kind} · puts \`${referenceFollowUp(reference)!.join(' ')}\` in the composer to review; nothing runs`} + : undefined; + if (primary) items.push({id: `${id}:${primary.verb}`, label: primary.label, detail: primary.detail, category: 'Transcript', action: {kind: 'reference', startId, refId: reference.id, verb: primary.verb}}); + items.push({id: `${id}:copy`, label: `Copy ${reference.label}`, detail: `${kind} · ${reference.kind === 'file' ? 'path and line' : 'as printed'}`, category: 'Transcript', + action: {kind: 'reference', startId, refId: reference.id, verb: 'copy'}}); + } + return items; +} + +/** What Copy puts on the clipboard for a reference. */ +export function referenceCopyText(reference: OutputReference): string { + return reference.kind === 'file' ? reference.label : reference.value; +} diff --git a/src/ui/ReportReview.ts b/src/ui/ReportReview.ts new file mode 100644 index 00000000..d812ec60 --- /dev/null +++ b/src/ui/ReportReview.ts @@ -0,0 +1,159 @@ +import type {Key} from '../terminal/keys.js'; +import type {CompletedCommand} from '../output/OutputBuffer.js'; +import {renderControlRows} from './controls.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {displaySafe} from '../input/PasteReview.js'; +import {buildReport, describeFindings, redactedLines, reportText, scanSensitive, type ReportFinding, type ReportOptions, type ReportResult} from '../clipboard/report.js'; + +/** + * The review before a report is copied: the exact text that will go to the clipboard, what looks sensitive in the + * unredacted report, and the switches that change it. Nothing is copied until Enter; Esc copies nothing. + */ +export interface ReportReviewState { + /** Oldest first. */ + records: CompletedCommand[]; + options: ReportOptions; + /** What the unredacted report contains that looks sensitive (decides whether review is needed at all). */ + sensitive: ReportFinding[]; + /** The report as it will be copied. */ + text: string; + /** What redaction changed in `text`. */ + redacted: ReportFinding[]; + /** The person edited the text in their editor: switches no longer rebuild it until they reset. */ + edited: boolean; + scroll: number; + /** An editor can be offered (VISUAL/EDITOR is set). */ + canEdit: boolean; + /** A document builder over the chosen data: redaction precedes formatting on every rebuild. */ + source?: {title: string; build: (options: ReportOptions) => ReportResult}; +} + +export function createReportReview(records: readonly CompletedCommand[], options: ReportOptions, canEdit: boolean): ReportReviewState { + const state: ReportReviewState = {records: [...records], options: {...options}, sensitive: [], text: '', redacted: [], edited: false, scroll: 0, canEdit}; + rebuild(state); + return state; +} + +/** Review a document built from stable source data: redaction, the exact text, the same keys. */ +export function createTextReview(title: string, build: (options: ReportOptions) => ReportResult, records: readonly CompletedCommand[], options: ReportOptions, canEdit: boolean): ReportReviewState { + const state: ReportReviewState = {records: [...records], options: {...options}, sensitive: [], text: '', redacted: [], edited: false, scroll: 0, canEdit, source: {title, build}}; + rebuild(state); + return state; +} + +function rebuild(state: ReportReviewState): void { + if (state.source) { + const redacted = state.source.build({...state.options, redact: true}); + state.sensitive = redacted.findings; + const built = state.options.redact ? redacted : state.source.build(state.options); + state.text = built.text; + state.redacted = built.findings; + state.edited = false; + return; + } + const redacted = buildReport(state.records, {...state.options, redact: true}); + state.sensitive = redacted.findings; + const built = state.options.redact ? redacted : buildReport(state.records, state.options); + state.text = built.text; + state.redacted = built.findings; + state.edited = false; +} + + +export type ReportReviewAction = {kind: 'close'} | {kind: 'copy'; text: string} | {kind: 'edit'}; + +export function reportReviewKey(state: ReportReviewState, key: Key, height: number, columns = 80): ReportReviewAction | undefined { + const page = Math.max(1, height - 8); + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'}; + if (key.kind === 'enter') return height >= 4 && columns >= 4 ? {kind: 'copy', text: state.text} : undefined; + if (key.kind === 'up' || key.kind === 'down') { state.scroll += key.kind === 'up' ? -1 : 1; } + else if (key.kind === 'pageUp' || key.kind === 'pageDown') { state.scroll += key.kind === 'pageUp' ? -page : page; } + else if (key.kind === 'text') { + const toggles: Record void> = { + r: () => { state.options.redact = !state.options.redact; }, + ...(state.source ? {} : { + f: () => { state.options.format = state.options.format === 'markdown' ? 'plain' : 'markdown'; }, + d: () => { state.options.directory = !state.options.directory; }, + t: () => { state.options.timestamps = !state.options.timestamps; }, + }), + }; + if (key.value === 'e' && state.canEdit) return {kind: 'edit'}; + const toggle = toggles[key.value]; + if (toggle) { toggle(); rebuild(state); } + } + // The render clamps to the wrapped rows it shows; here only the lower bound is known. + state.scroll = Math.max(0, state.scroll); + return undefined; +} + +/** A report line as display rows of at most `width` cells: wrapped, never cut, so every copied character is shown. */ +function wrapRows(line: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + let used = 0; + for (const char of displaySafe(line)) { + // Conservative cells: a character whose width a terminal may render wider (ambiguous, combining, emoji) counts + // as two, so no row can overflow the panel and be cut. + const cells = Math.max(displayWidth(char), (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1); + if (used + cells > width && row) { rows.push(row); row = ''; used = 0; } + row += char; + used += cells; + } + rows.push(row); + return rows; +} + +/** The person's edited text replaces the report as it will be copied (switches rebuild from the records again). */ +export function applyReportEdit(state: ReportReviewState, text: string): void { + // The same characters as a generated report: nothing invisible survives into what is checked and copied. + state.text = reportText(text); + state.edited = true; + state.scroll = 0; + // An edit is checked again: what it contains is what will be copied. + state.sensitive = scanSensitive(state.text, state.options.home); + state.redacted = []; +} + +export function renderReportReview(state: ReportReviewState, columns: number, height: number): string[] { + if (height < 4 || columns < 4) return height > 0 ? [truncateText(' Resize to review · Esc cancels', columns)] : []; + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const warning = foreground(UI_COLORS.failure); + const reset = '\u001b[0m'; + const width = Math.max(1, columns - 4); + const count = state.records.length; + const format = state.options.format === 'markdown' ? 'Markdown' : 'plain text'; + const title = state.source?.title ?? 'Copy as report'; + const detail = state.source ? `Markdown${state.edited ? ' · edited' : ''}` : `${count} command${count === 1 ? '' : 's'} · ${format}${state.edited ? ' · edited' : ''}`; + const out = [` ${primary}${title}${reset} ${subtle}${truncateText(detail, Math.max(4, width - displayWidth(title) - 4))}${reset}`]; + if (state.sensitive.length) { + const what = describeFindings(state.sensitive); + const how = state.edited ? 'in your edit, not redacted' : state.options.redact ? `redacted ${describeFindings(state.redacted) || 'nothing'}` : 'NOT redacted'; + out.push(` ${warning}${truncateText(`Looks sensitive: ${what} · ${how}`, width)}${reset}`); + } else out.push(` ${subtle}${truncateText('No common secret shapes found. Redaction is a pattern match, not a guarantee: read before sharing.', width)}${reset}`); + const marked = redactedLines(state.text); + if (marked.length) out.push(` ${subtle}${truncateText(`Changed lines (▸): ${marked.slice(0, 12).join(', ')}${marked.length > 12 ? ` and ${marked.length - 12} more` : ''}`, width)}${reset}`); + out.push(` ${subtle}${truncateText(state.source ? `Redact ${state.options.redact ? 'on' : 'off'} (r)` : `Redact ${state.options.redact ? 'on' : 'off'} (r) · Directory ${state.options.directory ? 'on' : 'off'} (d) · Times ${state.options.timestamps ? 'on' : 'off'} (t) · Format (f)`, width)}${reset}`, ''); + const controls = height < 10 + ? [truncateText(' Esc cancel · Enter copy · ↑↓ scroll', width + 2)] + : renderControlRows([['↑↓', 'scroll'], ['Enter', 'copy'], ...(state.canEdit ? [['e', 'edit'] as const] : []), ['Esc', 'cancel']], width); + // Keep a document row even when optional headings do not fit. + out.splice(Math.max(1, height - controls.length - 2)); + const room = Math.max(1, height - out.length - controls.length - 1); + const markedSet = new Set(marked); + // Every line wrapped to the panel: a long line is shown in full, never truncated out of sight. + const rows = state.text.split('\n').flatMap((line, index) => wrapRows(line, Math.max(2, width - 1)).map((text, part) => ({text, marked: part === 0 && markedSet.has(index + 1)}))); + const more = rows.length > room; + const visible = more && room > 1 ? room - 1 : room; + state.scroll = Math.max(0, Math.min(state.scroll, Math.max(0, rows.length - visible))); + for (const row of rows.slice(state.scroll, state.scroll + visible)) out.push(` ${row.marked ? `${warning}▸${reset}` : ' '}${secondary}${row.text}${reset}`); + if (more && room > 1) { + const below = rows.length - state.scroll - visible; + out.push(` ${subtle}${below > 0 ? `… ${below} more row${below === 1 ? '' : 's'} below (↓)` : 'end of report (↑ to scroll back)'}${reset}`); + } + out.push('', ...controls.map(row => ` ${row}`)); + return out.map(line => truncateAnsi(line, columns)).slice(0, Math.max(1, height)); +} diff --git a/src/ui/RowPanel.ts b/src/ui/RowPanel.ts index dcbb2811..5dd775a6 100644 --- a/src/ui/RowPanel.ts +++ b/src/ui/RowPanel.ts @@ -5,7 +5,7 @@ import {framePanel} from './PanelShell.js'; import {renderControls} from './controls.js'; import {focusForeground, foreground, UI_COLORS} from './palette.js'; import {GLYPHS} from './glyphs.js'; -import {padCells, truncateAnsi} from '../util/text.js'; +import {displayWidth, padCells, truncateAnsi} from '../util/text.js'; /** * A focused panel over a fixed set of the canonical Settings rows (for @@ -50,11 +50,14 @@ export function renderRowPanel(state: RowPanelState, config: PromptConfiguration const reset = '\u001B[0m'; const visible = rows(state, config); const out = [` ${primary}${state.title}${reset} ${subtle}${state.subtitle}${reset}`, '']; + // Labels get the room the longest one needs (at most half the panel), so none is cut where there is space. + const labelWidth = Math.max(22, Math.min(Math.floor(columns / 2), Math.max(0, ...visible.map(row => displayWidth(row.label.trim()) + (row.parent ? 2 : 0) + 2)))); visible.forEach((row, index) => { const selected = index === state.selected; - const value = settingsRowValue(row, config) ?? ''; + const raw = settingsRowValue(row, config) ?? ''; + const value = row.control === 'boolean' ? (raw === 'true' ? 'On' : 'Off') : raw; const indent = row.parent ? ' ' : ''; - out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${indent}${focusForeground(selected)}${padCells(row.label.trim(), 22 - indent.length)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : value}`); + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${indent}${focusForeground(selected)}${padCells(row.label.trim(), labelWidth - indent.length)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : value}`); }); const description = visible[state.selected]?.description; if (description && height >= out.length + preview.length + 6) out.push('', ` ${subtle}${description}${reset}`); diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index a655ae03..b9569f46 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -4,7 +4,7 @@ import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; import {historicalPromptLevel} from '../output/TranscriptPanel.js'; -import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; +import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; @@ -13,6 +13,7 @@ import {CATPPUCCIN_ACCENTS, CATPPUCCIN_ACCENT_LABELS, normalizeCatppuccinAccent} import {availabilityOf, availableValues, currentCursorHost, unavailableReason, type CursorFeature} from '../cursor/backends.js'; import {describeCursorColor, contextFor} from '../cursor/colors.js'; import {CURSOR_EFFECTS, CURSOR_IDLE_EFFECTS, CURSOR_LEVELS, CURSOR_MOTIONS, CURSOR_RENDERERS, type CursorSettings} from '../prompt/configuration.js'; +import {COPY_MODES, COPY_MODE_LABELS} from '../prompt/configuration.js'; import {CHROME_PRESET_LABELS, CHROME_PRESETS, CHROME_SOURCES, chromeColorsFrom, LAVENDER_TINT_LABELS, LAVENDER_TINTS, resolveChrome} from '../appearance/uiChrome.js'; import {currentSelectionFamily, defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectionFamilies, selectionFamilyLabel, selectionVariants, selectSelectionFamily, variantOptions} from '../appearance/themeSelection.js'; import {librarySummary} from '../appearance/themeLibrary.js'; @@ -376,6 +377,13 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ get: config => config.transcriptPresentation, set: (config, transcriptPresentation) => ({...config, transcriptPresentation})}), {id: 'composerDividers', label: 'Composer dividers', description: 'The horizontal lines around the composer; Off gives their rows back. Transcript dividers and prompt separators are separate', category: 'Layout', control: 'boolean', get: config => config.composerDividers, set: (config, composerDividers) => ({...config, composerDividers})}, + enumRow({id: 'copyMode', label: '/copy without a number', description: 'Quick Copy copies the latest command output at once; Interactive Picker lists recent outputs to choose one or several. /copy N, -N, A-B and 1,3,5 always copy directly', + category: 'Copy', values: COPY_MODES, labels: COPY_MODES.map(mode => COPY_MODE_LABELS[mode]), + get: config => config.copy.mode, set: (config, mode) => ({...config, copy: {...config.copy, mode}})}), + {id: 'copyIncludeStatus', label: 'Include completion status', description: 'After each copied output, add the status NMSh showed when it finished (Completed or failed, exit code, time, waits for input). Off copies only the output. --status or --no-status overrides it once', + category: 'Copy', control: 'boolean', get: config => config.copy.includeStatus, set: (config, includeStatus) => ({...config, copy: {...config.copy, includeStatus}})}, + {id: 'copyAutoExpand', label: 'Auto-expand copied output', description: 'After a successful copy, unfold the blocks whose output was copied. Off leaves folding as it was; what is copied is the same either way', + category: 'Copy', control: 'boolean', get: config => config.copy.autoExpand, set: (config, autoExpand) => ({...config, copy: {...config.copy, autoExpand}})}, enumRow({id: 'outputFolding', level: 'advanced', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'], get: config => config.outputFolding, set: (config, outputFolding) => ({...config, outputFolding})}), @@ -396,6 +404,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'terminalTitle', label: 'Terminal title', description: 'Set the window/tab title while NMSh owns the screen; programs set their own while they run and NMSh restores its title after them. Off leaves the title to the terminal', category: 'Sessions', values: ['off', 'project', 'session'] as const, labels: ['Off', 'Project', 'Project and session'], get: config => config.terminalTitle, set: (config, terminalTitle) => ({...config, terminalTitle})}), + {id: 'commandQueue', label: 'Queue commands', description: 'While a command runs, Enter queues the next one to run after it (Ctrl+Q always queues, Ctrl+S sends to the program); a program waiting for input still gets your answer. Off: Enter sends to the program', category: 'Sessions', + control: 'boolean', get: config => config.commandQueue, set: (config, commandQueue) => ({...config, commandQueue})}, {id: 'agentActivity', label: 'Agent activity', description: 'Local-only durations and counts for Claude Code and Codex CLI runs (/agents); never prompts or output', category: 'Sessions', control: 'boolean', get: config => config.agentActivity, set: (config, agentActivity) => ({...config, agentActivity})}, enumRow({id: 'shellBackend', label: 'Default shell', description: 'Shell for new sessions; /shell switches the current session and lists what is installed', category: 'Sessions', @@ -497,6 +507,9 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ get: c => c.idleVisuals.colorSource, set: (c, colorSource) => ({...c, idleVisuals: withIdleColorSource(c, colorSource)})}), {id: 'idleCustomColors', parent: 'idleColor', when: c => c.idleVisuals.colorSource === 'custom', label: 'Edit colors', description: 'The idle visuals\' own gradient stops, with a live preview', category: 'Idle visuals', control: 'action', actionLabel: 'Edit ›', destination: 'idleColors'}, + enumRow({id: 'activityStyle', label: 'Activity wording', description: 'Expressive gives each command a playful working phrase and a matching finished one (Cooking… → Cooked for 18s). Classic says Running and Completed. Exit codes, failures, interrupts and waiting for input read the same in both', category: 'Live activity', + values: ACTIVITY_STYLES, labels: ACTIVITY_STYLES.map(style => ACTIVITY_STYLE_LABELS[style]), + get: c => c.liveActivity.style, set: (c, style) => ({...c, liveActivity: {...c.liveActivity, style}})}), enumRow({id: 'activityColors', label: 'Live activity colors', description: 'The running-command line. Follow appearance uses Chroma when it is on, otherwise the theme. Only live work moves; finished commands show their plain result', category: 'Live activity', values: LIVE_ACTIVITY_COLORS, labels: LIVE_ACTIVITY_COLORS.map(colors => LIVE_ACTIVITY_COLOR_LABELS[colors]), get: c => c.liveActivity.colors, set: (c, colors) => ({...c, liveActivity: {...c.liveActivity, colors, @@ -602,7 +615,7 @@ export const CONFIG_GROUP_BY_CATEGORY: Readonly> = { General: 'General', Updates: 'General', Appearance: 'Appearance', Presentation: 'Appearance', 'Idle visuals': 'Appearance', 'Live activity': 'Appearance', Prompt: 'Prompt & Composer', Layout: 'Prompt & Composer', 'Status strip': 'Prompt & Composer', - Editor: 'Editor', Syntax: 'Editor', Suggestions: 'Editor', + Editor: 'Editor', Syntax: 'Editor', Suggestions: 'Editor', Copy: 'Editor', Cursor: 'Cursor & Motion', Motion: 'Cursor & Motion', Sessions: 'Sessions & Alerts', 'Command notifications': 'Sessions & Alerts', Tools: 'Shell & Providers', Welcome: 'Shell & Providers', History: 'Shell & Providers', @@ -624,7 +637,7 @@ export function configGroup(row: SettingsRow): ConfigGroup { /** The order of root rows inside their groups (children follow their root); roots not listed keep their place after these. */ const CONFIG_ORDER: readonly string[] = [ 'glyphStyle', 'panelPosition', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', - 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityColors', + 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityStyle', 'activityColors', 'provider', 'promptStyle', 'promptSymbol', 'composerPosition', 'composerDividers', 'divider', 'historicalPrompt', 'showShell', 'statusStrip', 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', 'cursorShape', 'cursorRenderer', 'cursorMotion', 'cursorEffect', 'cursorIdle', 'cursorColor', 'cursorSpeed', 'cursorIntensity', 'cursorTrail', 'cursorParticles', 'cursorAdvanced', diff --git a/tests/activityStyle.test.ts b/tests/activityStyle.test.ts new file mode 100644 index 00000000..1d1366f3 --- /dev/null +++ b/tests/activityStyle.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {completedActivity, liveActivityParts} from '../src/status/activity.js'; +import {DEFAULT_LIVE_ACTIVITY, DEFAULT_PROMPT_CONFIGURATION, loadPromptConfiguration, savePromptConfiguration, normalizeLiveActivity, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {ACTIVITY_VERBS} from '../src/status/activityVerbs.js'; +import {stripAnsi} from '../src/util/text.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {selectActivityVerb} from '../src/status/activityVerbs.js'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {join} from 'node:path'; +import {exportSettings, parsePortableDocument, planImport} from '../src/configuration/portability.js'; + +const cooking = {active: 'Cooking', complete: 'Cooked'}; +const at = new Date(2026, 9, 10, 14, 46, 0); + +test('a pair is chosen before rendering and its collision-avoiding choice survives the live journal', () => { + const {restore} = isolateConfig(); + const first = new TerminalApp(); + const second = new TerminalApp(); + first['render'] = second['render'] = (() => {}) as never; + try { + const started = 1_700_000_000_000; + // Force the case where the previous phrase changes this command's deterministic base choice. + first['lastActivityPair'] = selectActivityVerb('echo stable', started); + first['onShellExec']('echo stable', started, 1); + const selected = first['running']?.activity; + assert.ok(selected, 'short commands must select a pair even if never drawn'); + assert.notEqual(selected, selectActivityVerb('echo stable', started)); + first['sessionId'] = 'stable-session'; + const live = first['liveLink']()!; + assert.equal(live.running?.activityIndex, ACTIVITY_VERBS.indexOf(selected)); + second['beginReattach']({cwd: '/tmp', ackedSeq: 0} as never, {startCwd: '/tmp', transcript: first['output'].transcript(), live} as never); + assert.deepEqual(second['activityPair'](), selected); + assert.deepEqual(second['activityPairFor'](second['running']!), selected); + assert.deepEqual(second['lastActivityPair'], selected, 'the next command also avoids the resumed phrase'); + } finally { + for (const app of [first, second]) { app['stop'](0); app['session'].kill(); } + restore(); + } +}); + +test('Expressive is the default and old settings without a style migrate to it', () => { + assert.equal(DEFAULT_LIVE_ACTIVITY.style, 'expressive'); + assert.equal(normalizeLiveActivity(undefined).style, 'expressive'); + assert.deepEqual(normalizeLiveActivity({colors: 'lavender'}), {colors: 'lavender', customStops: [], style: 'expressive'}); + assert.equal(normalizeLiveActivity({style: 'classic'}).style, 'classic'); + assert.equal(normalizeLiveActivity({style: 'chatty'}).style, 'expressive'); + assert.equal(normalizeLiveActivity({style: 7}).style, 'expressive'); +}); + +test('Classic and Expressive wording persist and survive settings transfer', () => { + const {directory, restore} = isolateConfig(); + try { + for (const style of ['classic', 'expressive'] as const) { + const config = normalizePromptConfiguration({liveActivity: {style}}); + const file = join(directory, 'wording.json'); + savePromptConfiguration(config, file); + assert.equal(loadPromptConfiguration(file).liveActivity.style, style); + const transferred = parsePortableDocument(JSON.stringify(exportSettings(config, ['idle']))); + assert.equal(planImport(structuredClone(DEFAULT_PROMPT_CONFIGURATION), transferred, ['idle']).next.liveActivity.style, style); + } + } finally { restore(); } +}); + +test('the working line: the phrase and the real elapsed time; Classic still names the command', () => { + const parts = liveActivityParts('npm test', 18_000, 18_000, cooking); + assert.match(stripAnsi(parts.phrase), /^\S Cooking…$/u); + assert.equal(parts.duration, ' · 18.0s'); + const classic = liveActivityParts('npm test', 18_000, 18_000); + assert.match(classic.phrase, /Running npm test$/u); +}); + +test('a clean exit reads "Cooked for 18.0s · done 14:46"', () => { + const parts = completedActivity('npm test', 18_000, at, 0, false, undefined, cooking); + assert.equal(`${parts.main}${parts.detail}`, '✔ Cooked for 18.0s · done 14:46'); + const withFacts = completedActivity('adb devices', 1400, at, 0, false, ['2 devices'], cooking); + assert.equal(`${withFacts.main}${withFacts.detail}`, '✔ Cooked · 2 devices · 1.4s · done 14:46'); +}); + +test('failure and interruption are never decorated: exit code and wording stay factual', () => { + const failed = completedActivity('npm test', 18_000, at, 3, false, undefined, cooking); + assert.equal(`${failed.main}${failed.detail}`, '✘ Command failed · exit 3 · 18.0s · 14:46'); + const stopped = completedActivity('sleep 99', 4000, at, 0, true, undefined, cooking); + assert.match(stopped.main, /Interrupted/u); + assert.doesNotMatch(`${failed.main}${stopped.main}`, /Cooked|Cooking/u); + // The failure line is the same with or without a pair. + assert.deepEqual(failed, completedActivity('npm test', 18_000, at, 3, false, undefined)); +}); + +test('Classic output is exactly what it was before', () => { + const parts = completedActivity('ls', 1400, at, 0, false); + assert.equal(`${parts.main}${parts.detail}`, '✔ Completed · 1.4s · 14:46'); +}); + +test('every phrase fits a narrow line with its duration', () => { + for (const pair of ACTIVITY_VERBS) { + const done = completedActivity('x', 18_000, at, 0, false, undefined, pair); + assert.ok(`${done.main}${done.detail}`.length <= 58, `${done.main}${done.detail}`); + assert.ok(`${liveActivityParts('x', 125_000, 0, pair).phrase} · 2m 5s`.length <= 50, pair.active); + } +}); diff --git a/tests/activityStyleLive.test.ts b/tests/activityStyleLive.test.ts new file mode 100644 index 00000000..606ed513 --- /dev/null +++ b/tests/activityStyleLive.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; +import {ACTIVITY_VERBS} from '../src/status/activityVerbs.js'; + +/** Expressive activity wording through the real composer and PTY: decorated success, factual failure, factual copies. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: Expressive keeps its active/completed pair across a detached running command', {skip: !supported, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', liveActivity: {style: 'expressive'}}); + const release = join(sandbox.home, 'release'); + const command = `echo HOLD-STYLE; while ! test -f '${release}'; do sleep 0.1; done; echo STYLE-DONE`; + const first = sandbox.launch(); + try { + await first.waitFor(/Vespyr|notMyShell|zsh/u); + await first.run('echo READY', /READY/u); + await first.run(command, /HOLD-STYLE/u, {completion: false}); + await until(async () => (await sandbox.transcripts().list()).some(session => session.live?.running?.activityIndex !== undefined), 15_000, 'saved activity choice'); + const journal = (await sandbox.transcripts().list()).find(session => session.live?.running?.command === command)!; + const pair = ACTIVITY_VERBS[journal.live!.running!.activityIndex!]!; + assert.ok(pair); + const sessionId = journal.live!.sessionId; + first.pty.kill('SIGKILL'); + await first.waitExit(); + await until(async () => (await sandbox.sessions()).some(session => session.id === sessionId && !session.attached), 15_000, 'detached session'); + const resumed = sandbox.launch(['--attach', sessionId]); + const escaped = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + await resumed.waitFor(new RegExp(`${escaped(pair.active)}…`, 'u')); + const mark = resumed.mark; + writeFileSync(release, 'go'); + await resumed.waitFor(new RegExp(`${escaped(pair.complete)}.*done`, 'u'), mark); + // The UI may draw completion before the asynchronous journal checkpoint finishes. + await until(async () => (await sandbox.transcripts().list()).some(session => session.transcript.records.some(record => record.command === command)), 15_000, 'factual completion journal'); + const saved = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).find(record => record.command === command)!; + assert.match(saved.lifecycleText, /Completed/u); + assert.doesNotMatch(saved.lifecycleText, new RegExp(escaped(pair.complete), 'u')); + } finally { await sandbox.dispose(); } +}); + +test('live: Expressive wording decorates success only; failures keep their exit code and copies stay factual', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', liveActivity: {style: 'expressive'}, copy: {mode: 'quick', includeStatus: true, autoExpand: false}}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.txt'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + await frontend.run('echo hello-style', /hello-style/u); + await frontend.waitFor(/✔ \p{Lu}[^\n·]* for \d[^\n]*· done \d\d:\d\d/u, mark); + mark = frontend.mark; + await frontend.run('sh -c "exit 3"', /./u); + await frontend.waitFor(/Command failed · exit 3/u, mark); + // The copy payload is the factual status, never the decorative phrase. + frontend.pty.write('/copy 2\r'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').length > 0, 15_000, 'the copy on the clipboard'); + const copied = readFileSync(clip, 'utf8'); + assert.match(copied, /hello-style\n✔ Completed · [\d.]+ ?m?s · \d\d:\d\d\n?$/u, copied); + assert.doesNotMatch(copied, / for \d.*done /u); + } finally { + await sandbox.dispose(); + } +}); diff --git a/tests/activityVerbs.test.ts b/tests/activityVerbs.test.ts new file mode 100644 index 00000000..234c6c70 --- /dev/null +++ b/tests/activityVerbs.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ACTIVITY_VERBS, selectActivityVerb} from '../src/status/activityVerbs.js'; + +/** Past tenses that are not "-ed": the only single-word pairs allowed to skip it. */ +const IRREGULAR = new Map([ + ['Weaving', 'Wove'], ['Drawing', 'Drew'], ['Binding', 'Bound'], ['Creeping', 'Crept'], ['Sliding', 'Slid'], ['Swimming', 'Swam'], + ['Diving', 'Dived'], ['Spitting', 'Spat'], ['Sleeping', 'Slept'], ['Digging', 'Dug'], ['Slinking', 'Slunk'], ['Singing', 'Sang'], + ['Growing', 'Grew'], ['Freewriting', 'Freewrote'], ['Winging it', 'Winged it'], ['Speedrunning', 'Speedran'], +]); + +test('the dictionary has at least 600 distinct pairs', () => { + assert.ok(ACTIVITY_VERBS.length >= 600, String(ACTIVITY_VERBS.length)); + assert.equal(new Set(ACTIVITY_VERBS.map(pair => pair.active.toLowerCase())).size, ACTIVITY_VERBS.length, 'active phrases are unique'); + assert.equal(new Set(ACTIVITY_VERBS.map(pair => pair.complete.toLowerCase())).size, ACTIVITY_VERBS.length, 'completed phrases are unique'); +}); + +test('every phrase is a short, clean, capitalised piece of text that "for 18s" can follow', () => { + for (const {active, complete} of ACTIVITY_VERBS) { + for (const phrase of [active, complete]) { + assert.match(phrase, /^\p{Lu}[\p{L}'’ -]*$/u, phrase); + assert.ok(phrase.length >= 3 && phrase.length <= 34, phrase); + assert.equal(phrase, phrase.trim()); + assert.doesNotMatch(phrase, /\s{2,}|--|-$/u, phrase); + } + assert.notEqual(active, complete); + } +}); + +test('single-word pairs conjugate correctly', () => { + for (const {active, complete} of ACTIVITY_VERBS) { + if (/[ -]/u.test(active) || /[ -]/u.test(complete) || active.includes("'")) continue; + const irregular = IRREGULAR.get(active); + if (irregular) { assert.equal(complete, irregular, active); continue; } + assert.match(complete, /ed$/u, `${active} → ${complete}`); + // The past tense is the same word, not a different one. + assert.equal(complete.slice(0, 2).toLowerCase(), active.slice(0, 2).toLowerCase(), `${active} → ${complete}`); + } +}); + +test('no phrase claims a technical outcome it cannot know', () => { + const claims = /\b(verif|install|deploy|fix|secur|encrypt|authenticat|backed|test(?:ed|ing)|pass(?:ed|ing)|succe|fail|finish|complet|compil|cleaned|cleaning|download|upload|sync|commit|push|merg|delet|remov|updat|upgrad|sort|fetch|cach|optimi[sz]|analy[sz]|calculat|evaluat|scann|render)/iu; + for (const {active, complete} of ACTIVITY_VERBS) { + assert.doesNotMatch(active, claims, active); + assert.doesNotMatch(complete, claims, complete); + } + // NMSh's own words must not be borrowed for decoration: folding, queue, copy, running, completed. + for (const {active, complete} of ACTIVITY_VERBS) assert.doesNotMatch(`${active} ${complete}`, /\b(?:fold|queue|copy|copied|running|ran\b|skipp|paus|stopp|interrupt)/iu, active); +}); + +test('the choice depends only on the command and its start, and is stable', () => { + const first = selectActivityVerb('make test', 1_700_000_000_000); + for (let index = 0; index < 5; index += 1) assert.equal(selectActivityVerb('make test', 1_700_000_000_000), first); + assert.ok(ACTIVITY_VERBS.includes(first)); +}); + +test('two commands in a row never share a phrase, and the whole dictionary gets used', () => { + let previous = selectActivityVerb('a', 0); + const seen = new Set([previous.active]); + for (let index = 1; index < 20_000; index += 1) { + const next = selectActivityVerb(`command ${index}`, 1_700_000_000_000 + index * 997, previous); + assert.notEqual(next.active, previous.active); + seen.add(next.active); + previous = next; + } + assert.ok(seen.size > ACTIVITY_VERBS.length * 0.95, `${seen.size} of ${ACTIVITY_VERBS.length}`); +}); diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index 863336c3..9cc073eb 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -4,7 +4,7 @@ import {parseSlashCommand, slashSuggestions, suggestionWindow} from '../src/comm import {calculateScreenLayout} from '../src/app/layout.js'; test('slash autocomplete exposes copy variants and help', () => { - assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config', '/composer', '/configure', '/codex']); + assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/copy settings', '/compare', '/config', '/composer', '/configure', '/codex']); assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history', '/history-provider']); assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}, '/history stays history search'); assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zoomies', '/zsh']); diff --git a/tests/blockActions.test.ts b/tests/blockActions.test.ts index 7af0e134..022961b3 100644 --- a/tests/blockActions.test.ts +++ b/tests/blockActions.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {TerminalApp} from '../src/app/TerminalApp.js'; -import {blockCopyPayload, blockPaletteItems, blockAffordance} from '../src/ui/BlockActions.js'; +import {BLOCK_ACTIONS, blockCopyPayload, blockPaletteItems, blockAffordance} from '../src/ui/BlockActions.js'; import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {decodeKeys} from '../src/terminal/keys.js'; import {regionOf} from '../src/app/screenPlan.js'; @@ -23,7 +23,10 @@ function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } test('one block registry supplies keyboard palette actions and copies authoritative full plain data', () => { const output = new OutputBuffer(); const record = fixture(output); - assert.equal(blockPaletteItems(record).length, 7); + const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references'); + assert.deepEqual(blockPaletteItems(record).map(item => item.action.kind === 'block' && item.action.id), passive.map(action => action.id)); + assert.ok(passive.some(action => action.id === 'clipCommand')); + assert.ok(passive.some(action => action.id === 'clipOutput')); assert.equal(blockCopyPayload(record, 'copyCommand'), 'echo hello'); assert.equal(blockCopyPayload(record, 'copyOutput'), record.output); assert.equal(blockCopyPayload(record, 'copyBoth'), record.command + '\n' + record.output); @@ -53,7 +56,7 @@ test('keyboard focus opens actions; edit waits for Enter, rerun uses visible nor instance['handleKey']({kind: 'focusPrevious'}); assert.equal(instance['focusedCommandIndex'], 0); instance['handleKey']({kind: 'enter'}); - assert.equal(instance['paletteState']?.items.length, 7); + assert.deepEqual(instance['paletteState']?.items, blockPaletteItems(record)); instance['paletteState'] = undefined; await instance['runBlockAction'](record.startId, 'edit'); assert.equal(instance['editor'].text, record.command); diff --git a/tests/clipboardChanges.test.ts b/tests/clipboardChanges.test.ts new file mode 100644 index 00000000..9d7ad0ea --- /dev/null +++ b/tests/clipboardChanges.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ClipboardChangeWatch, parseClipboardCounter} from '../src/clipboard/ClipboardChanges.js'; + +test('counter parsing accepts bounded nonnegative metadata only', () => { + assert.equal(parseClipboardCounter('42\n'), 42); + for (const raw of ['-1', 'secret contents', '12\n34', '9'.repeat(200), '9007199254740992']) + assert.throws(() => parseClipboardCounter(raw)); +}); + +test('suggestions are off by default, sample only when idle, and never request clipboard text', async () => { + let count = 3, reads = 0, changes = 0, idle = true; + let next: (() => void) | undefined; + const watch = new ClipboardChangeWatch({counter: async () => { reads++; return count; }, idle: () => idle, + changed: () => { changes++; }, failed: () => assert.fail('unexpected failure'), + schedule: callback => { next = callback; return () => { next = undefined; }; }}); + const settle = async () => { await new Promise(done => setImmediate(done)); }; + assert.equal(watch.enabled, false); + assert.equal(reads, 0); + watch.start(); await settle(); + assert.equal(reads, 1); + assert.equal(changes, 0, 'initial metadata is a baseline'); + idle = false; count++; + next!(); await settle(); + assert.equal(reads, 1, 'foreground input ownership prevents even metadata probing'); + idle = true; + next!(); await settle(); + assert.equal(changes, 1); + watch.stop(); + assert.equal(watch.enabled, false); + assert.equal(next, undefined); +}); + +test('failures disable suggestions; a response after stop cannot raise a suggestion', async () => { + let failed = 0, changes = 0; + let resolve!: (count: number) => void; + const watch = new ClipboardChangeWatch({counter: () => new Promise(done => { resolve = done; }), idle: () => true, + changed: () => { changes++; }, failed: () => { failed++; }}); + watch.start(); watch.stop(); resolve(1); + await new Promise(done => setImmediate(done)); + assert.equal(changes, 0); + assert.equal(watch.enabled, false); + const broken = new ClipboardChangeWatch({counter: async () => { throw new Error('unavailable'); }, idle: () => true, + changed: () => { changes++; }, failed: () => { failed++; }}); + broken.start(); + await new Promise(done => setImmediate(done)); + assert.equal(broken.enabled, false); + assert.equal(failed, 1); +}); diff --git a/tests/clipboardLifecycle.test.ts b/tests/clipboardLifecycle.test.ts new file mode 100644 index 00000000..5642c993 --- /dev/null +++ b/tests/clipboardLifecycle.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import {syncBuiltinESMExports} from 'node:module'; +import {chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import type {Socket} from 'node:net'; +import {writeClipboard, CLIPBOARD_MAX_BYTES} from '../src/clipboard/clipboard.js'; +import {until} from './helpers/liveFrontend.js'; + +type FinalCallback = (error?: Error | null) => void; +type FinalSocket = Socket & {_final: (callback: FinalCallback) => void}; + +test('clipboard succeeds after payload delivery and backend exit even if exit suppresses finish', async t => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-clip-final-')); + const sink = join(root, 'copied'); + const backend = join(root, 'xclip'); + writeFileSync(backend, `#!/bin/sh\ncat > '${sink}'\n`); + chmodSync(backend, 0o700); + const spawn = childProcess.spawn; + let nativeFinalCompleted = false; + let finished = false; + let exited: number | null | undefined; + let closed = false; + const hook = t.mock.method(childProcess, 'spawn', (...args: Parameters) => { + const child = spawn(...args); + if (args[0] === backend) { + const stream = child.stdin! as FinalSocket; + const final = stream._final; + stream._final = function(callback) { + final.call(this, error => { + if (error) { callback(error); return; } + nativeFinalCompleted = true; + // Actual shutdown/EOF is complete; hold only its JS callback until + // Node destroys stdin during backend exit. No input or exit is faked. + child.once('exit', () => callback()); + }); + }; + stream.once('finish', () => { finished = true; }); + child.once('exit', code => { exited = code; }); + child.once('close', () => { closed = true; }); + } + return child; + }); + syncBuiltinESMExports(); + try { + const payload = 'héllo; $(touch pwned)\n🐈'; + await writeClipboard(payload, {platform: 'linux', env: {DISPLAY: ':0'}, resolve: () => backend}); + assert.equal(readFileSync(sink, 'utf8'), payload); + assert.equal(nativeFinalCompleted, true, 'backend received real EOF'); + assert.equal(exited, 0, 'the real backend succeeded'); + assert.equal(finished, false, 'success cannot depend on suppressed finish'); + } finally { + hook.mock.restore(); + syncBuiltinESMExports(); + try { await until(() => closed, 3000, 'owned clipboard child closed'); } + finally { rmSync(root, {recursive: true, force: true}); } + } +}); + +test('clipboard rejects a partial stdin read even when the backend exits zero', async () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-clip-partial-')); + const sink = join(root, 'prefix'); + const backend = join(root, 'xclip'); + writeFileSync(backend, `#!/bin/sh\ndd bs=1 count=128 > '${sink}' 2>/dev/null\nexec 0<&-\nsleep 0.1\nexit 0\n`); + chmodSync(backend, 0o700); + try { + await assert.rejects(writeClipboard('x'.repeat(CLIPBOARD_MAX_BYTES), { + platform: 'linux', env: {DISPLAY: ':0'}, resolve: () => backend, + }), /EPIPE|pipe|stdin/iu); + assert.equal(readFileSync(sink).length, 128, 'backend consumed only a strict prefix'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + + +test('clipboard rejects the write callback error even after backend exit zero', async t => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-clip-callback-')); + const backend = join(root, 'xclip'); + writeFileSync(backend, '#!/bin/sh\ncat >/dev/null\n'); + chmodSync(backend, 0o700); + const spawn = childProcess.spawn; + let exited: number | null | undefined; + let closed = false; + const hook = t.mock.method(childProcess, 'spawn', (...args: Parameters) => { + const child = spawn(...args); + if (args[0] === backend) { + const stream = child.stdin!; + const write = stream.write; + t.mock.method(stream, 'write', (chunk: string, callback: (error?: Error | null) => void) => + write.call(stream, chunk, 'utf8', error => { + if (error) { callback(error); return; } + // Exercise callback(error) without an accompanying error event. The + // real child still receives EOF and exits zero before this callback. + child.once('exit', () => callback(new Error('controlled write callback error'))); + })); + child.once('exit', code => { exited = code; }); + child.once('close', () => { closed = true; }); + } + return child; + }); + syncBuiltinESMExports(); + try { + await assert.rejects(writeClipboard('payload', { + platform: 'linux', env: {DISPLAY: ':0'}, resolve: () => backend, + }), /controlled write callback error/u); + assert.equal(exited, 0, 'backend success cannot override a write error'); + } finally { + hook.mock.restore(); + syncBuiltinESMExports(); + try { await until(() => closed, 3000, 'owned clipboard child closed'); } + finally { rmSync(root, {recursive: true, force: true}); } + } +}); diff --git a/tests/clipboardRead.test.ts b/tests/clipboardRead.test.ts new file mode 100644 index 00000000..3d5a5b7a --- /dev/null +++ b/tests/clipboardRead.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ClipboardUnavailableError, readClipboard, selectClipboardReadBackend} from '../src/clipboard/clipboard.js'; + +const installed = (...names: string[]) => (name: string) => names.includes(name) ? `/usr/bin/${name}` : undefined; + +test('the read tool follows platform and session, like the write tool', () => { + assert.deepEqual(selectClipboardReadBackend({platform: 'darwin', env: {}}), {command: 'pbpaste', args: []}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {WAYLAND_DISPLAY: 'w0', DISPLAY: ':0'}, resolve: installed('wl-paste', 'xclip')}), {command: '/usr/bin/wl-paste', args: ['--no-newline']}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {DISPLAY: ':0'}, resolve: installed('xclip')}), {command: '/usr/bin/xclip', args: ['-selection', 'clipboard', '-o']}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {DISPLAY: ':0'}, resolve: installed('xsel')}), {command: '/usr/bin/xsel', args: ['--clipboard', '--output']}); + assert.equal(selectClipboardReadBackend({platform: 'linux', env: {}, resolve: installed('xclip')}), undefined); + assert.equal(selectClipboardReadBackend({platform: 'win32', env: {}}), undefined); +}); + +function fake(body: string): {options: Parameters[0]; cleanup: () => void} { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-paste-')); + const script = join(directory, 'xclip'); + writeFileSync(script, `#!/bin/sh\n${body}\n`); + chmodSync(script, 0o755); + return {options: {platform: 'linux', env: {DISPLAY: ':0'}, resolve: name => name === 'xclip' ? script : undefined, timeoutMs: 1500}, cleanup: () => rmSync(directory, {recursive: true, force: true})}; +} + +test('reads exactly what the tool prints: newlines, quotes and non-ASCII intact', async () => { + const {options, cleanup} = fake('printf \'%s\' "line one\n rm -rf \\"x\\"\nüñí 🐈"'); + try { assert.equal(await readClipboard(options), 'line one\n rm -rf "x"\nüñí 🐈'); } finally { cleanup(); } +}); + +test('an empty clipboard reads as an empty string', async () => { + const {options, cleanup} = fake('exit 0'); + try { assert.equal(await readClipboard(options), ''); } finally { cleanup(); } +}); + +test('a tool that fails, hangs or prints too much is an error, never partial text', async () => { + const failing = fake('echo secret; exit 1'); + try { await assert.rejects(readClipboard(failing.options), /exited with code 1/u); } finally { failing.cleanup(); } + const hanging = fake('sleep 30'); + try { await assert.rejects(readClipboard({...hanging.options, timeoutMs: 200}), /timed out/u); } finally { hanging.cleanup(); } + const huge = fake('head -c 3000000 /dev/zero | tr "\\0" "a"'); + try { await assert.rejects(readClipboard(huge.options), (error: unknown) => error instanceof ClipboardUnavailableError && /more than 1 MiB/u.test(error.message)); } finally { huge.cleanup(); } +}); + +test('no clipboard tool is a clear, non-fatal error', async () => { + await assert.rejects(readClipboard({platform: 'linux', env: {}, resolve: () => undefined}), /install wl-paste/u); + await assert.rejects(readClipboard({platform: 'win32', env: {}}), /unsupported platform/u); +}); diff --git a/tests/clips.test.ts b/tests/clips.test.ts new file mode 100644 index 00000000..402e72b1 --- /dev/null +++ b/tests/clips.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ClipStore, CLIP_MAX_BYTES, CLIP_LIMIT} from '../src/clipboard/ClipStore.js'; + +test('clips retain exact text and provenance in memory; remove and clear never write anything', () => { + const store = new ClipStore(); + const added = store.add('echo "$(not executed)"\n', {source: 'history', cwd: '/old', session: 'source-session'}); + assert.ok(added.ok); + if (!added.ok) return; + assert.deepEqual(store.get(added.clip.id), added.clip); + assert.equal(added.clip.text, 'echo "$(not executed)"\n'); + assert.equal(added.clip.cwd, '/old'); + assert.equal(added.clip.session, 'source-session'); + assert.equal(new ClipStore().list().length, 0, 'no persistence or global state'); + assert.equal(store.remove(added.clip.id), true); + assert.equal(store.get(added.clip.id), undefined); + store.add('echo next', {source: 'composer'}); + store.clear(); + assert.deepEqual(store.list(), []); +}); + +test('clips bound both bytes and count, reject empty/oversized text, and evict oldest first', () => { + const store = new ClipStore(); + assert.equal(store.add(' \n', {source: 'paste'}).ok, false); + assert.equal(store.add('é'.repeat(CLIP_MAX_BYTES), {source: 'paste'}).ok, false); + for (let index = 0; index <= CLIP_LIMIT; index++) store.add(`echo ${index}`, {source: 'composer'}); + assert.equal(store.list().length, CLIP_LIMIT); + assert.equal(store.list().at(-1)?.text, 'echo 1'); + const large = store.add('a'.repeat(CLIP_MAX_BYTES), {source: 'output'}); + assert.ok(large.ok); + assert.equal(store.list().length, 1, 'the total memory budget is also bounded'); +}); diff --git a/tests/clipsLive.test.ts b/tests/clipsLive.test.ts new file mode 100644 index 00000000..97136947 --- /dev/null +++ b/tests/clipsLive.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('live: an explicit clipboard read can be kept as a clip, reviewed again and queued only on approval', {skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const readMarker = join(sandbox.root, 'clipboard-read'); + const execution = join(sandbox.home, 'explicit-execution'); + const source = join(sandbox.root, 'clipboard-text'); + writeFileSync(source, `touch '${execution}'\necho CLIP-DONE`); + for (const name of ['pbpaste', 'wl-paste', 'xclip', 'xsel']) { + writeFileSync(join(bin, name), `#!/bin/sh\ntouch '${readMarker}'\ncat '${source}'\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 100, rows: 32}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + assert.equal(existsSync(readMarker), false, 'startup never reads clipboard text'); + let mark = frontend.mark; + frontend.pty.write('/ps\r'); + await frontend.waitFor(/Review paste/u, mark); + assert.equal(existsSync(readMarker), true); + assert.equal(existsSync(execution), false); + frontend.pty.write('c'); + // Complete Escape sequences avoid relying on repaint of an unchanged composer row + // or on a bare Escape's ambiguity timer before the next typed slash. + frontend.pty.write('\u001b[27u\u001b[27u'); + await frontend.run('/clips', /Review clip 1/u); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Review paste/u, mark); + assert.equal(existsSync(execution), false, 'selecting a clip only opens review'); + frontend.pty.write('b'); + await frontend.waitFor(/Review commands/u, mark); + assert.equal(existsSync(execution), false, 'batch review never dispatches'); + frontend.pty.write('q'); + await until(() => existsSync(execution), 15_000, 'explicitly approved queued command'); + await frontend.waitFor(/CLIP-DONE[\s\S]*from queue/u, mark); + } finally { await sandbox.dispose(); } +}); 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/commandQueue.test.ts b/tests/commandQueue.test.ts new file mode 100644 index 00000000..85cfe641 --- /dev/null +++ b/tests/commandQueue.test.ts @@ -0,0 +1,171 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandQueue, QUEUE_LIMIT} from '../src/session/CommandQueue.js'; +import {QueueDispatcher, type QueueEvent, type QueueShell} from '../src/session/QueueDispatcher.js'; +import type {QueueState} from '../src/session/CommandQueue.js'; + +function shell(): QueueShell & {submitted: string[]} { + return {submitted: [], isReady: true, adapter: {id: 'zsh'}, submit(command: string) { this.submitted.push(command); }}; +} + +function harness() { + const target = shell(); + const dispatcher = new QueueDispatcher(target); + const events: Array<[QueueState, QueueEvent]> = []; + dispatcher.on('state', (state, event) => events.push([state, event])); + /** The shell runs what was submitted and finishes with `exitCode`. */ + const finish = (exitCode = 0) => { dispatcher.onExec(target.submitted.at(-1) ?? 'manual'); dispatcher.onPrompt(exitCode); }; + return {target, dispatcher, events, finish}; +} + +test('entries run one at a time, in order, each only after the previous command reached its prompt', () => { + const {target, dispatcher, events, finish} = harness(); + dispatcher.onExec('shasum -a 256 big.iso'); + dispatcher.request({op: 'add', text: 'adb devices'}); + dispatcher.request({op: 'add', text: 'adb shell pm list packages'}); + assert.deepEqual(target.submitted, [], 'nothing runs while a command runs'); + dispatcher.onPrompt(0); + assert.deepEqual(target.submitted, ['adb devices']); + assert.equal(dispatcher.state.entries.length, 1); + assert.equal(events.filter(([, event]) => event.dispatched).length, 1, 'announced once'); + dispatcher.request({op: 'add', text: 'echo third'}); + assert.deepEqual(target.submitted, ['adb devices'], 'a new entry waits for the running entry'); + finish(); + finish(); + assert.deepEqual(target.submitted, ['adb devices', 'adb shell pm list packages', 'echo third']); + finish(); + assert.equal(dispatcher.state.entries.length, 0); +}); + +test('an idle shell runs a new entry at once; a prompt without a command (empty Enter) counts as idle, not as a finish', () => { + const {target, dispatcher} = harness(); + dispatcher.onPrompt(0); + dispatcher.request({op: 'add', text: 'ls'}); + assert.deepEqual(target.submitted, ['ls']); + dispatcher.onPrompt(1); + dispatcher.request({op: 'add', text: 'pwd'}); + assert.deepEqual(target.submitted, ['ls', 'pwd'], 'an empty Enter\'s status is not a failure'); +}); + +test('a failure or an interrupt pauses the rest with the reason; nothing follows blindly; resume continues', () => { + const {target, dispatcher, finish} = harness(); + dispatcher.onExec('make build'); + dispatcher.request({op: 'add', text: 'make deploy'}); + dispatcher.onPrompt(2); + assert.deepEqual(target.submitted, []); + assert.deepEqual(dispatcher.state.paused && {...dispatcher.state.paused, at: 0}, {reason: 'failed', at: 0, command: 'make build', exitCode: 2}); + dispatcher.request({op: 'resume'}); + assert.deepEqual(target.submitted, ['make deploy']); + + dispatcher.onExec('make deploy'); + dispatcher.request({op: 'add', text: 'notify done'}); + dispatcher.onInterrupt(); + dispatcher.onPrompt(0); + assert.equal(dispatcher.state.paused?.reason, 'interrupted', 'Ctrl+C pauses even when the program exits 0'); + dispatcher.onExec('sleep 9'); + dispatcher.onPrompt(130); + assert.deepEqual(target.submitted, ['make deploy'], 'still paused'); + void finish; +}); + +test('an entry is taken exactly once: repeated prompts, edits and removals never run it twice or out of order', () => { + const {target, dispatcher} = harness(); + dispatcher.onExec('long'); + dispatcher.request({op: 'add', text: 'first'}); + dispatcher.request({op: 'add', text: 'second'}); + const [first, second] = dispatcher.state.entries; + dispatcher.request({op: 'edit-begin', id: first!.id}); + dispatcher.onPrompt(0); + assert.deepEqual(target.submitted, [], 'an entry being edited holds the queue at that point'); + dispatcher.request({op: 'edit', id: first!.id, text: 'first --edited'}); + assert.deepEqual(target.submitted, ['first --edited']); + dispatcher.onPrompt(0); + dispatcher.onPrompt(0); + assert.deepEqual(target.submitted, ['first --edited', 'second'], 'a prompt without a command does not dispatch twice'); + dispatcher.request({op: 'remove', id: second!.id}); + assert.match(String(dispatcher.queue.apply({op: 'remove', id: second!.id})), /already ran or was removed/u); +}); + +test('reorder, clear, pause and limits', () => { + const queue = new CommandQueue(() => 5); + for (const text of ['a', 'b', 'c']) queue.apply({op: 'add', text}); + const [a, , c] = queue.state.entries; + queue.apply({op: 'move', id: c!.id, to: 0}); + assert.deepEqual(queue.state.entries.map(entry => entry.text), ['c', 'a', 'b']); + queue.apply({op: 'move', id: a!.id, to: 99}); + assert.deepEqual(queue.state.entries.map(entry => entry.text), ['c', 'b', 'a']); + queue.apply({op: 'pause'}); + assert.equal(queue.take(), undefined); + queue.apply({op: 'clear'}); + assert.deepEqual(queue.state, {entries: []}); + assert.match(String(queue.apply({op: 'add', text: ' '})), /Nothing to queue/u); + for (let index = 0; index < QUEUE_LIMIT; index += 1) queue.apply({op: 'add', text: `cmd ${index}`}); + assert.match(String(queue.apply({op: 'add', text: 'one more'})), /at most/u); +}); + +test('multi-line entries are submitted whole, exactly as prepared', () => { + const {target, dispatcher} = harness(); + dispatcher.onPrompt(0); + const script = "for f in a b; do\n echo \"$f\"\ndone\ncat <<'EOF'\nliteral $HOME\nEOF"; + dispatcher.request({op: 'add', text: script}); + assert.deepEqual(target.submitted, [`{ ${script}\n}`], 'one block, exactly as the composer submits it'); +}); + +test('Fish queue entries use a native begin/end block, including after a shell switch', () => { + const {dispatcher} = harness(); + const fish = {...shell(), adapter: {id: 'fish' as const}}; + dispatcher.onShellSwitched(fish); + dispatcher.onPrompt(0); + dispatcher.request({op: 'add', text: 'set -gx QUEUED kept\necho $QUEUED'}); + assert.deepEqual(fish.submitted, ['begin\nset -gx QUEUED kept\necho $QUEUED\nend']); +}); + +test('a shell switch pauses the queue for the new shell; a shell exit drops and reports what is left', () => { + const {target, dispatcher, events} = harness(); + dispatcher.onExec('long'); + dispatcher.request({op: 'add', text: 'echo for zsh'}); + const next = shell(); + dispatcher.onShellSwitched(next); + dispatcher.onPrompt(0); + assert.deepEqual([...target.submitted, ...next.submitted], []); + assert.equal(dispatcher.state.paused?.reason, 'shell-switched'); + dispatcher.onShellExit(); + assert.deepEqual(events.at(-1)![1].dropped?.map(entry => entry.text), ['echo for zsh']); + assert.deepEqual(dispatcher.state, {entries: []}); +}); + +test('nothing is dispatched before the shell is ready', () => { + const target = {...shell(), isReady: false}; + const dispatcher = new QueueDispatcher(target); + dispatcher.request({op: 'add', text: 'echo early'}); + dispatcher.onPrompt(0); + assert.deepEqual(target.submitted, []); +}); + +test('the exec after a dispatch is that entry, by its own text; nothing else is ever attributed to the queue', () => { + const {target, dispatcher} = harness(); + assert.equal(dispatcher.onExec('sleep 3'), undefined, 'a typed command is not from the queue'); + dispatcher.request({op: 'add', text: 'for i in 1 2; do\n echo "$i"\ndone'}); + dispatcher.onPrompt(0); + assert.equal(target.submitted.length, 1); + assert.deepEqual(dispatcher.onExec(target.submitted[0]!)?.text, 'for i in 1 2; do\n echo "$i"\ndone', 'the entry, not the wrapped block'); + dispatcher.onPrompt(0); + assert.equal(dispatcher.onExec('ls'), undefined, 'claimed once'); + // A submission that started no command (a comment) is not owed to whatever runs next. + dispatcher.request({op: 'add', text: '# just a note'}); + dispatcher.onPrompt(0); + dispatcher.onPrompt(0); + assert.equal(dispatcher.onExec('ls'), undefined); +}); + +test('queue text is drawn, never interpreted: control sequences in entries and pause reasons are neutralized', async () => { + const {queuePreview, pauseReason, queueRow} = await import('../src/status/queueStatus.js'); + const {renderQueuePanel} = await import('../src/queue/QueuePanel.js'); + const hostile = 'echo hi\u001b]52;c;cm0gLXJmIH4=\u0007\u001b[2J\u009b31m'; + for (const text of [queuePreview(hostile), pauseReason({reason: 'failed', at: 1, command: hostile, exitCode: 2}), + queueRow({entries: [{id: 1, text: hostile, addedAt: 1}]}, 120) ?? '', + renderQueuePanel({selected: 0}, {entries: [{id: 1, text: `${hostile}\nsecond ${hostile}`, addedAt: 1}]}, hostile, 120, 30).join('\n')]) { + assert.doesNotMatch(text.replace(/\u001b\[[\d;]*m/gu, ''), /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/u, JSON.stringify(text)); + } + assert.match(queuePreview(hostile), /␛\]52/u, 'visible, not executed'); +}); diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts new file mode 100644 index 00000000..623354f5 --- /dev/null +++ b/tests/commandQueueLive.test.ts @@ -0,0 +1,313 @@ +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'; +import {inForeground, uniqueSleep} from './helpers/processState.js'; + +/** + * The command queue through the real composer, PTY and session service, with harmless commands only (sleep, echo, + * cd, touch inside the sandbox). Entries run in the session's own shell, in order, after the shell's own prompt. + */ +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'; +type Shell = 'zsh' | 'bash' | 'fish'; + +async function start(shell: Shell, options: {size?: {cols: number; rows: number}; env?: Record} = {}) { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], options.size ?? {cols: 100, rows: 30}, options.env ?? {}); + try { + await frontend.waitFor(/notMyShell|zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'the shell authenticated prompt'); + await frontend.run('echo READY', /READY/u); + } catch (error) { + // A start that never became ready is not inside the test's try/finally: end its processes here, or they keep + // the test file (and the suite) running. + try { await sandbox.dispose(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'startup and cleanup failed'); } + throw error; + } + return {sandbox, frontend}; +} + +const drawn = (frontend: Frontend, from: number) => strip(frontend.output.slice(from)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +const SET_STATE: Record = { + zsh: ['cd sub', 'export QUEUE_VAR=kept', 'qfn() { echo "fn-$1" }', 'setopt nullglob'], + bash: ['cd sub', 'export QUEUE_VAR=kept', 'qfn() { echo "fn-$1"; }', 'shopt -s nullglob'], + fish: ['cd sub', 'set -gx QUEUE_VAR kept', 'function qfn; echo "fn-$argv[1]"; end', 'set -g fish_queue_opt on'], +}; +const CHECK_STATE: Record = { + zsh: 'echo "STATE $(basename $PWD) $QUEUE_VAR $(qfn x) $([[ -o nullglob ]] && echo opt)"', + bash: 'echo "STATE $(basename $PWD) $QUEUE_VAR $(qfn x) $(shopt -q nullglob && echo opt)"', + fish: 'echo "STATE "(basename $PWD)" $QUEUE_VAR "(qfn x)" $fish_queue_opt"', +}; + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: Enter while a command runs queues; entries run in order in the same shell, keeping its state`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + await frontend.run('mkdir -p sub', /❯/u); + const mark = frontend.mark; + const release = join(sandbox.home, 'queue-state-release'); + // A file gate keeps command ownership stable until every queue assertion has run. + // Assemble the output marker at run time so the command echo cannot satisfy readiness. + const holding = `sh -c 'printf "STATE-%s\\n" HOLD; until test -f "$1"; do sleep 0.05; done' nmsh-fixture "${release}"`; + frontend.pty.write(`${holding}\r`); + await frontend.waitFor(/STATE-HOLD/u, mark); + for (const line of [...SET_STATE[shell], CHECK_STATE[shell]]) { + frontend.pty.write(`${line}\r`); + await pause(80); + } + await frontend.waitFor(/≡ Queued \(5\) · next: cd sub/u, mark); + assert.match(drawn(frontend, mark), /Enter queues the next command · Ctrl\+S sends to sh/u, 'the empty composer says what Enter does'); + writeFileSync(release, ''); + await frontend.waitFor(/STATE sub kept fn-x (?:opt|on)/u, mark, 30_000); + const commands = [holding, ...SET_STATE[shell], CHECK_STATE[shell]]; + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => commands.includes(record.command)); + await until(async () => (await records()).length >= commands.length, 20_000, 'every command recorded'); + const recorded = (await records()).reverse(); + assert.deepEqual(recorded.map(record => record.command), commands, 'in order, each once'); + assert.deepEqual(recorded.map(record => /from queue/u.test(record.lifecycleText)), [false, true, true, true, true, true], + `queued commands say so: ${recorded.map(record => record.lifecycleText).join(' | ')}`); + assert.doesNotMatch(drawn(frontend, mark), /command not found: (?:cd|export|qfn)/u, 'nothing reached sleep as input or ran early'); + } finally { await sandbox.dispose(); } + }); +} + +test('live: a failed command pauses the queue with its reason; nothing after it runs until resumed or cleared', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const marker = join(sandbox.home, 'deployed'); + const mark = frontend.mark; + frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); + await frontend.waitFor(/Running sh -c/u, mark); + frontend.pty.write(`touch ${marker}\r`); + await frontend.waitFor(/Queue paused: sh -c 'sleep 1; exit 3' failed \(exit 3\)\. 1 waiting · \/queue to resume or clear/u, mark, 20_000); + await frontend.waitFor(/⏸ Queue paused · sh -c 'sleep 1; exit 3' failed \(exit 3\) · 1 waiting/u, mark); + await pause(1500); + assert.equal(existsSync(marker), false, 'the follow-up did not run blindly'); + // A command typed at the prompt still runs at once; the paused queue keeps waiting. + await frontend.run('echo DIRECT', /DIRECT/u); + assert.equal(existsSync(marker), false); + const resume = frontend.mark; + frontend.pty.write('/queue resume\r'); + await frontend.waitFor(/Queue resumed; 1 will run in order/u, resume); + await until(() => existsSync(marker), 15_000, 'the resumed entry runs'); + await frontend.waitFor(/from queue/u, resume); + } finally { await sandbox.dispose(); } +}); + +test('live: a program waiting for a line gets Enter; Ctrl+Q queues; Ctrl+S sends to a program NMSh cannot see waiting', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + let mark = frontend.mark; + frontend.pty.write(`read 'x?Name: '; echo got=$x\r`); + await frontend.waitFor(/[◆◇] (?:Probably w|W)aiting for input[\s\S]*Name:/u, mark); + await frontend.waitFor(/Enter answers zsh · Ctrl\+Q queues a command/u, mark); + frontend.pty.write('echo QUEUED-AFTER-READ\u0011'); + await frontend.waitFor(/≡ 1 queued|≡ Queued \(1\)/u, mark); + frontend.pty.write('alice\r'); + await frontend.waitFor(/got=alice[\s\S]*QUEUED-AFTER-READ/u, mark, 20_000); + assert.doesNotMatch(drawn(frontend, mark), /got=echo/u, 'the queued command never reached read'); + + // cat reads stdin with no prompt: NMSh cannot tell it waits, so Enter queues; Ctrl+S sends the line to cat. + mark = frontend.mark; + frontend.pty.write('cat\r'); + await frontend.waitFor(/Running cat/u, mark); + frontend.pty.write('to-cat'); + await frontend.waitFor(/❯ to-cat/u, mark); + // After Ctrl+S: the terminal's echo of the line, then cat's own copy of it (composer redraws may sit between). + const sent = frontend.mark; + frontend.pty.write('\u0013'); + await frontend.waitFor(/to-cat[\s\S]*to-cat/u, sent); + frontend.pty.write('\u0004'); + await frontend.waitFor(/Completed/u, mark); + } finally { await sandbox.dispose(); } +}); + +test('live: a reply typed at a fresh prompt goes to the program before any probe looks; it is never queued or run', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + // The prompt is assembled at run time, so only the program's own question matches (not the echoed command). + frontend.pty.write(`p=Pass; sleep 0.3; read -s "x?\${p}word: "; echo; echo "len=\${#x}"\r`); + await frontend.waitFor(/Password: /u, mark); + // At once: no input probe has run yet, only the question on the program's line is evidence. + frontend.pty.write('hunter2\r'); + await frontend.waitFor(/len=7[\s\S]*Completed/u, mark, 20_000); + await pause(800); + const painted = drawn(frontend, mark); + assert.doesNotMatch(painted, /≡ (?:\d+ q|Queued)/u, 'the reply was never queued'); + assert.doesNotMatch(painted, /hunter2/u, 'the hidden reply was never shown or run'); + const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + assert.equal(records.some(record => record.command.includes('hunter2')), false, 'never recorded as a command'); + } finally { await sandbox.dispose(); } +}); + +test('live: during single-key input, Ctrl+Q composes a queue entry the program never sees; Esc returns keys to it', {skip: supported && python ? false : 'python3 with termios not available', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + frontend.pty.write(`python3 ${ASKYN} 'Fix? '\r`); + await frontend.waitFor(/◆ Waiting for input[\s\S]*Ctrl\+Q queues a command/u, mark); + frontend.pty.write('\u0011'); + await frontend.waitFor(/Queue a command · Enter adds it · Esc returns to the program/u, mark); + frontend.pty.write('echo AFTER-ASKYN\r'); + await frontend.waitFor(/≡ 1 queued/u, mark); + frontend.pty.write('y'); + await frontend.waitFor(/ANSWERS=yes[\s\S]*AFTER-ASKYN/u, mark, 20_000); + assert.doesNotMatch(drawn(frontend, mark), /ANSWERS=[^\n]*,/u, 'exactly one key reached the program'); + } finally { await sandbox.dispose(); } +}); + +test('live: Ctrl+C pauses what was queued behind the interrupted command', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const marker = join(sandbox.home, 'after-interrupt'); + const mark = frontend.mark; + frontend.pty.write('sleep 30\r'); + await frontend.waitFor(/Running sleep 30/u, mark); + frontend.pty.write(`touch ${marker}\r`); + await frontend.waitFor(/≡ Queued \(1\)/u, mark); + frontend.pty.write('\u0003'); + await frontend.waitFor(/Queue paused: sleep 30 was interrupted/u, mark, 20_000); + await pause(1000); + assert.equal(existsSync(marker), false); + const clear = frontend.mark; + frontend.pty.write('/queue clear\r'); + await frontend.waitFor(/Cleared 1 queued command; none of them ran\./u, clear); + } finally { await sandbox.dispose(); } +}); + +test('live: after Ctrl+Z or Ctrl+C, what you type next is for the shell prompt, not a queue entry', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + const suspended = uniqueSleep(71); + const interrupted = uniqueSleep(72); + frontend.pty.write(`sleep ${suspended}\r`); + await until(() => inForeground(suspended), 20_000, 'sleep owns the foreground before Ctrl+Z'); + // Typed at once, before NMSh sees the prompt the stop brings back (as the GNU screen test does). + frontend.pty.write('\u001a'); + frontend.pty.write('jobs\r'); + await frontend.waitFor(/suspended\s+sleep 30[\s\S]*suspended\s+sleep 30/u, mark, 20_000); + await frontend.run('kill %1; echo JOB-GONE', /JOB-GONE/u); + frontend.pty.write(`sleep ${interrupted}\r`); + await until(() => inForeground(interrupted), 20_000, 'sleep owns the foreground before Ctrl+C'); + frontend.pty.write('\u0003'); + frontend.pty.write('echo AFTER-STOP\r'); + await frontend.waitFor(/AFTER-STOP[\s\S]*Completed/u, mark, 20_000); + assert.doesNotMatch(drawn(frontend, mark), /Queue paused/u, 'nothing typed after a stop was held as a queue entry'); + } finally { await sandbox.dispose(); } +}); + +test('live: a program that survives Ctrl+C gets ordinary routing again: later typing queues, it is never sent into the program', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + // Synchronize with the installed trap, not NMSh's submission echo. /bin/sh on Ubuntu is dash, + // whose read has no -t option. Keep the fixture alive until the assertion releases it rather + // than letting a fixed sleep expire under load. Unexpected input is actually read and reported. + const release = join(sandbox.home, 'release-survivor'); + frontend.pty.write(`"${bash}" -c 'trap "" INT; printf "INT-%s\\n" READY; until test -f "$1"; do if read -t 0.1 line; then echo "GOT=$line"; fi; done; echo SURVIVED' nmsh-fixture "${release}"\r`); + await frontend.waitFor(/INT-READY/u, mark); + frontend.pty.write('\u0003'); + await pause(4200); + frontend.pty.write('echo LATER\r'); + await frontend.waitFor(/≡ Queued \(1\) · next: echo LATER/u, mark); + writeFileSync(release, ''); + await frontend.waitFor(/SURVIVED[\s\S]*LATER/u, mark, 20_000); + assert.doesNotMatch(drawn(frontend, mark), /GOT=echo LATER/u, 'nothing typed after the window reached the program'); + } finally { await sandbox.dispose(); } +}); + +test('live: a multi-line paste queues as one entry and runs once, as one block', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + frontend.pty.write('sleep 5\r'); + await frontend.waitFor(/Running sleep 5/u, mark); + frontend.pty.write('\u001b[200~for i in 1 2; do\n echo "LINE-$i"\ndone\u001b[201~'); + // NMSh's paste review comes first; Enter inserts the paste into the composer, and only the next Enter queues it. + await frontend.waitFor(/Enter insert · R review(?: · B queue as commands)? · Esc cancel/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/❯ for i in 1 2; do/u, mark); + await pause(300); + assert.doesNotMatch(drawn(frontend, mark), /≡ Queued/u, 'a paste never queues by itself'); + frontend.pty.write('\r'); + await frontend.waitFor(/≡ Queued \(1\) · next: for i in 1 2; do \(\+2 lines\)/u, mark); + await frontend.waitFor(/LINE-1[\s\S]*LINE-2[\s\S]*from queue/u, mark, 20_000); + assert.equal(drawn(frontend, mark).match(/LINE-1/gu)?.length, 1, 'it ran exactly once'); + } finally { await sandbox.dispose(); } +}); + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + test(`live ${shell}: a multiline queue entry uses native grouping and preserves shell variables`, {skip: supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)) ? false : `${shell} not installed`, timeout: 90_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + const release = join(sandbox.home, 'release-multiline'); + const mark = frontend.mark; + frontend.pty.write(`sh -c 'printf "MULTI-%s\\n" READY; until test -f "$1"; do sleep 0.1; done' nmsh-fixture "${release}"\r`); + await frontend.waitFor(/MULTI-READY/u, mark); + const command = `${shell === 'fish' ? 'set -g MULTI_VALUE kept' : 'MULTI_VALUE=kept'}\nprintf 'BLOCK-%s\\n' FIRST\nprintf 'BLOCK-%s-%s\\n' LAST "$MULTI_VALUE"`; + frontend.pty.write(`\u001b[200~${command}\u001b[201~`); + await frontend.waitFor(/Enter insert · R review(?: · B queue as commands)? · Esc cancel/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/❯ (?:set -g MULTI_VALUE|MULTI_VALUE=kept)/u, mark); + frontend.pty.write('\u0011'); + await frontend.waitFor(/≡ Queued \(1\)/u, mark); + assert.doesNotMatch(drawn(frontend, mark), /BLOCK-FIRST/u, 'review and insertion never execute'); + writeFileSync(release, ''); + await frontend.waitFor(/BLOCK-FIRST[\s\S]*BLOCK-LAST-kept[\s\S]*from queue/u, mark, 20_000); + await frontend.run("printf 'AFTER-%s\\n' \"$MULTI_VALUE\"", /AFTER-kept/u); + await until(async () => (await sandbox.transcripts().list()).some(item => item.transcript.records.some(record => record.command === command)), 15_000, 'one multiline command recorded'); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records).filter(record => record.command === command); + assert.equal(records.length, 1); + } finally { await sandbox.dispose(); } + }); +} + +test('live: the queue belongs to the session: it survives detach and reattach and runs in its own shell', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const {sandbox, frontend: first} = await start('zsh'); + try { + const marker = join(sandbox.home, 'ran-detached'); + const mark = first.mark; + first.pty.write('sleep 4\r'); + await first.waitFor(/Running sleep 4/u, mark); + first.pty.write(`touch ${marker}\r`); + first.pty.write('sleep 6\r'); + await first.waitFor(/≡ Queued \(2\)/u, mark); + const [session] = await sandbox.sessions(); + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session!.id)?.state === 'detached', 15_000, 'detached'); + // The queue keeps running while no window is attached. + await until(() => existsSync(marker), 20_000, 'the first entry ran while detached'); + const back = sandbox.launch(['--attach', session!.id]); + await back.waitFor(/Running sleep 6|Completed[\s\S]*from queue/u, 0, 30_000); + await back.waitFor(/sleep 6[\s\S]*from queue/u, 0, 30_000); + } finally { await sandbox.dispose(); } +}); + +test('live: narrow, NO_COLOR and Safe glyphs keep the queue legible', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh', {size: {cols: 40, rows: 18}, env: {NO_COLOR: '1', NMSH_ICONS: 'safe'}}); + try { + const mark = frontend.mark; + frontend.pty.write('sleep 3\r'); + await frontend.waitFor(/Running/u, mark); + frontend.pty.write('echo NARROW-QUEUED\r'); + // 40 columns: the entry's text is cut to fit, never wrapped into the composer. + await frontend.waitFor(/= Queued \(1\) · next: echo NARR/u, mark); + assert.doesNotMatch(drawn(frontend, mark), /≡|⏸/u, 'Safe glyphs only'); + await frontend.waitFor(/NARROW-QUEUED[\s\S]*from queue/u, mark, 20_000); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/compareOutput.test.ts b/tests/compareOutput.test.ts new file mode 100644 index 00000000..c286532d --- /dev/null +++ b/tests/compareOutput.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer, type CompletedCommand} from '../src/output/OutputBuffer.js'; +import {compareRecords, comparisonNotes, comparisonReport, buildComparisonReport, diffLines, previousRun, summary, unifiedDiff, MAX_EDIT_DISTANCE} from '../src/output/compare.js'; + +let clock = Date.UTC(2026, 9, 10, 8, 0, 0); +function record(command: string, output: string, exitCode = 0): CompletedCommand { + const buffer = new OutputBuffer(); + buffer.beginCommand(command, [`❯ ${command}`]); + buffer.write(output); + const done = buffer.complete(exitCode)!; + clock += 60_000; + return Object.assign(done, {startedAt: clock}); +} + +const lines = (count: number, make: (index: number) => string) => Array.from({length: count}, (_, index) => `${make(index)}\n`).join(''); + +test('the line diff reconstructs both sides exactly, for random edits', () => { + let seed = 7; + const random = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; + for (let round = 0; round < 200; round += 1) { + const a = Array.from({length: Math.floor(random() * 30)}, () => String(Math.floor(random() * 6))); + const b = a.flatMap(line => random() < 0.2 ? [] : random() < 0.2 ? [line, String(Math.floor(random() * 6))] : [line]); + const script = diffLines(a, b)!; + assert.deepEqual(script.filter(line => line.kind !== 'add').map(line => line.text), a); + assert.deepEqual(script.filter(line => line.kind !== 'remove').map(line => line.text), b); + } +}); + +test('changes, additions and removals are counted; unchanged regions collapse into hunks with context', () => { + const before = record('adb shell getprop', lines(100, index => `[prop.${index}]: [v1]`)); + const after = record('adb shell getprop', lines(100, index => index === 10 ? '[prop.10]: [v2]' : index === 60 ? '' : `[prop.${index}]: [v1]`).replace('\n\n', '\n') + '[prop.new]: [x]\n'); + const result = compareRecords(after, before); + assert.equal(result.a, before, 'A is the older run whatever order they were picked in'); + assert.deepEqual(result.stats, {added: 1, removed: 1, changed: 1, unchanged: 98}); + assert.equal(result.hunks.length, 3); + assert.equal(summary(result), '1 changed · 1 added · 1 removed · 98 unchanged'); + const diff = unifiedDiff(result); + assert.match(diff, /^--- A: adb shell getprop · \d\d:\d\d:\d\d · exit 0\n\+\+\+ B: adb shell getprop · \d\d:\d\d:\d\d · exit 0\n@@ -8,7 \+8,7 @@\n \[prop\.7\]: \[v1\]\n \[prop\.8\]: \[v1\]\n \[prop\.9\]: \[v1\]\n-\[prop\.10\]: \[v1\]\n\+\[prop\.10\]: \[v2\]\n/u); + assert.match(diff, /\n@@ -98,3 \+97,4 @@\n \[prop\.97\]: \[v1\]\n \[prop\.98\]: \[v1\]\n \[prop\.99\]: \[v1\]\n\+\[prop\.new\]: \[x\]\n$/u); + assert.ok(!diff.includes('[prop.30]'), 'unchanged regions far from changes are left out'); +}); + +test('identical runs, whitespace normalization (explicit and labelled), empty and incomplete outputs are said plainly', () => { + const one = record('ls', 'a b\nc\n'); + assert.equal(compareRecords(one, record('ls', 'a b\nc\n')).identical, true); + assert.equal(summary(compareRecords(one, record('ls', 'a b\nc\n'))), 'Identical output'); + const spaced = record('ls', 'a b \nc\n'); + assert.equal(compareRecords(one, spaced).identical, false, 'faithful by default'); + const normalized = compareRecords(one, spaced, {ignoreWhitespace: true, context: 3}); + assert.equal(normalized.identical, true); + assert.match(comparisonNotes(normalized).join(), /Whitespace ignored/u); + const silent = record('ls', ''); + assert.match(comparisonNotes(compareRecords(one, silent)).join(' '), /B printed no output/u); + const partial = Object.assign(record('ls', 'a\n'), {outputIncomplete: true as const}); + assert.match(comparisonNotes(compareRecords(one, partial)).join(' '), /B's output is incomplete/u); +}); + +test('outputs too different to align are reported as such, not as a misleading diff; large similar outputs stay fast', () => { + const big = MAX_EDIT_DISTANCE + 200; + const a = record('gen', lines(big, index => `a${index}`)); + const b = record('gen', lines(big, index => `b${index}`)); + const result = compareRecords(a, b); + assert.equal(result.unaligned, true); + assert.match(comparisonNotes(result).join(), /Too different to align/u); + const started = Date.now(); + const similar = compareRecords(record('seq', lines(5000, String)), record('seq', lines(5000, index => index === 2500 ? 'x' : String(index)))); + assert.deepEqual(similar.stats.changed, 1); + assert.ok(Date.now() - started < 1000, 'a 5000-line comparison is quick'); +}); + +test('the comparison report names both sides and fences the diff safely', () => { + const result = compareRecords(record('cat f', 'one\n```\n'), record('cat f', 'two\n```\n')); + const report = comparisonReport(result); + assert.match(report, /^# Output comparison\n\n- \*\*A \(older\):\*\* `cat f · [^`]+`\n- \*\*B \(newer\):\*\* `cat f · [^`]+`\n- \*\*Result:\*\* 1 changed · 1 unchanged\n\n````diff\n--- A:/u); + assert.match(comparisonReport(compareRecords(record('x', 'same\n'), record('x', 'same\n'))), /_No differences\._/u); +}); + +test('the suggested partner is the previous run of exactly the same command', () => { + // Created oldest first; /copy and the picker list them newest first. + const newestFirst = [record('make test', 'z'), record('make test', 'a'), record('ls', 'b'), record('make test', 'c')].reverse(); + assert.equal(previousRun(newestFirst[0]!, newestFirst)?.output, 'a'); + assert.equal(previousRun(newestFirst[1]!, newestFirst), undefined, 'ls has no earlier run'); +}); + +test('panel: the previous run is suggested; Enter compares; n/N move between changes; w is explicit and labelled; c and r copy', async () => { + const {createComparePanel, comparePanelKey, renderComparePanel} = await import('../src/ui/ComparePanel.js'); + const {stripAnsi, displayWidth} = await import('../src/util/text.js'); + const old = record('adb shell getprop', lines(60, index => index === 5 || index === 50 ? `[p${index}]: [old]` : `[p${index}]: [v]`)); + const other = record('ls', 'x\n'); + const latest = record('adb shell getprop', lines(60, index => index === 5 || index === 50 ? `[p${index}]: [new]` : `[p${index}]: [v]`)); + const recent = [latest, other, old]; + const panel = createComparePanel(latest, recent); + assert.equal(panel.candidates[panel.cursor]!.record, old, 'the previous run is preselected'); + assert.match(stripAnsi(renderComparePanel(panel, 100, 24).join('\n')), /previous run/u); + comparePanelKey(panel, {kind: 'enter'}, 100, 24); + assert.ok(panel.comparison); + const view = stripAnsi(renderComparePanel(panel, 100, 24).join('\n')); + assert.match(view, /A adb shell getprop · [\d:]+ · exit 0\n B adb shell getprop · [\d:]+ · exit 0\n 2 changed · 58 unchanged/u); + assert.match(view, /- \[p5\]: \[old\]\n \+ \[p5\]: \[new\]/u); + // Navigation over many changes in a short panel: n moves forward change by change, N back. + const many = (tag: string) => record('scan', lines(400, index => index % 80 === 5 ? `[p${index}]: [${tag}]` : `[p${index}]: [v]`)); + const nav = createComparePanel(many('new'), [], many('old')); + const stops: number[] = []; + for (let step = 0; step < 3; step += 1) { comparePanelKey(nav, {kind: 'text', value: 'n'}, 100, 14); stops.push(nav.scroll); } + assert.ok(stops[0]! > 0 && stops[1]! > stops[0]! && stops[2]! > stops[1]!, `n moves forward: ${stops}`); + comparePanelKey(nav, {kind: 'text', value: 'N'}, 100, 14); + assert.equal(nav.scroll, stops[1], 'N goes back one change'); + assert.deepEqual(comparePanelKey(panel, {kind: 'text', value: 'c'}, 100, 24)?.kind, 'copy'); + const report = comparePanelKey(panel, {kind: 'text', value: 'r'}, 100, 24); + assert.equal(report?.kind === 'copy' ? report.what : '', 'report'); + // Side by side on wide windows; unified on narrow ones; nothing wider than the window. + assert.match(stripAnsi(renderComparePanel(panel, 160, 24).join('\n')), /old\].*│.*new\]/u); + for (const columns of [160, 100, 40]) assert.ok(renderComparePanel(panel, columns, 20).map(stripAnsi).every(row => displayWidth(row) <= columns), `${columns}`); + // Esc returns to choosing; a second Esc closes. + assert.equal(comparePanelKey(panel, {kind: 'escape'}, 100, 24), undefined); + assert.equal(panel.comparison, undefined); + assert.equal(comparePanelKey(panel, {kind: 'escape'}, 100, 24)?.kind, 'close'); +}); + +test('panel text is drawn, never interpreted: hostile output and commands cannot reach the terminal', async () => { + const {createComparePanel, renderComparePanel} = await import('../src/ui/ComparePanel.js'); + const hostile = '\u001b]52;c;Zm9v\u0007\u001b[2J\u202e\u200b\u2028\u2029'; + const a = Object.assign(record(`echo ${hostile}`, 'a\n'), {output: `ok${hostile}\n`}); + const b = Object.assign(record(`echo ${hostile}`, 'b\n'), {output: `changed${hostile}\n`}); + for (const partner of [undefined, a]) for (const columns of [24, 100, 160]) { + const panel = createComparePanel(b, [b, a], partner); + const rendered = renderComparePanel(panel, columns, 24).join('\n').replace(/\u001b\[[\d;]*m/gu, ''); + assert.doesNotMatch(rendered, /[\u0007\u001b\p{Cf}\p{Zl}\p{Zp}]/u, `chooser and diff at ${columns} columns keep hostile text visible and inert`); + } +}); + +test('/compare parses numbers as /copy does and refuses anything else', async () => { + const {parseSlashCommand} = await import('../src/commands/slashCommands.js'); + assert.deepEqual(parseSlashCommand('/compare'), {kind: 'compare'}); + assert.deepEqual(parseSlashCommand('/compare 3'), {kind: 'compare', first: 3}); + assert.deepEqual(parseSlashCommand('/compare 1 4'), {kind: 'compare', first: 1, second: 4}); + for (const bad of ['/compare x', '/compare 0', '/compare 1 2 3', '/compare -1']) assert.ok((parseSlashCommand(bad) as {error?: string}).error, bad); +}); + +test('the comparison report is reviewed like any report: redacted, wrapped, and Enter copies exactly what was shown', async () => { + const {createTextReview, renderReportReview, reportReviewKey} = await import('../src/ui/ReportReview.js'); + const {DEFAULT_REPORT_OPTIONS} = await import('../src/clipboard/report.js'); + const {stripAnsi} = await import('../src/util/text.js'); + const before = record('env', 'API_TOKEN=old\n'); + const after = record('env', 'API_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789\n'); + const comparison = compareRecords(before, after); + const review = createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [before, after], DEFAULT_REPORT_OPTIONS, false); + const shown = stripAnsi(renderReportReview(review, 100, 40).join('\n')); + assert.match(shown, /^ {2}Copy comparison report {2}Markdown/u); + assert.match(shown, /Looks sensitive/u); + assert.doesNotMatch(review.text, /ghp_abcdef/u); + assert.equal(reportReviewKey(review, {kind: 'text', value: 'f'}, 40), undefined, 'format does not apply to a finished document'); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 40), {kind: 'copy', text: review.text}); +}); diff --git a/tests/compareOutputLive.test.ts b/tests/compareOutputLive.test.ts new file mode 100644 index 00000000..216a0f83 --- /dev/null +++ b/tests/compareOutputLive.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** Compare output through the real composer and PTY; a stand-in clipboard tool records the copied diff. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /compare shows what changed between two runs of a command; c copies a unified diff', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.txt'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 110, rows: 32}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + const props = join(sandbox.home, 'props.txt'); + writeFileSync(props, Array.from({length: 40}, (_, index) => `[ro.prop.${index}]: [${index === 7 ? 'before' : 'same'}]`).join('\n') + '\n'); + await frontend.run(`cat ${props}`, /ro\.prop\.39/u); + writeFileSync(props, Array.from({length: 40}, (_, index) => `[ro.prop.${index}]: [${index === 7 ? 'after\u202e\u200b\u001b[31m\u001b[0m' : 'same'}]`).join('\n') + '\n[ro.prop.new]: [1]\n'); + await frontend.run('echo between', /between/u); + await frontend.run(`cat ${props}`, /ro\.prop\.new/u); + // /compare 1 3: the latest cat and the earlier one (echo between is 2). + let mark = frontend.mark; + frontend.pty.write('/compare 1 3\r'); + await frontend.waitFor(/Compare output[\s\S]*1 changed · 1 added · 39 unchanged[\s\S]*- \[ro\.prop\.7\]: \[before\][\s\S]*\+ \[ro\.prop\.7\]: \[after··\]/u, mark); + frontend.pty.write('c'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').length > 0, 15_000, 'the diff on the clipboard'); + const diff = readFileSync(clip, 'utf8'); + assert.doesNotMatch(diff, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\p{Cf}]/u, 'hostile program controls never reach final clipboard bytes'); + assert.match(diff, /^--- A: cat [^\n]+· exit 0\n\+\+\+ B: cat [^\n]+· exit 0\n@@ -5,7 \+5,7 @@\n[\s\S]*-\[ro\.prop\.7\]: \[before\]\n\+\[ro\.prop\.7\]: \[after\]\n[\s\S]*\+\[ro\.prop\.new\]: \[1\]\n$/u, diff); + await frontend.waitFor(/Copied diff · \d+ lines/u, mark); + frontend.pty.write('\u001b'); + frontend.pty.write('\u001b'); + // Bare /compare: the latest output and its previous run, straight to the difference. + mark = frontend.mark; + frontend.pty.write('/compare\r'); + await frontend.waitFor(/Compare output[\s\S]*1 changed · 1 added/u, mark); + frontend.pty.write('\u001b'); + frontend.pty.write('\u001b'); + await frontend.run('echo AFTER-COMPARE', /AFTER-COMPARE/u); + mark = frontend.mark; + frontend.pty.write('/compare 9\r'); + await frontend.waitFor(/No completed command output at 9\./u, mark); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/compareSecurity.test.ts b/tests/compareSecurity.test.ts new file mode 100644 index 00000000..7a7fde86 --- /dev/null +++ b/tests/compareSecurity.test.ts @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer, type CompletedCommand} from '../src/output/OutputBuffer.js'; +import {compareRecords, comparisonReport, buildComparisonReport, unifiedDiff, copyableDiff} from '../src/output/compare.js'; +import {DEFAULT_REPORT_OPTIONS} from '../src/clipboard/report.js'; +import {createTextReview, reportReviewKey} from '../src/ui/ReportReview.js'; + +let clock = Date.UTC(2026, 9, 10, 8, 0, 0); +function record(command: string, output: string, extra: Partial = {}): CompletedCommand { + const buffer = new OutputBuffer(); + buffer.beginCommand(command, [`❯ ${command}`]); + buffer.write(output); + const done = buffer.complete(0)!; + clock += 60_000; + return Object.assign(done, {startedAt: clock}, extra); +} + +/** A record whose stored output is exactly this, whatever the buffer would have kept. */ +const hostile = (output: string) => Object.defineProperty(record('x', 'b\n'), 'output', {value: output}); + +const reviewed = (a: CompletedCommand, b: CompletedCommand) => { + const comparison = compareRecords(a, b); + return createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [a, b], {...DEFAULT_REPORT_OPTIONS, home: '/Users/alex'}, false).text; +}; + +test('a plain diff copy carries no terminal controls, paste-end markers or bidi/invisible characters', () => { + const comparison = compareRecords(record('x', 'a\n'), hostile('b \u001b[201~ \u202e evil \u200b\u0007\n')); + const text = copyableDiff(comparison); + assert.doesNotMatch(text, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b\u202e]/u); + assert.match(text, /^\+b \[201~/mu); + assert.equal(text.endsWith('\n'), true); +}); + +test('hidden characters between backticks cannot close the diff fence once they are removed', () => { + const text = reviewed(record('x', 'a\n'), record('x', 'b\n`​`​`​`\n')); + const fenceLines = text.split('\n').filter(line => /^`{5,}/u.test(line)); + assert.equal(fenceLines.length, 2, text); +}); + +test('a Unicode line separator cannot start a closing fence inside the diff', () => { + const text = reviewed(record('x', 'a\n'), record('x', 'b
```\n# injected\n')); + const fenceLines = text.split('\n').filter(line => /^`{4,}/u.test(line)); + assert.equal(fenceLines.length, 2, text); + assert.equal(text.split('\n').includes('# injected'), false); +}); + +test('a command with backticks, markup or line breaks cannot break out of its inline code in the comparison header', () => { + const hostile = 'echo `x` **bold** [l](http://e) 
# heading'; + const text = reviewed(record(hostile, 'a\n'), record(hostile, 'b\n')); + const headerLines = text.split('\n').slice(0, 8); + assert.equal(headerLines.some(line => /^#\s+heading/u.test(line)), false, text); + for (const line of headerLines.filter(line => line.startsWith('- **A') || line.startsWith('- **B'))) { + assert.match(line, /^- \*\*[AB] \((?:older|newer)\):\*\* (`+) ?.*\1$/u, line); + } +}); + +test('a secret only in compared output is redacted in the reviewed comparison report', () => { + const text = reviewed(record('env', 'A=1\n'), record('env', 'A=1\nTOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789\n')); + assert.doesNotMatch(text, /ghp_abcdefgh/u); +}); + +test('the sides keep their unified-diff text for tools: unifiedDiff stays raw, only the copy is cleaned', () => { + const comparison = compareRecords(record('x', 'a\n'), record('x', 'b\n')); + assert.equal(copyableDiff(comparison), unifiedDiff(comparison)); +}); + +test('comparison redaction cannot expose a hostile label by removing its generated inline delimiter', () => { + const command = '[open](https://example.invalid/leak) cookie: x'; + const a = record(command, 'before\n'); + const b = record(command, 'after\n'); + const comparison = compareRecords(a, b); + const review = createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [a, b], DEFAULT_REPORT_OPTIONS, false); + const labels = review.text.split('\n').filter(line => /^- \*\*[AB] /u.test(line)); + assert.equal(labels.length, 2); + for (const label of labels) { + assert.match(label, /`[^`]+cookie: \[REDACTED\]`$/u, label); + } + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 40), {kind: 'copy', text: review.text}); + reportReviewKey(review, {kind: 'text', value: 'r'}, 40); + assert.match(review.text, /`[^\n]*cookie: x[^\n]*`/u); + reportReviewKey(review, {kind: 'text', value: 'r'}, 40); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 40), {kind: 'copy', text: review.text}); + for (const label of review.text.split('\n').filter(line => /^- \*\*[AB] /u.test(line))) assert.match(label, /`$/u); +}); + + +test('an unfinished private key in compared output cannot consume the generated diff fence', () => { + const a = record('env', 'before\n'); + const b = record('env', '-----BEGIN OPENSSH PRIVATE KEY-----\nsecret without an end marker\n'); + const comparison = compareRecords(a, b); + const review = createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [a, b], DEFAULT_REPORT_OPTIONS, false); + assert.match(review.text, /\[REDACTED PRIVATE KEY\]/u); + assert.ok(review.text.endsWith('\n```\n')); + assert.doesNotMatch(review.text, /secret without an end marker/u); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 40), {kind: 'copy', text: review.text}); +}); + + +test('a large backtick-rich comparison sizes its diff fence without an argument spread', () => { + const a = record('printf', 'before\n'); + const b = record('printf', 'after\n', {output: '` '.repeat(150_000)}); + const text = comparisonReport(compareRecords(a, b)); + assert.ok(text.includes('```diff\n')); + assert.ok(text.endsWith('\n```\n')); +}); diff --git a/tests/composerLayout.test.ts b/tests/composerLayout.test.ts index 767a5440..d803b33c 100644 --- a/tests/composerLayout.test.ts +++ b/tests/composerLayout.test.ts @@ -8,7 +8,7 @@ import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; test('one-line prompt and editor share a row inside composer boundaries for either legacy placement', () => { for (const placement of ['header', 'composer'] as const) { const app = new TerminalApp(); - app['promptConfiguration'] = normalizePromptConfiguration({composerLayout: 'oneLine', placement}); + app['promptConfiguration'] = normalizePromptConfiguration({composerLayout: 'oneLine', placement, liveActivity: {style: 'classic'}}); app['context'] = {cwd: '/tmp/work', project: 'work', branch: 'dev', exitStatus: 7}; app['editor'].insert('echo ready'); app['running'] = {command: 'echo ready', startedAt: Date.now(), interrupted: false, cleared: false, startId: 0}; diff --git a/tests/contextCapabilities.test.ts b/tests/contextCapabilities.test.ts index 87e6e77c..f56343fb 100644 --- a/tests/contextCapabilities.test.ts +++ b/tests/contextCapabilities.test.ts @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {createHash} from 'node:crypto'; import type {CapabilityDefinition} from '../src/context/capability.js'; import {projectPackage} from '../src/context/capabilities/project.js'; -import {goRuntime, javaRuntime, nodeRuntime, pythonRuntime, rustRuntime, versionMismatch} from '../src/context/capabilities/runtimes.js'; +import {goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, versionMismatch} from '../src/context/capabilities/runtimes.js'; import {direnv, toolVersions} from '../src/context/capabilities/environment.js'; import {docker, helmChart, kubernetes, pulumi, terraform} from '../src/context/capabilities/infrastructure.js'; import {aws, azure, gcp} from '../src/context/capabilities/cloud.js'; @@ -342,3 +342,51 @@ test('vcs.git reads stash depth and upstream from Git files, including linked wo assert.equal(await resolve(gitExtras, {cwd: root}), undefined, 'outside a repository there is nothing to read'); } finally { await rm(root, {recursive: true, force: true}); } }); + +test('Ruby manifest whitespace stays bounded and declarations never span lines', async () => { + resetServiceCaches(); + const root = await workspace(); + try { + await writeFile(join(root, 'Gemfile'), `${'\n \t'.repeat(20_000)}ruby\n"3.3.0"\n`); + const started = Date.now(); + const result = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested', 'requestedFrom']}); + assert.equal(result?.requested, undefined, 'a different line is not a literal Ruby version declaration'); + assert.ok(Date.now() - started < 1500, `${Date.now() - started} ms`); + await writeFile(join(root, 'Gemfile'), ' \truby "3.3.0"\n'); + assert.equal((await resolve(rubyRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested']}))?.requested, '3.3.0'); + } finally { await rm(root, {recursive: true, force: true}); resetServiceCaches(); } +}); + +test('runtime facts: Ruby and PHP from the project\'s own files and the install location, never by running them', async () => { + resetServiceCaches(); + const root = await workspace(); + const tools = await workspace(); + try { + const rbenv = join(tools, '.rbenv', 'versions', '3.3.5', 'bin'); + await mkdir(rbenv, {recursive: true}); + await sentinelExecutable(join(rbenv, 'ruby'), join(tools, 'RUBY_RAN'), 'ruby 9.9.9'); + await writeFile(join(root, '.ruby-version'), 'ruby-3.2.4\n'); + await writeFile(join(root, 'Gemfile'), 'source "https://rubygems.org"\nruby "3.1.0"\n'); + const ruby = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: rbenv}}); + assert.deepEqual(ruby, {requested: '3.2.4', requestedFrom: '.ruby-version', active: '3.3.5', manager: 'rbenv', buildTool: 'bundler', mismatch: true}); + await rm(join(root, '.ruby-version')); + assert.deepEqual((await resolve(rubyRuntime, {cwd: root, root, env: {PATH: rbenv}, fields: ['requested', 'requestedFrom']})), {requested: '3.1.0', requestedFrom: 'Gemfile', buildTool: 'bundler'}); + assert.equal(await exists(join(tools, 'RUBY_RAN')), false); + + const cellar = join(tools, 'opt', 'Cellar', 'php', '8.3.12', 'bin'); + await mkdir(cellar, {recursive: true}); + await sentinelExecutable(join(cellar, 'php'), join(tools, 'PHP_RAN'), 'PHP 9.9.9'); + await writeFile(join(root, 'composer.json'), JSON.stringify({require: {php: '^8.2'}})); + const php = await resolve(phpRuntime, {cwd: root, root, env: {PATH: cellar}}); + assert.deepEqual(php, {requested: '^8.2', requestedFrom: 'composer.json', active: '8.3.12', manager: 'Homebrew', buildTool: 'composer'}); + assert.equal(await exists(join(tools, 'PHP_RAN')), false); + // A ruby inside the workspace is never inspected. + const planted = join(root, 'bin'); + await mkdir(planted); + await sentinelExecutable(join(planted, 'ruby'), join(root, 'PLANTED_RAN'), 'ruby 1.0.0'); + resetServiceCaches(); + const refused = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: planted}}); + assert.equal(refused?.active, undefined); + assert.equal(await exists(join(root, 'PLANTED_RAN')), false); + } finally { await rm(root, {recursive: true, force: true}); await rm(tools, {recursive: true, force: true}); resetServiceCaches(); } +}); 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/contextKernel.test.ts b/tests/contextKernel.test.ts index 7f4cccde..cd538fed 100644 --- a/tests/contextKernel.test.ts +++ b/tests/contextKernel.test.ts @@ -112,6 +112,7 @@ test('timeouts are failures with backoff; refresh retries; a previous value stay mode = 'hang'; now += 200; engine.demand(demand('slow.one')); await engine.settle(1000); assert.equal(engine.status('slow.one').state, 'timeout'); + assert.equal(engine.status('slow.one').hasValue, true, 'diagnostics disclose the retained previous value'); assert.match(engine.status('slow.one').error!, /timed out after 20 ms/); assert.equal(engine.facts()['slow.one']!.freshness, 'stale', 'old value kept, marked stale, never refreshed by invention'); assert.equal(engine.stats.timedOut, 1); @@ -138,6 +139,7 @@ test('a capability\'s first resolution gets a cold-start allowance; later ones k engine.stage(scope('/other')); engine.commit(); engine.demand(demand('cold.one')); await engine.settle(2000); assert.equal(engine.status('cold.one').state, 'timeout', 'once warm, the declared limit applies'); + assert.equal(engine.status('cold.one').hasValue, false, 'a different scope has no previous value to reuse'); assert.match(engine.status('cold.one').error!, /timed out after 30 ms/u); delay = 0; engine.refresh(); await engine.settle(2000); diff --git a/tests/copyFolding.test.ts b/tests/copyFolding.test.ts new file mode 100644 index 00000000..4aa2184c --- /dev/null +++ b/tests/copyFolding.test.ts @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {OutputBuffer} from '../src/output/OutputBuffer.js'; + +/** + * Folding is presentation; copying reads stored data. No copy path changes what is folded, unless "Auto-expand copied + * output" is On, and then only the blocks whose text was actually copied, after the clipboard accepted it. + * The clipboard is a stand-in tool first on PATH (the person's clipboard is never touched); it can be made to fail. + */ +function clipboardStub() { + const dir = mkdtempSync(join(tmpdir(), 'nmsh-clip-')); + const bin = join(dir, 'bin'); + mkdirSync(bin); + const clip = join(dir, 'clip.txt'); + const failFlag = join(dir, 'fail'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\n[ -e ${JSON.stringify(failFlag)} ] && exit 1\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const previous = {PATH: process.env.PATH, DISPLAY: process.env.DISPLAY}; + process.env.PATH = `${bin}:${process.env.PATH}`; + if (process.platform === 'linux') process.env.DISPLAY = ':nmsh-test'; + return { + read: () => { try { return readFileSync(clip, 'utf8'); } catch { return ''; } }, + failing: (on: boolean) => { if (on) writeFileSync(failFlag, ''); else rmSync(failFlag, {force: true}); }, + restore: () => { process.env.PATH = previous.PATH; if (previous.DISPLAY === undefined) delete process.env.DISPLAY; else process.env.DISPLAY = previous.DISPLAY; rmSync(dir, {recursive: true, force: true}); }, + }; +} + +const lines = (count: number, prefix: string) => Array.from({length: count}, (_, index) => `${prefix} ${index}\n`).join(''); + +function app(autoExpand = false) { + const instance = new TerminalApp(); + const output = instance['output'] as OutputBuffer; + output.setOutputFolding('smart'); + const add = (command: string, text: string, exitCode = 0) => { + output.beginCommand(command, [`❯ ${command}`]); + output.write(text); + output.complete(exitCode); + output.setCompletionLifecycle(exitCode ? `✘ Command failed · exit ${exitCode} · 1 ms` : '✔ Completed · 1 ms'); + }; + // Oldest first: three long (Smart folds them) and one silent. /copy 1 is "long-c". + add('long-a', lines(300, 'a')); + add('silent', ''); + add('long-b', lines(300, 'b'), 2); + add('long-c', lines(300, 'c')); + instance['promptConfiguration'].copy = {mode: 'quick', includeStatus: false, autoExpand}; + Object.defineProperty(instance, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + instance['renderer'].render = (() => {}) as never; + const folds = () => Object.fromEntries(output.recentShellCommands().map(record => [record.command, Boolean(record.expanded)])); + /** Type a command and wait for its outcome: the copy note (success, nothing to copy, or failure) or a panel. */ + const type = async (text: string) => { + instance['clipboardNote'] = undefined; + for (const char of text) await instance['handleKey']({kind: 'text', value: char}); + await instance['handleKey']({kind: 'enter'}); + const deadline = Date.now() + 5000; + while (!instance['clipboardNote'] && !instance['copyPicker'] && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + }; + const done = () => { instance['stop'](0); instance['session'].kill(); }; + return {instance, output, folds, type, done}; +} + +// A silent block has nothing to fold: it reads as expanded throughout. +const ALL_FOLDED = {'long-c': false, 'long-b': false, silent: true, 'long-a': false}; + +test('Auto-expand Off (default): no copy path changes folding; every copy reads the full stored output', async () => { + const clip = clipboardStub(); + const {instance, folds, type, done, output} = app(false); + try { + assert.deepEqual(folds(), ALL_FOLDED, 'Smart folded the long blocks (silent has nothing to fold)'); + for (const command of ['/copy', '/copy 2', '/copy -4', '/copy 2-4', '/copy 1,4', '/cp', '/cp -2 --status', '/copy latest']) { + await type(command); + assert.deepEqual(folds(), ALL_FOLDED, command); + } + await type('/copy 1-4'); + assert.equal(clip.read().split('\n').length, 900, 'all 900 lines, though every block is folded'); + // The picker, direct block Copy and the Actions menu's copies. + await type('/copy ui'); + await instance['handleKey']({kind: 'text', value: 'a'}); + instance['clipboardNote'] = undefined; + await instance['handleKey']({kind: 'enter'}); + while (!instance['clipboardNote']) await new Promise(resolve => setTimeout(resolve, 20)); + const target = output.recentShellCommands()[2]!; + await instance['copyBlock'](target.startId); + for (const action of ['copyOutput', 'copyBoth', 'copyCommand'] as const) await instance['runBlockAction'](target.startId, action); + assert.deepEqual(folds(), ALL_FOLDED); + assert.equal(instance['historyViewport'].detached, false, 'no scroll change'); + } finally { done(); clip.restore(); } +}); + +test('Auto-expand On: exactly the copied blocks unfold, after success; silent and unselected blocks are untouched', async () => { + const clip = clipboardStub(); + const {instance, folds, type, done, output} = app(true); + try { + await type('/copy 2-3'); + assert.deepEqual(folds(), {...ALL_FOLDED, 'long-b': true}, 'long-b was copied; silent added nothing'); + await type('/cp 4,1'); + assert.deepEqual(folds(), {'long-c': true, 'long-b': true, silent: true, 'long-a': true}); + // A person can fold a copied block again; it stays folded. + instance['output'].toggleExpanded(0); + assert.equal(folds()['long-c'], false); + // A failed clipboard write expands nothing. + clip.failing(true); + instance['output'].toggleExpanded(1); + const before = folds(); + await type('/copy -4'); + assert.deepEqual(folds(), before, 'nothing changes when the copy fails'); + clip.failing(false); + // Block Copy expands only that block. + const fresh = app(true); + try { + const target = fresh.output.recentShellCommands()[3]!; + await fresh.instance['copyBlock'](target.startId); + assert.deepEqual(fresh.folds(), {...ALL_FOLDED, 'long-a': true}); + } finally { fresh.done(); } + assert.ok(output.recentShellCommands().length === 4); + } finally { done(); clip.restore(); } +}); + +test('Auto-expand persists, is exported with the copy category, and defaults Off', async () => { + const {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} = await import('../src/prompt/configuration.js'); + const {exportSettings} = await import('../src/configuration/portability.js'); + assert.equal(DEFAULT_PROMPT_CONFIGURATION.copy.autoExpand, false); + assert.equal(normalizePromptConfiguration({copy: {autoExpand: true}}).copy.autoExpand, true); + const exported = exportSettings({...DEFAULT_PROMPT_CONFIGURATION, copy: {mode: 'quick', includeStatus: false, autoExpand: true}}, ['copy']); + assert.deepEqual(exported.categories.copy, {copy: {mode: 'quick', includeStatus: false, autoExpand: true}}); +}); diff --git a/tests/copyPayload.test.ts b/tests/copyPayload.test.ts index d9981ab9..49b8b11e 100644 --- a/tests/copyPayload.test.ts +++ b/tests/copyPayload.test.ts @@ -95,7 +95,8 @@ test('live: /copy and /copy N put exactly the command output on the clipboard, t writeFileSync(clip, 'KEEP'); const mark = frontend.mark; frontend.pty.write('/copy\r'); - await frontend.waitFor(/Nothing to copy: true printed no output\./u, mark); + // A brief note above the composer, never a transcript line. + await frontend.waitFor(/Nothing to copy: true printed no output; the clipboard is unchanged/u, mark); assert.equal(readFileSync(clip, 'utf8'), 'KEEP'); } finally { await sandbox.dispose(); } }); diff --git a/tests/copyReport.test.ts b/tests/copyReport.test.ts new file mode 100644 index 00000000..5cbd4398 --- /dev/null +++ b/tests/copyReport.test.ts @@ -0,0 +1,272 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer, type CompletedCommand} from '../src/output/OutputBuffer.js'; +import {buildReport, inlineCode, DEFAULT_REPORT_OPTIONS, describeFindings, parseReportFlag, redactedLines, redactText, scanSensitive} from '../src/clipboard/report.js'; +import {parseCopyArgs} from '../src/clipboard/copySelection.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {applyReportEdit, createReportReview, renderReportReview, reportReviewKey} from '../src/ui/ReportReview.js'; +import {copyPickerKey, createCopyPicker} from '../src/ui/CopyPicker.js'; +import {BLOCK_ACTIONS} from '../src/ui/BlockActions.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +function record(command: string, output: string, exitCode: number, lifecycle: string, extra: Partial = {}): CompletedCommand { + const buffer = new OutputBuffer(); + buffer.beginCommand(command, [`❯ ${command}`]); + buffer.write(output); + const done = buffer.complete(exitCode)!; + buffer.setCompletionLifecycle(lifecycle); + return Object.assign(done, {startedAt: Date.UTC(2026, 9, 10, 8, 1, 47), durationMs: 12_900, historicalContext: {cwd: '/Users/alex/Projects/demo'}}, extra); +} + +const devices = () => record('adb devices', 'List of devices attached\r\nR58M12ABC\tdevice\r\n', 0, '✔ Completed · 1.4s · 10:04'); +const failed = () => record('npm test', 'boom\r\n```inner fence```\r\n', 1, '✘ Command failed · exit 1 · 12.9s · 10:05 · 3.1s waiting for input'); + +test('Markdown report: one section per command, facts from the record, output fenced safely, oldest first', () => { + const {text, findings} = buildReport([devices(), failed()], {...DEFAULT_REPORT_OPTIONS, redact: false}); + assert.deepEqual(findings, []); + assert.match(text, /^# Command report\n\n2 commands, oldest first\.\n\n## 1\. `adb devices`\n\n- \*\*Status:\*\* `✔ Completed · 1\.4s · 10:04`\n- \*\*Exit code:\*\* 0\n- \*\*Duration:\*\* 12\.9s\n\n```text\nList of devices attached\nR58M12ABC {7}device\n```/u, 'output as stored (tabs expanded as shown)'); + assert.match(text, /## 2\. `npm test`[\s\S]*- \*\*Status:\*\* `✘ Command failed · exit 1 · 12\.9s · 10:05 · 3\.1s waiting for input`\n- \*\*Exit code:\*\* 1/u); + assert.match(text, /````text\nboom\n```inner fence```\n````/u, 'a longer fence than any backtick run in the output'); + assert.doesNotMatch(text, /Directory|Started/u, 'directory and times are opt-in'); + const options = {...DEFAULT_REPORT_OPTIONS, redact: false, directory: true, timestamps: true}; + assert.match(buildReport([devices()], options).text, /- \*\*Directory:\*\* `\/Users\/alex\/Projects\/demo`\n- \*\*Started:\*\* 2026-10-1[01] \d\d:\d\d:47/u); + assert.match(buildReport([record('true', '', 0, '✔ Completed · 2 ms')], options).text, /_No output\._/u); + const multi = buildReport([record('for i in 1 2; do\n echo `x`\ndone', 'x\r\n', 0, '✔ Completed')], options).text; + assert.match(multi, /## 1\. Command\n\n```sh\nfor i in 1 2; do\n echo `x`\ndone\n```/u); +}); + +test('plain report: labelled lines and clear separators; control sequences never reach the report', () => { + const hostile = record('printf x', 'ok\u001b]52;c;Zm9v\u0007\u001b[31m red\r\n', 0, '✔ Completed · 1 ms'); + const {text} = buildReport([hostile, failed()], {...DEFAULT_REPORT_OPTIONS, format: 'plain', redact: false}); + assert.match(text, /^Command report · 2 commands, oldest first\n\n=== 1\. \$ printf x\nStatus: ✔ Completed · 1 ms\nExit code: 0\nDuration: 12\.9s\n--- output ---\nok red\n--- end ---/u, 'stored output is already free of escape sequences'); + assert.doesNotMatch(text, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/u); +}); + +test('redaction: common secret shapes and private paths are replaced and counted; ordinary text is left alone', () => { + const source = [ + 'export GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', + 'curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.c2lnbmF0dXJlLXRleHQ"', + 'git clone https://alex:s3cretPass@github.com/org/repo.git', + 'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'AKIAIOSFODNN7EXAMPLE and sk-ant-api03-abcdefghijklmnopqrstuvwx', + 'password: hunter2', + '-----BEGIN OPENSSH PRIVATE KEY-----', 'b3BlbnNzaC1rZXktdjEAAAAA', '-----END OPENSSH PRIVATE KEY-----', + 'cd /Users/alex/Projects/demo && ls /home/sam/build', + 'mail me at alex@example.com', + 'mysql --password=s3cr3tpw -u root && deploy --token abc.def.ghi && login -p', + 'Set-Cookie: session=abcdef123; HttpOnly', + 'stripe sk_live_ABCDEFGHIJKLMNOPQRSTUV and -----BEGIN PGP PRIVATE KEY BLOCK-----', 'lQOYBF', '-----END PGP PRIVATE KEY BLOCK-----', + 'ordinary output: 3 tests passed, token count 42, key: value', + ].join('\n'); + const {text, findings} = redactText(source, '/Users/alex'); + for (const secret of ['ghp_abcdefghijklmnopqrstuvwxyz0123456789', 'eyJhbGciOiJIUzI1NiJ9', 's3cretPass', 'wJalrXUtnFEMI', 'AKIAIOSFODNN7EXAMPLE', 'sk-ant-api03', 'hunter2', + 'b3BlbnNzaC1rZXktdjEAAAAA', '/Users/alex', '/home/sam', 'alex@example.com', 's3cr3tpw', 'abc.def.ghi', 'session=abcdef123', 'sk_live_ABCD', 'lQOYBF']) assert.ok(!text.includes(secret), `${secret} redacted:\n${text}`); + assert.match(text, /git clone https:\/\/alex:\[REDACTED\]@github\.com/u); + assert.match(text, /cd ~\/Projects\/demo && ls \/home\/\/build/u); + assert.match(text, /ordinary output: 3 tests passed, token count 42, key: value/u, 'not every "token" or "key" is a secret'); + const kinds = Object.fromEntries(findings.map(finding => [finding.kind, finding.count])); + assert.equal(kinds['private key'], 2); + assert.match(text, /login -p$/mu, 'a bare flag with no value is left alone'); + assert.equal(kinds['private path'], 2); + assert.ok(kinds.token! >= 3 && kinds.credential! >= 3, JSON.stringify(kinds)); + assert.match(describeFindings([{kind: 'token', count: 2}, {kind: 'private path', count: 1}, {kind: 'email address', count: 3}]), /^2 tokens, 1 private path and 3 email addresses$/u); + assert.deepEqual(scanSensitive('all good here: 12 files, 0 errors'), []); +}); + +test('flags: --report and --report=plain in /copy and /cp; with --status it explains instead of guessing', () => { + assert.equal(parseReportFlag('--report'), 'markdown'); + assert.equal(parseReportFlag('--report=md'), 'markdown'); + assert.equal(parseReportFlag('--report=plain'), 'plain'); + assert.equal(parseReportFlag('--report=pdf'), 'invalid'); + assert.equal(parseReportFlag('--reports'), undefined); + assert.deepEqual(parseCopyArgs('1-4 --report'), {ok: true, request: {selector: {kind: 'indices', indices: [1, 2, 3, 4]}, report: 'markdown'}}); + assert.deepEqual(parseCopyArgs('--report=plain -2'), {ok: true, request: {selector: {kind: 'latest', count: 2}, report: 'plain'}}); + assert.deepEqual(parseCopyArgs('ui --report'), {ok: true, request: {selector: {kind: 'picker'}, report: 'markdown'}}); + assert.match((parseCopyArgs('--report --status') as {error: string}).error, /always includes each command's status/u); + assert.match((parseCopyArgs('--report=pdf') as {error: string}).error, /--report=plain/u); + assert.deepEqual(parseSlashCommand('/cp 1-4 --report'), parseSlashCommand('/copy 1-4 --report')); +}); + +test('review: every report is reviewed; changed lines are marked; switches rebuild; an edit is re-checked and is what gets copied', () => { + const secret = record('env', 'API_KEY=abc123def456\r\nHOME=/Users/alex\r\n', 0, '✔ Completed · 3 ms'); + const home = {...DEFAULT_REPORT_OPTIONS, home: '/Users/alex'}; + assert.match(stripAnsi(renderReportReview(createReportReview([devices()], home, false), 100, 30).join('\n')), /No common secret shapes found[\s\S]*not a guarantee/u); + const review = createReportReview([secret], home, true); + assert.deepEqual(redactedLines(review.text).length, 2); + assert.match(stripAnsi(renderReportReview(review, 100, 30).join('\n')), /Changed lines \(▸\): \d+, \d+[\s\S]*▸- \*\*|▸API_KEY=\[REDACTED\]/u); + assert.ok(!review.text.includes('abc123def456') && !review.text.includes('/Users/alex')); + assert.match(stripAnsi(renderReportReview(review, 100, 30).join('\n')), /Looks sensitive: 1 credential and 1 private path · redacted 1 credential and 1 private path/u); + reportReviewKey(review, {kind: 'text', value: 'r'}, 30); + assert.ok(review.text.includes('abc123def456'), 'redaction off shows the original'); + assert.match(stripAnsi(renderReportReview(review, 100, 30).join('\n')), /NOT redacted/u); + reportReviewKey(review, {kind: 'text', value: 'r'}, 30); + reportReviewKey(review, {kind: 'text', value: 'f'}, 30); + assert.match(review.text, /^Command report · 1 command/u); + assert.deepEqual(reportReviewKey(review, {kind: 'text', value: 'e'}, 30), {kind: 'edit'}); + applyReportEdit(review, 'my corrected report\n'); + assert.deepEqual(review.sensitive, []); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 30), {kind: 'copy', text: 'my corrected report\n'}); + applyReportEdit(review, 'oops ghp_abcdefghijklmnopqrstuvwxyz0123456789\n'); + assert.match(stripAnsi(renderReportReview(review, 100, 30).join('\n')), /Looks sensitive: 1 token · in your edit, not redacted/u); + assert.equal(reportReviewKey(review, {kind: 'escape'}, 30)?.kind, 'close'); + // No editor configured: no edit key. + const noEditor = createReportReview([secret], home, false); + assert.equal(reportReviewKey(noEditor, {kind: 'text', value: 'e'}, 30), undefined); + for (const columns of [100, 40]) { + const lines = renderReportReview(noEditor, columns, 20).map(stripAnsi); + assert.ok(lines.every(line => displayWidth(line) <= columns) && lines.length <= 20, `${columns} columns`); + } +}); + +test('entry points: the picker\'s r and the Actions menu name the same report flow', () => { + const picker = createCopyPicker([failed(), devices()], false); + copyPickerKey(picker, {kind: 'text', value: 'a'}); + const action = copyPickerKey(picker, {kind: 'text', value: 'r'}); + assert.equal(action?.kind, 'report'); + assert.deepEqual(action?.kind === 'report' ? action.records.map(item => item.command) : [], ['adb devices', 'npm test']); + assert.ok(BLOCK_ACTIONS.some(item => item.id === 'copyReport' && /report/u.test(item.label))); +}); + +test('no parser differential: invisible characters cannot hide a secret, glued tokens are found, and the review shows every copied character', () => { + // Zero-width space, zero-width joiner, a bidi override and a soft hyphen inside or around a token. + const hidden = 'TOKEN=gh​p_abcdefghij‍klmnopqrstuvwxyz0123456789 ‮evil‬ and gl­pat-abcdefghijklmnopqrstuv'; + const {text} = redactText(hidden); + assert.doesNotMatch(text, /ghp_|glpat-/u, text); + assert.doesNotMatch(text, /\p{Cf}/u, 'no invisible format characters survive into the report'); + assert.match(redactText('build_ghp_abcdefghijklmnopqrstuvwxyz0123456789_done').text, /build_\[REDACTED\]_done/u); + // A long line in the review: wrapped, every character visible; what Enter copies is exactly what was shown. + const long = record('echo', `${'x'.repeat(590)}-TAIL-END\r\n`, 0, '✔ Completed · 1 ms'); + const review = createReportReview([long], {...DEFAULT_REPORT_OPTIONS, home: '/Users/alex'}, false); + const shown: string[] = []; + for (let page = 0; page < 40; page += 1) { + shown.push(...renderReportReview(review, 60, 24).map(stripAnsi)); + reportReviewKey(review, {kind: 'pageDown'}, 24); + } + assert.ok(shown.some(row => row.includes('-TAIL-END')), 'the end of a long line is visible'); + const action = reportReviewKey(review, {kind: 'enter'}, 24); + assert.deepEqual(action, {kind: 'copy', text: review.text}); + // The rendered rows, joined, contain the whole report: nothing copied is unseen. + const visible = shown.join('').replace(/\s+/gu, ''); + for (const line of review.text.split('\n')) assert.ok(visible.includes(line.replace(/\s+/gu, '')), `shown: ${line.slice(0, 40)}`); + // An edit carrying invisible characters is cleaned exactly like a generated report. + applyReportEdit(review, 'see gh​p_abcdefghijklmnopqrstuvwxyz0123456789\n'); + assert.equal(review.text, 'see ghp_abcdefghijklmnopqrstuvwxyz0123456789\n'); + assert.match(stripAnsi(renderReportReview(review, 100, 30).join('\n')), /Looks sensitive: 1 token · in your edit/u); +}); + +test('no differential with what a reader parses: line separators, ignorable characters, look-alikes and marks', () => { + // A line separator would end a line in the editor or Markdown reader; it cannot close the fence from inside. + const escape = record('cat notes', 'safe\u2028```\u2029![x](https://example.invalid/leak)\r\n', 0, '✔ Completed · 1 ms'); + const {text} = buildReport([escape], {...DEFAULT_REPORT_OPTIONS, redact: false}); + assert.doesNotMatch(text, /[\u2028\u2029]/u); + assert.match(text, /````text\nsafe\n```\n!\[x\]\(https:\/\/example\.invalid\/leak\)\n````/u, 'the fence outgrows the inner run and still holds the whole output'); + // Variation selectors, Hangul fillers, combining marks and full-width look-alikes inside a token. + for (const disguised of ['ghp_abcdefghij\uFE0Fklmnopqrstuvwxyz0123456789', 'ghp_abcdefghij\u3164klmnopqrstuvwxyz0123456789', + 'ghp_abcdefghij\u0301klmnopqrstuvwxyz0123456789', '\uFF47\uFF48\uFF50_abcdefghijklmnopqrstuvwxyz0123456789']) { + const result = redactText(`token: ${disguised} end`).text; + assert.match(result, /^token: \[REDACTED\] end$/u, JSON.stringify(disguised)); + } + // Ordinary non-ASCII text is never rewritten: fidelity outside redacted spans. + const faithful = 'café naïve x² file 日本語 émoji 🐈'; + assert.equal(redactText(faithful).text, faithful); +}); + +test('the review never cuts a row: wide, ambiguous and combining characters wrap within the panel', () => { + const wide = record('echo', `${'日本語の出力'.repeat(30)} é\u0301 ${'🐈'.repeat(40)} TAIL\r\n`, 0, '✔ Completed · 1 ms'); + const review = createReportReview([wide], {...DEFAULT_REPORT_OPTIONS}, false); + const seen: string[] = []; + for (let page = 0; page < 20; page += 1) { + const rows = renderReportReview(review, 50, 20).map(stripAnsi); + assert.ok(rows.every(row => displayWidth(row) <= 50), 'every row fits'); + seen.push(...rows); + reportReviewKey(review, {kind: 'pageDown'}, 20); + } + assert.ok(seen.some(row => row.includes('TAIL')), 'the end of the line was shown'); +}); + +test('a working directory with a line break or backtick cannot add structure to a report', () => { + const cwd = '/tmp/x`\n# Injected\n- **Status:** fine'; + const {text} = buildReport([record('ls', 'a\n', 0, '', {historicalContext: {cwd}})], {...DEFAULT_REPORT_OPTIONS, directory: true, redact: false}); + assert.equal(text.split('\n').some(line => /^# Injected|^- \*\*Status:\*\* fine/u.test(line)), false, text); + assert.equal(buildReport([record('ls', 'a\n', 0, '', {historicalContext: {cwd}})], {...DEFAULT_REPORT_OPTIONS, format: 'plain', directory: true, redact: false}).text.split('\n').some(line => line === '# Injected'), false); +}); + +test('restored status text stays literal Markdown rather than injecting links, HTML or code delimiters', () => { + const status = 'Completed ``` [open](javascript:alert(1)) \n# forged heading'; + const text = buildReport([record('echo x', 'ok\n', 0, status)], {...DEFAULT_REPORT_OPTIONS, redact: false}).text; + const line = text.split('\n').find(value => value.startsWith('- **Status:**')); + assert.equal(line, '- **Status:** ````Completed ``` [open](javascript:alert(1)) # forged heading````'); + assert.equal(text.split('\n').includes('# forged heading'), false); +}); + +test('redaction cannot consume the Markdown boundaries around hostile commands, status or directory facts', () => { + const payload = ' password=hunter2'; + const item = record(payload, 'safe\n', 1, payload, {historicalContext: {cwd: payload}}); + const review = createReportReview([item], {...DEFAULT_REPORT_OPTIONS, directory: true}, false); + const literal = ' password=[REDACTED]'; + assert.ok(review.text.includes('## 1. `' + literal + '`\n')); + assert.ok(review.text.includes('- **Status:** `' + literal + '`\n')); + assert.ok(review.text.includes('- **Directory:** `' + literal + '`\n')); + assert.doesNotMatch(review.text, /hunter2/u); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 40), {kind: 'copy', text: review.text}); + assert.deepEqual(review.redacted, [{kind: 'credential', count: 3}]); +}); + +test('redaction cannot consume generated command/output fences or a later report section', () => { + const dangling = record('echo', 'safe\n', 0, 'Completed', {command: 'echo\npassword=', output: 'safe\npassword=\n'}); + const privateKey = record('cat key', '-----BEGIN OPENSSH PRIVATE KEY-----\nsecret without an end marker', 0, 'Completed'); + const next = record('echo NEXT', 'NEXT\n', 0, 'Completed'); + const {text} = buildReport([dangling, privateKey, next], DEFAULT_REPORT_OPTIONS); + assert.ok(text.includes('```sh\necho\npassword=\n```')); + assert.ok(text.includes('```text\nsafe\npassword=\n```')); + assert.ok(text.includes('```text\n[REDACTED PRIVATE KEY]\n```')); + assert.ok(text.includes('## 3. `echo NEXT`')); + assert.doesNotMatch(text, /secret without an end marker/u); +}); + + +test('a short sensitive report keeps every document row reachable before approval', () => { + const review = createReportReview([record('env', 'TOKEN=abc123\nTAIL\n', 0, 'Completed')], DEFAULT_REPORT_OPTIONS, false); + const seen: string[] = []; + for (let scroll = 0; scroll < 80; scroll++) { + seen.push(stripAnsi(renderReportReview(review, 40, 8).join('\n'))); + reportReviewKey(review, {kind: 'down'}, 8); + } + assert.ok(seen.some(frame => frame.includes('# Command report'))); + assert.ok(seen.some(frame => frame.includes('TOKEN=[REDACTED]'))); + assert.ok(seen.some(frame => frame.includes('TAIL'))); + for (const frame of seen) assert.ok(frame.split('\n').length <= 8, frame); +}); + + +test('a report too short to show a document refuses copy until resized', () => { + const review = createReportReview([devices()], DEFAULT_REPORT_OPTIONS, false); + assert.equal(reportReviewKey(review, {kind: 'enter'}, 3), undefined); + const frame = stripAnsi(renderReportReview(review, 40, 3).join('\n')); + assert.match(frame, /Resize to review/u); + assert.ok(frame.split('\n').length <= 3); + assert.deepEqual(reportReviewKey(review, {kind: 'enter'}, 8), {kind: 'copy', text: review.text}); +}); + + +test('extremely narrow report wrapping keeps every copied character reachable', () => { + const review = createReportReview([record('env', 'ABCDEFGH9\n', 0, 'Completed')], DEFAULT_REPORT_OPTIONS, false); + const shown: string[] = []; + for (let scroll = 0; scroll < 100; scroll++) { + shown.push(...renderReportReview(review, 10, 8).map(stripAnsi)); + reportReviewKey(review, {kind: 'down'}, 8); + } + assert.ok(shown.some(row => row.includes('H9')), 'the final characters are reachable rather than clipped'); + assert.equal(reportReviewKey(review, {kind: 'enter'}, 8, 3), undefined, 'an unrenderable body cannot be approved'); +}); + + +test('large backtick-rich output and restored facts do not exceed the JavaScript argument limit', () => { + const payload = '` '.repeat(150_000); + const item = record('printf', '', 0, payload, {output: payload}); + const {text} = buildReport([item], {...DEFAULT_REPORT_OPTIONS, redact: false}); + assert.ok(text.includes('```text\n' + payload + '\n```')); + const literal = inlineCode(payload); + assert.ok(literal.startsWith('`` ')); + assert.ok(literal.endsWith(' ``')); +}); diff --git a/tests/copyReportLive.test.ts b/tests/copyReportLive.test.ts new file mode 100644 index 00000000..f8f04584 --- /dev/null +++ b/tests/copyReportLive.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('live: report review cancels safely and copies sanitized, redacted Markdown bytes', {skip: !['darwin', 'linux'].includes(process.platform), timeout: 90_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clipboard = join(sandbox.root, 'copied.txt'); + for (const name of ['pbcopy', 'wl-copy', 'xclip']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > '${clipboard}'\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 110, rows: 34}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + const output = join(sandbox.home, 'hostile-output.txt'); + const secret = 'ghp_abcdefghijklmnopqrstuvwxyz0123456789'; + writeFileSync(output, `GITHUB_TOKEN=${secret}\n\u001b[31mred\u001b[0m\u202e\u200b\n\u0060\u200b\u0060\u200b\u0060\n# output heading\nEND-OF-HOSTILE\n`); + await frontend.run(`cat '${output}'`, /END-OF-HOSTILE/u); + let mark = frontend.mark; + frontend.pty.write('/copy 1 --report\r'); + await frontend.waitFor(/Copy as report[\s\S]*Redact on/u, mark); + assert.equal(existsSync(clipboard), false, 'opening review must not copy'); + frontend.pty.write('\u001b'); + await frontend.run('echo AFTER-CANCEL', /AFTER-CANCEL/u); + assert.equal(existsSync(clipboard), false, 'cancelling must not copy'); + mark = frontend.mark; + frontend.pty.write('/copy 2 --report\r'); + await frontend.waitFor(/Copy as report[\s\S]*Redact on/u, mark); + frontend.pty.write('\r'); + await until(() => existsSync(clipboard) && readFileSync(clipboard, 'utf8').length > 0, 15_000, 'reviewed report bytes'); + const copied = readFileSync(clipboard, 'utf8'); + assert.doesNotMatch(copied, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\p{Cf}]/u); + assert.doesNotMatch(copied, new RegExp(secret, 'u')); + assert.match(copied, /\[REDACTED/u); + assert.match(copied, /END-OF-HOSTILE/u); + assert.equal(copied.split('\n').filter(line => /^`{4,}/u.test(line)).length, 2, 'sanitized output cannot close the authored fence'); + await frontend.waitFor(/Copied report/u, mark); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/copySelection.test.ts b/tests/copySelection.test.ts new file mode 100644 index 00000000..dab50e2b --- /dev/null +++ b/tests/copySelection.test.ts @@ -0,0 +1,194 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection, type CopySelector} from '../src/clipboard/copySelection.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {blockCopyPayload, blockControlsLayout} from '../src/ui/BlockActions.js'; +import {copyPickerKey, copyPickerRows, createCopyPicker, renderCopyPicker} from '../src/ui/CopyPicker.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {exportSettings, PORTABLE_CATEGORIES} from '../src/configuration/portability.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; + +function run(buffer: OutputBuffer, command: string, output: string, exitCode: number, lifecycle: string) { + buffer.beginCommand(command, [`❯ ${command}`]); + buffer.write(output); + const record = buffer.complete(exitCode)!; + buffer.setCompletionLifecycle(lifecycle); + return record; +} + +/** Five commands, oldest first: one, two (failed), silent, four (multi-line), five. /copy 1 is "five". */ +function transcript() { + const buffer = new OutputBuffer(); + run(buffer, 'echo one', 'one\r\n', 0, '✔ Completed · 4 ms · 10:01'); + run(buffer, 'false-ish', 'boom\r\n', 2, '✘ Command failed · exit 2 · 12.9s · 10:02'); + run(buffer, 'true', '', 0, '✔ Completed · 2 ms · 10:03'); + run(buffer, 'printf multi', 'a\r\n\r\n b\r\n', 0, '✔ Completed · 1.4s · 10:04 · 3.1s waiting for input'); + run(buffer, 'echo five', 'five\r\n', 0, '✔ Completed · 3 ms · 10:05'); + return buffer; +} + +const ok = (text: string) => { + const parsed = parseCopyArgs(text); + assert.ok(parsed.ok, `${text}: ${parsed.ok ? '' : parsed.error}`); + return parsed.request; +}; +const selector = (text: string) => ok(text).selector as Extract; + +test('parse: every form, flags in any position, and precise errors; -N is a count, --flags are never counts', () => { + assert.deepEqual(ok(''), {selector: {kind: 'default'}}); + assert.deepEqual(ok('latest'), {selector: {kind: 'latest', count: 1}}); + assert.deepEqual(ok('ui'), {selector: {kind: 'picker'}}); + assert.deepEqual(ok('2'), {selector: {kind: 'indices', indices: [2]}}); + assert.deepEqual(ok('-3'), {selector: {kind: 'latest', count: 3}}); + assert.deepEqual(ok('2-4'), {selector: {kind: 'indices', indices: [2, 3, 4]}}); + assert.deepEqual(ok('4-2'), {selector: {kind: 'indices', indices: [2, 3, 4]}}, 'either order'); + assert.deepEqual(ok('1,3,5'), {selector: {kind: 'indices', indices: [1, 3, 5]}}); + assert.deepEqual(ok('1-3,6,8'), {selector: {kind: 'indices', indices: [1, 2, 3, 6, 8]}}); + assert.deepEqual(ok('1-3,2,3-4'), {selector: {kind: 'indices', indices: [1, 2, 3, 4]}}, 'overlaps deduplicated'); + assert.deepEqual(ok('-3 --status'), {selector: {kind: 'latest', count: 3}, status: true}); + assert.deepEqual(ok('--no-status 2-4'), {selector: {kind: 'indices', indices: [2, 3, 4]}, status: false}); + assert.deepEqual(ok('--status'), {selector: {kind: 'default'}, status: true}); + for (const bad of ['0', '-0', '2-', 'x', '1,,2', '--state', '1 2', '1000', '-101', '1-200', 'ui 2']) { + const parsed = parseCopyArgs(bad); + assert.equal(parsed.ok, false, bad); + } + assert.match((parseCopyArgs('--status --no-status') as {error: string}).error, /either --status or --no-status/u); +}); + +test('/cp is /copy: the same parse for every form', () => { + for (const args of ['', '2', '-3', '2-4', '1,3,5', 'ui', 'latest', '-3 --status', 'settings']) { + assert.deepEqual(parseSlashCommand(`/cp${args ? ` ${args}` : ''}`), parseSlashCommand(`/copy${args ? ` ${args}` : ''}`), args); + } + assert.deepEqual(parseSlashCommand('/cp -3 --status'), {kind: 'copy', args: '-3 --status'}); + assert.deepEqual(parseSlashCommand('/copy settings'), {kind: 'copySettings'}); + assert.equal(parseSlashCommand('/cpx')?.kind, 'unknown', 'only the exact alias'); +}); + +test('resolve: oldest first; a missing number refuses the whole copy; -N takes what there is', () => { + const recent = transcript().recentShellCommands(); + const commands = (text: string) => { + const resolved = resolveCopySelection(selector(text), recent); + assert.ok(resolved.ok, text); + return resolved.records.map(record => record.command); + }; + assert.deepEqual(commands('1'), ['echo five']); + assert.deepEqual(commands('-3'), ['true', 'printf multi', 'echo five']); + assert.deepEqual(commands('2-4'), ['false-ish', 'true', 'printf multi']); + assert.deepEqual(commands('5,1,3'), ['echo one', 'true', 'echo five']); + assert.deepEqual(commands('-9'), ['echo one', 'false-ish', 'true', 'printf multi', 'echo five']); + const missing = resolveCopySelection(selector('1,7'), recent); + assert.equal(missing.ok, false); + assert.match((missing as {error: string}).error, /No completed command output at 7: there are only 5 completed commands\. Nothing was copied\./u); + assert.equal(resolveCopySelection(selector('1'), []).ok, false); +}); + +test('payload: output only by default; with status each record\'s own recorded status follows its own output', () => { + const recent = transcript().recentShellCommands(); + const pick = (text: string) => (resolveCopySelection(selector(text), recent) as {records: typeof recent}).records; + assert.equal(copySelectionPayload(pick('2-4'), false), 'boom\na\n\n b', 'silent commands add nothing; no status rows'); + assert.equal(copySelectionPayload(pick('2-4'), true), + 'boom\n✘ Command failed · exit 2 · 12.9s · 10:02\n✔ Completed · 2 ms · 10:03\na\n\n b\n✔ Completed · 1.4s · 10:04 · 3.1s waiting for input'); + assert.equal(copySelectionPayload(pick('3'), false), '', 'a silent command alone copies nothing'); + assert.equal(copySelectionPayload(pick('3'), true), '✔ Completed · 2 ms · 10:03', 'status was asked for: it alone is copied'); + // The stored output is never changed by including the status. + assert.equal(recent[1]!.output, 'a\n\n b'); + assert.equal(recordCopyText(recent[0]!, true), 'five\n✔ Completed · 3 ms · 10:05'); + assert.match(copiedNote(3, {characters: 3501, lines: 12}, true), /^Copied 3 outputs with completion status · 3,501 characters · 12 lines$/u); + assert.equal(copiedNote(1, {characters: 1, lines: 1}, false), 'Copied · 1 character · 1 line'); +}); + +test('folded and restored transcripts copy the full stored output, with the same status', () => { + const buffer = transcript(); + const long = Array.from({length: 400}, (_, index) => `line ${index}`).join('\r\n'); + run(buffer, 'seq', `${long}\r\n`, 0, '✔ Completed · 9 ms · 10:06'); + const restored = new OutputBuffer(); + restored.restoreTranscript(buffer.transcript()); + for (const source of [buffer, restored]) { + const [record] = source.recentShellCommands(); + assert.equal(recordCopyText(record!, true).split('\n').length, 401); + assert.ok(recordCopyText(record!, true).endsWith('line 399\n✔ Completed · 9 ms · 10:06')); + } +}); + +test('block actions: Copy output and Copy command + output follow the status setting; Copy command never does', () => { + const record = transcript().recentShellCommands()[3]!; + assert.equal(blockCopyPayload(record, 'copyOutput', false), 'boom'); + assert.equal(blockCopyPayload(record, 'copyOutput', true), 'boom\n✘ Command failed · exit 2 · 12.9s · 10:02'); + assert.equal(blockCopyPayload(record, 'copyBoth', true), 'false-ish\nboom\n✘ Command failed · exit 2 · 12.9s · 10:02'); + assert.equal(blockCopyPayload(record, 'copyCommand', true), 'false-ish'); +}); + +test('block controls: Copy beside Actions where room allows; "Copied" never moves Actions; narrow keeps Actions only', () => { + const wide = blockControlsLayout(20, 100)!; + const copied = blockControlsLayout(20, 100, {copied: true})!; + assert.ok(wide.suffix.endsWith('[Copy] [Actions]')); + assert.ok(copied.suffix.endsWith('[✓ Copied] [Actions]')); + assert.ok(blockControlsLayout(20, 100, {copied: true, safe: true})!.suffix.endsWith('[+ Copied] [Actions]'), 'Safe glyphs'); + assert.equal(wide.column, copied.column, 'Actions stays put'); + assert.equal(displayWidth(wide.suffix), 80); + assert.equal(displayWidth(copied.suffix), 80); + // Click targets are exactly the labels' cells. + const row = ' '.repeat(20) + wide.suffix; + assert.equal(row.slice(wide.copy!.column - 1, wide.copy!.end), '[Copy]'); + assert.equal(row.slice(wide.column - 1), '[Actions]'); + const narrow = blockControlsLayout(20, 40)!; + assert.equal(narrow.copy, undefined); + assert.ok(narrow.suffix.endsWith('[Actions]')); + assert.equal(blockControlsLayout(35, 40), undefined, 'never covers the row\'s own text'); +}); + +test('picker: newest first with /copy numbers; Space selects, a toggles all, search keeps selections, Enter copies oldest first', () => { + const picker = createCopyPicker(transcript().recentShellCommands(), false); + assert.deepEqual(copyPickerRows(picker).map(row => [row.number, row.record.command]).slice(0, 2), [[1, 'echo five'], [2, 'printf multi']]); + // Enter with nothing chosen copies the highlighted row. + assert.deepEqual((copyPickerKey(structuredClone(picker), {kind: 'enter'}) as {records: Array<{command: string}>}).records.map(record => record.command), ['echo five']); + copyPickerKey(picker, {kind: 'text', value: ' '}); + copyPickerKey(picker, {kind: 'down'}); + copyPickerKey(picker, {kind: 'text', value: ' '}); + // Search narrows the rows; the chosen ones stay chosen. + copyPickerKey(picker, {kind: 'text', value: '/'}); + for (const char of 'boom') copyPickerKey(picker, {kind: 'text', value: char}); + assert.deepEqual(copyPickerRows(picker).map(row => row.number), [4]); + copyPickerKey(picker, {kind: 'enter'}); + copyPickerKey(picker, {kind: 'text', value: ' '}); + copyPickerKey(picker, {kind: 'escape'}); + assert.equal(picker.query, '', 'Esc clears the search first'); + copyPickerKey(picker, {kind: 'text', value: 's'}); + const action = copyPickerKey(picker, {kind: 'enter'}); + assert.equal(action?.kind, 'copy'); + if (action?.kind !== 'copy') return; + assert.deepEqual(action.records.map(record => record.command), ['false-ish', 'true', 'echo five']); + assert.equal(action.includeStatus, true); + const all = createCopyPicker(transcript().recentShellCommands(), false); + copyPickerKey(all, {kind: 'text', value: 'a'}); + assert.equal(all.chosen.size, 5); + copyPickerKey(all, {kind: 'text', value: 'a'}); + assert.equal(all.chosen.size, 0, 'a again deselects all'); + assert.equal(copyPickerKey(all, {kind: 'escape'})?.kind, 'close'); +}); + +test('picker render: says whether statuses are included before Enter; fits narrow widths; previews sanitized output', () => { + const buffer = transcript(); + run(buffer, 'evil', 'safe\u001b]52;c;Zm9v\u0007 \u001b[31mred\r\n', 0, '✔ Completed · 1 ms · 10:07'); + const picker = createCopyPicker(buffer.recentShellCommands(), true); + for (const columns of [100, 40, 28]) { + const lines = renderCopyPicker(picker, columns, 24).map(stripAnsi); + assert.ok(lines.every(line => displayWidth(line) <= columns), `${columns} columns`); + assert.match(lines.join('\n'), /Completion status: (?:included|not included)|Completion status/u); + } + const text = renderCopyPicker(picker, 100, 30).join('\n'); + assert.match(stripAnsi(text), /Completion status: included after each output \(s\)/u); + assert.doesNotMatch(text, /\u001b\]52/u, 'program output never reaches the terminal as control sequences'); + picker.includeStatus = false; + assert.match(stripAnsi(renderCopyPicker(picker, 100, 30).join('\n')), /Completion status: not included/u); +}); + +test('settings: Quick Copy and status Off by default; normalized; exported and imported with the copy category', () => { + assert.deepEqual(DEFAULT_PROMPT_CONFIGURATION.copy, {mode: 'quick', includeStatus: false, autoExpand: false}); + assert.deepEqual(normalizePromptConfiguration({copy: {mode: 'picker', includeStatus: true}} as never).copy, {mode: 'picker', includeStatus: true, autoExpand: false}); + assert.deepEqual(normalizePromptConfiguration({copy: {mode: 'weird', includeStatus: 'yes'}} as never).copy, {mode: 'quick', includeStatus: false, autoExpand: false}); + assert.deepEqual(PORTABLE_CATEGORIES.copy, ['copy']); + const exported = exportSettings({...DEFAULT_PROMPT_CONFIGURATION, copy: {mode: 'picker', includeStatus: true, autoExpand: false}}, ['copy']); + assert.deepEqual(exported.categories.copy, {copy: {mode: 'picker', includeStatus: true, autoExpand: false}}); +}); diff --git a/tests/copySelectionLive.test.ts b/tests/copySelectionLive.test.ts new file mode 100644 index 00000000..740a3686 --- /dev/null +++ b/tests/copySelectionLive.test.ts @@ -0,0 +1,210 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.js'; + +/** + * Expanded /copy through the real composer, PTY and clipboard tool boundary: a stand-in pbcopy/xclip/wl-copy first on + * PATH records exactly what NMSh copies, so the person's clipboard is never touched. + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +function withClipboard(config: Record = {}, env: Record = {}, size = {cols: 100, rows: 30}) { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', ...config}, {}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.txt'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const launchEnv = {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {}), ...env}; + const launch = (args: string[] = []) => sandbox.launch(args, size, launchEnv); + const frontend = launch(); + const reset = () => writeFileSync(clip, ''); + const read = () => existsSync(clip) ? readFileSync(clip, 'utf8') : ''; + /** Type a command and return what reached the clipboard. */ + const copied = async (target: Frontend, command: string) => { + reset(); + target.pty.write(`${command}\r`); + await until(() => read().length > 0, 15_000, `the clipboard after ${command}`); + return read(); + }; + return {sandbox, frontend, launch, copied, reset, read}; +} + +async function ready(frontend: Frontend) { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); +} + +/** Four commands, oldest first: alpha, a failure, a silent one, omega. /copy 1 is omega. */ +async function history(frontend: Frontend) { + await frontend.run(`printf 'alpha\\n two\\n'`, /two/u); + await frontend.run(`sh -c 'echo boom >&2; exit 3'`, /boom/u); + await frontend.run('true', /Completed/u); + await frontend.run('echo omega', /omega/u); +} + +test('live: /copy and /cp select by number, count, range and list, oldest first; --status adds each command\'s own status', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend, copied, reset, read} = withClipboard(); + try { + await ready(frontend); + await history(frontend); + assert.equal(await copied(frontend, '/copy'), 'omega'); + assert.equal(await copied(frontend, '/copy -3'), 'boom\nomega', 'the silent command adds nothing'); + assert.equal(await copied(frontend, '/cp -3'), 'boom\nomega', '/cp is /copy'); + assert.equal(await copied(frontend, '/copy 2-4'), 'alpha\n two\nboom'); + assert.equal(await copied(frontend, '/cp 4,1'), 'alpha\n two\nomega', 'a list copies in the order the commands ran'); + assert.equal(await copied(frontend, '/cp latest'), 'omega'); + const withStatus = await copied(frontend, '/cp -3 --status'); + assert.match(withStatus, /^boom\n✘ Command failed · exit 3 · [^\n]+\n✔ Completed · [^\n]+\nomega\n✔ Completed · [^\n]+$/u, + `each status follows its own output; the silent one copies its status alone: ${JSON.stringify(withStatus)}`); + // A status-free copy never carries NMSh's rows, even though they are recorded. + assert.doesNotMatch(await copied(frontend, '/copy 1-4'), /Completed|failed/u); + // A missing output refuses the whole copy, and the clipboard keeps what it held. + reset(); + writeFileSync(join(sandbox.root, 'clipboard.txt'), 'KEEP'); + const mark = frontend.mark; + frontend.pty.write('/copy 1,9\r'); + await frontend.waitFor(/No completed command output at 9: there are only \d+ completed commands\. Nothing was copied\./u, mark); + frontend.pty.write('/copy 2\r'); + await frontend.waitFor(/Nothing to copy: true printed no output; the clipboard is unchanged/u, mark); + frontend.pty.write('/copy -3 --state\r'); + await frontend.waitFor(/Unknown option --state/u, mark); + assert.equal(read(), 'KEEP'); + // With the status asked for, the silent command's status alone is copied. + assert.match(await copied(frontend, '/copy 2 --status'), /^✔ Completed · /u); + // Feedback is a brief note above the composer, never a transcript line. + const before = frontend.mark; + await copied(frontend, '/copy -2'); + await frontend.waitFor(/Copied 2 outputs · \d+ characters · \d+ lines?/u, before); + const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + assert.equal(records.some(record => /Copied/u.test(record.output)), false, 'no copy note entered any command\'s output'); + } finally { await sandbox.dispose(); } +}); + +test('live: Interactive Picker mode: bare /copy opens the picker; it says whether statuses are included; explicit forms still copy at once', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend, copied} = withClipboard({copy: {mode: 'picker', includeStatus: false}}); + try { + await ready(frontend); + await history(frontend); + // Explicit forms behave the same in both modes. + assert.equal(await copied(frontend, '/copy 1'), 'omega'); + let mark = frontend.mark; + frontend.pty.write('/copy\r'); + await frontend.waitFor(/Copy output[\s\S]*Completion status: not included \(s\)[\s\S]*echo omega/u, mark); + // Choose omega and the failure (Space moves on after each choice), include statuses, copy. + frontend.pty.write(' '); + frontend.pty.write('\u001b[B'); + frontend.pty.write(' '); + frontend.pty.write('s'); + await frontend.waitFor(/Completion status: included after each output/u, mark); + const clip = await copied(frontend, ''); + assert.match(clip, /^boom\n✘ Command failed · exit 3[^\n]*\nomega\n✔ Completed/u, JSON.stringify(clip)); + // /copy ui opens the picker in Quick Copy mode too; search narrows it; Esc cancels without copying. + mark = frontend.mark; + frontend.pty.write('/cp ui\r'); + await frontend.waitFor(/Copy output/u, mark); + frontend.pty.write('/alpha'); + await frontend.waitFor(/Search: alpha[\s\S]*1 of \d+/u, mark); + frontend.pty.write('\u001b'); + frontend.pty.write('\u001b'); + frontend.pty.write('\u001b'); + await frontend.run('echo AFTER-PICKER', /AFTER-PICKER/u); + } finally { await sandbox.dispose(); } +}); + +test('live: /copy settings changes the default and the status setting; they persist to the next launch', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + // The next launch starts fresh instead of offering the killed window's detached session. + const {sandbox, frontend, launch, copied} = withClipboard({liveSessionStartup: 'never'}); + try { + await ready(frontend); + await frontend.run('echo PERSIST', /PERSIST/u); + const mark = frontend.mark; + frontend.pty.write('/copy settings\r'); + await frontend.waitFor(/Copy[\s\S]*\/copy without a number[\s\S]*Quick Copy[\s\S]*Include completion status +Off/u, mark); + frontend.pty.write('\u001b[B'); + frontend.pty.write(' '); + await until(() => { + try { return JSON.parse(readFileSync(join(sandbox.config, 'nmsh', 'config.json'), 'utf8')).copy?.includeStatus === true; } catch { return false; } + }, 10_000, 'the setting saved'); + frontend.pty.write('\u001b'); + assert.match(await copied(frontend, '/copy'), /^PERSIST\n✔ Completed · /u, 'the setting applies at once'); + assert.equal(await copied(frontend, '/copy --no-status'), 'PERSIST', '--no-status overrides it once'); + frontend.pty.kill('SIGKILL'); + const next = launch(); + await ready(next); + assert.match(await copied(next, '/copy'), /^READY\n✔ Completed · /u, 'persisted across launches'); + } finally { await sandbox.dispose(); } +}); + +test('live: after detach and reattach, /copy reaches the commands of the restored transcript', {skip: supported ? false : 'unsupported platform', timeout: 150_000}, async () => { + const {sandbox, frontend, launch, copied} = withClipboard(); + try { + await ready(frontend); + await history(frontend); + 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, 'detached'); + const back = launch(['--attach', session!.id]); + await back.waitFor(/omega/u, 0, 30_000); + await back.waitFor(/❯/u); + assert.equal(await copied(back, '/copy -3'), 'boom\nomega'); + assert.match(await copied(back, '/cp 1 --status'), /^omega\n✔ Completed · /u); + } finally { await sandbox.dispose(); } +}); + +test('live: narrow, NO_COLOR and Safe glyphs keep the picker and copy notes legible', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend, copied} = withClipboard({}, {NO_COLOR: '1', NMSH_ICONS: 'safe'}, {cols: 40, rows: 20}); + try { + await ready(frontend); + await history(frontend); + const mark = frontend.mark; + frontend.pty.write('/copy ui\r'); + await frontend.waitFor(/Copy output[\s\S]*Completion status: not/u, mark); + const painted = frontend.output.slice(mark); + assert.doesNotMatch(painted, /\u001b\[38;[25];/u, 'no color under NO_COLOR'); + assert.doesNotMatch(strip(painted), /›|✓|✔/u, 'Safe glyphs only'); + frontend.pty.write('\u001b'); + const after = frontend.mark; + await copied(frontend, '/copy -2'); + await frontend.waitFor(/\+ Copied 2 outputs/u, after); + } finally { await sandbox.dispose(); } +}); + +test('live: Copy as Report always reviews before copying; sensitive reports are redacted and Esc preserves the clipboard', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend, copied, reset, read} = withClipboard(); + try { + await ready(frontend); + await history(frontend); + // Every report is reviewed before it is copied: Enter in the review copies. + const reviewed = async (command: string) => { + const at = frontend.mark; + frontend.pty.write(`${command}\r`); + await frontend.waitFor(/Copy as report/u, at); + return copied(frontend, ''); + }; + const report = await reviewed('/cp -2 --report'); + assert.match(report, /^# Command report\n\n2 commands, oldest first\.\n\n## 1\. `true`[\s\S]*_No output\._[\s\S]*## 2\. `echo omega`\n\n- \*\*Status:\*\* `✔ Completed · [^`\n]+`\n- \*\*Exit code:\*\* 0[\s\S]*```text\nomega\n```\n$/u, report); + assert.match(await reviewed('/copy 3 --report=plain'), /^Command report · 1 command, oldest first\n\n=== 1\. \$ sh -c 'echo boom >&2; exit 3'\nStatus: ✘ Command failed · exit 3[\s\S]*Exit code: 3[\s\S]*--- output ---\nboom\n--- end ---/u); + // Something that looks like a secret: the review opens and says so; nothing is copied yet. + await frontend.run('echo GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', /ghp_/u); + reset(); + writeFileSync(join(sandbox.root, 'clipboard.txt'), 'KEEP'); + let mark = frontend.mark; + frontend.pty.write('/copy --report\r'); + await frontend.waitFor(/Copy as report[\s\S]*Looks sensitive: 2 tokens · redacted 2 tokens/u, mark); + frontend.pty.write('\u001b'); + await frontend.run('echo AFTER-ESC', /AFTER-ESC/u); + assert.equal(read(), 'KEEP', 'Esc copied nothing'); + mark = frontend.mark; + frontend.pty.write('/copy 2 --report\r'); + await frontend.waitFor(/Looks sensitive/u, mark); + const redacted = await copied(frontend, ''); + assert.match(redacted, /GITHUB_TOKEN=\[REDACTED\]/u); + assert.doesNotMatch(redacted, /ghp_abcdefghij/u); + await frontend.waitFor(/Copied report · 1 command/u, mark); + } finally { await sandbox.dispose(); } +}); 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..f1c2b8a5 --- /dev/null +++ b/tests/fixtures/earlyStartupSubmission.ts @@ -0,0 +1,22 @@ +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'); + const command = process.env.NMSH_TEST_EARLY_COMMAND ?? 'echo HELD-$((1+1))'; + assert.ok(['echo HELD-$((1+1))', 'less', 'vim'].includes(command), 'known fixture command only'); + this['editor'].insert(command); + await this['submit'](); + assert.equal(this['running']?.command, command); + assert.equal(this['running']?.awaitingExec, true, 'command is held before first shell prompt'); + assert.equal(this['passthrough'], false, 'early fullscreen command cannot take terminal ownership'); + process.stdout.write('TEST-EARLY-SUBMISSION\n'); + return run.call(this); +}; +if (process.env.NMSH_TEST_DELAY_EARLY_JOURNAL === '1') await import('./slowJournalAttach.js'); +else 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..95271346 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -42,7 +42,9 @@ export class LiveSandbox { mkdirSync(this.runtime, {mode: 0o700}); chmodSync(this.runtime, 0o700); writeFileSync(join(this.config, 'nmsh', 'config.json'), - JSON.stringify({onboardingComplete: true, glyphChoiceComplete: true, updateChecks: false, ...config})); + JSON.stringify({onboardingComplete: true, glyphChoiceComplete: true, updateChecks: false, ...config, + // Most live tests read the plain wording; a test that is about Expressive wording asks for it. + liveActivity: {style: 'classic', ...(config.liveActivity as Record | undefined)}})); } get env(): NodeJS.ProcessEnv { @@ -58,10 +60,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 +159,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 +177,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/historyReuseLive.test.ts b/tests/historyReuseLive.test.ts new file mode 100644 index 00000000..612f6ca7 --- /dev/null +++ b/tests/historyReuseLive.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('live: selected history keeps its command and cwd in pins and ephemeral clips without executing', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', picker: 'native'}); + try { + const frontend = sandbox.launch(); + await frontend.waitFor(/notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + const origin = join(sandbox.home, 'origin'); + mkdirSync(origin); + await frontend.run('cd origin', /Completed/u); + const original = `printf '%s\\n' PIN-HISTORY`; + await frontend.run(original, /PIN-HISTORY/u); + await frontend.run('cd ..', /Completed/u); + await frontend.run('echo LATEST-IS-DIFFERENT', /LATEST-IS-DIFFERENT/u); + let mark = frontend.mark; + frontend.pty.write('/history PIN-HISTORY'); + await frontend.waitFor(/exit 0 · \d+ms/u, mark); + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Pin selected history command/u, mark); + frontend.pty.write('pin selected\r'); + const pins = join(sandbox.config, 'nmsh', 'pins.json'); + await until(() => existsSync(pins), 15_000, 'explicit pin save'); + const data = JSON.parse(readFileSync(pins, 'utf8')); + assert.deepEqual(data.pins.map((pin: {command: string; cwd: string}) => [pin.command, pin.cwd]), [[original, origin]], + 'the selected history record supplies both command and its original cwd'); + + mark = frontend.mark; + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Keep selected history command as clip/u, mark); + frontend.pty.write('keep selected history\r'); + await frontend.waitFor(/Kept clip 1 in memory/u, mark); + frontend.pty.write('\u0015'); + await frontend.run('/clips', /Review clip 1/u); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Review paste/u, mark); + frontend.pty.write('\u001b[27u\u001b[27u'); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + assert.equal(records.filter(record => record.command === original).length, 1, 'pin, keep and review never resubmit'); + assert.equal(records.some(record => record.command.includes('pin selected') || record.command.includes('keep selected')), false); + assert.equal(existsSync(join(sandbox.config, 'nmsh', 'clips.json')), false, 'clips are not persisted'); + + const [session] = await sandbox.sessions(); + assert.ok(session); + frontend.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'detached source frontend'); + const back = sandbox.launch(['--attach', session.id]); + await back.waitFor(/LATEST-IS-DIFFERENT/u); + await back.run('/clips', /No clips kept/u); + assert.deepEqual(JSON.parse(readFileSync(pins, 'utf8')).pins.map((pin: {command: string}) => pin.command), [original], + 'only the explicitly saved pin survives the frontend'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/historySearch.test.ts b/tests/historySearch.test.ts index 37bcaf1d..4a735f70 100644 --- a/tests/historySearch.test.ts +++ b/tests/historySearch.test.ts @@ -10,7 +10,7 @@ test('/history parses to one history kind with or without a query', () => { assert.deepEqual(parseSlashCommand('/history nonexistent-query'), {kind: 'history', query: 'nonexistent-query'}); assert.equal(parseSlashCommand('/historyx')?.kind, 'unknown'); assert.deepEqual(parseSlashCommand('/help'), {kind: 'help'}); - assert.deepEqual(parseSlashCommand('/copy 2'), {kind: 'copy', index: 2}); + assert.deepEqual(parseSlashCommand('/copy 2'), {kind: 'copy', args: '2'}); assert.equal(parseSlashCommand('/resume')?.kind, 'resume'); }); 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/keys.test.ts b/tests/keys.test.ts index 130c6551..9ba840fc 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -2,6 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {KeyDecoder, decodeKeys} from '../src/terminal/keys.js'; +test('F8 and Shift+F8 navigate failures in legacy and Kitty keyboard forms', () => { + for (const sequence of ['\u001b[19~', '\u001b[19;1~', '\u001b[57371u']) + assert.deepEqual(decodeKeys(sequence), [{kind: 'previousFailure'}]); + for (const sequence of ['\u001b[19;2~', '\u001b[57371;2u']) + assert.deepEqual(decodeKeys(sequence), [{kind: 'nextFailure'}]); +}); + test('legacy raw Escape (0x1B) still decodes on non-Kitty terminals', () => { assert.deepEqual(decodeKeys('\u001B'), [{kind: 'escape'}]); }); 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/moduleDiagnosticsLive.test.ts b/tests/moduleDiagnosticsLive.test.ts new file mode 100644 index 00000000..724aa196 --- /dev/null +++ b/tests/moduleDiagnosticsLive.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +test('live: module diagnostics remain readable in a short NO_COLOR Safe terminal', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'nmsh', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 50, rows: 24}, {NO_COLOR: '1', NMSH_ICONS: 'safe'}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'the shell authenticated prompt'); + await frontend.run('echo READY', /READY/u); + await frontend.run('/modules', /Modules[\s\S]*Catalog[\s\S]*Packs/u); + const start = frontend.mark; + frontend.pty.write('\t/aws\r\r'); + await frontend.waitFor(/PgUp\/PgDn/u, start); + const labels = ['Reads', 'Source', 'Cache', 'Timing', 'Why']; + const seen = new Set(); + for (let step = 0; step < 70 && seen.size < labels.length; step++) { + const mark = frontend.mark; + // A real resize requests a complete repaint, so assertions inspect the + // displayed rows rather than concatenating incremental changed spans. + frontend.pty.resize(step % 2 ? 50 : 51, 24); + await until(() => frontend.output.length > mark, 3000, 'diagnostic repaint'); + const painted = frontend.output.slice(mark); + assert.doesNotMatch(painted, /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + for (const label of labels) if (strip(painted).includes(label)) seen.add(label); + frontend.pty.write('\u001b[B'); + } + assert.deepEqual([...seen].sort(), [...labels].sort(), 'every diagnostic section is reachable'); + frontend.pty.write('\u001b[5~'); + frontend.pty.write('\u001b[27u\u001b[27u\u001b[27u'); + await frontend.run('echo AFTER-DETAIL', /AFTER-DETAIL/u); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/moduleExplain.test.ts b/tests/moduleExplain.test.ts new file mode 100644 index 00000000..f4c9d4ca --- /dev/null +++ b/tests/moduleExplain.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {contextEngineCheck} from '../src/doctor/doctor.js'; +import {whyText} from '../src/prompt/ModulesPanel.js'; + +const module = (overrides: Record = {}) => ({id: 'x', visible: true, condition: 'always', ...overrides}) as Parameters[0]; + +test('"Why" says in one sentence why a module is or is not on screen', () => { + assert.match(whyText(module({visible: false}), 'contextRail', []), /turned off/u); + assert.match(whyText(module(), 'hidden', []), /routed to Hidden/u); + assert.match(whyText(module(), 'contextRail', [{state: 'timeout'}]), /timed out/u); + assert.match(whyText(module(), 'contextRail', [{state: 'failed', error: 'denied'}]), /could not be read/u); + assert.match(whyText(module(), 'contextRail', [{state: 'absent'}, {state: 'absent'}]), /nothing for it to show/u); + assert.match(whyText(module({condition: 'onCommand'}), 'contextRail', [{state: 'idle'}], ['go', 'gofmt']), /after a command that uses it \(go, gofmt…\)/u); + assert.match(whyText(module(), 'contextRail', [{state: 'pending'}]), /being collected/u); + assert.match(whyText(module(), 'contextRail', [{state: 'fresh'}]), /data is available/u); +}); + +test('module explanations do not invent collection or visibility evidence', () => { + assert.match(whyText(module(), 'contextRail', []), /not available/u); + assert.match(whyText(module(), 'contextRail', [{state: 'idle'}]), /not collected/u); + assert.match(whyText(module(), 'contextRail', [{state: 'stale'}]), /last known.*refresh waits for demand/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'stale'}]), /while refreshing/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'fresh'}]), /conditions are met/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'failed', error: 'EACCES /Users/demo/private/config token=do-not-display'}]), /private|token|Users/u); + assert.match(whyText(module(), 'contextRail', [{state: 'timeout', hasValue: true}]), /last known data.*retained/u); + assert.match(whyText(module(), 'contextRail', [{state: 'failed', hasValue: false}]), /no cached value/u); + assert.doesNotMatch(whyText(module({condition: 'onCommand'}), 'contextRail', [{state: 'idle'}]), /none has run/u); +}); + +test('/doctor reports prompt-data health from the engine counters, and only complains with enough evidence', () => { + const quiet = {started: 0, completed: 0, cancelled: 0, timedOut: 0, failed: 0, cacheHits: 0, coalesced: 0}; + assert.equal(contextEngineCheck(quiet).state, 'info'); + assert.equal(contextEngineCheck({...quiet, started: 30, completed: 29, timedOut: 1, cacheHits: 80}).state, 'ok'); + assert.equal(contextEngineCheck({...quiet, started: 4, completed: 1, timedOut: 3}).state, 'ok', 'a few samples prove nothing'); + const bad = contextEngineCheck({...quiet, started: 40, completed: 20, timedOut: 15, failed: 5}); + assert.equal(bad.state, 'attention'); + assert.match(bad.detail ?? '', /15 timed out · 5 failed/u); +}); diff --git a/tests/moduleManager.test.ts b/tests/moduleManager.test.ts index 4e8e73ed..95522db5 100644 --- a/tests/moduleManager.test.ts +++ b/tests/moduleManager.test.ts @@ -92,7 +92,7 @@ test('Packs tab: recommendations with evidence, bundled and installed packs, exp s.modulesTab = 'packs'; const shown = text(s); assert.match(shown, /Recommended here\n.*○ Terraform +main\.tf in this project/u); - assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 6 modules/u); + assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 8 modules/u); assert.match(shown, /Installed\n.*Acme infra +acme\.infra 1\.0\.0 · enabled · 1 modules/u); handlePromptPanelKey(key({kind: 'text', value: ' '}), s); assert.equal(s.draft.modules.find(module => module.id === 'nmsh.infrastructure:terraform')!.visible, true, 'only an explicit Space turns a recommendation on'); diff --git a/tests/moduleManagerUx.test.ts b/tests/moduleManagerUx.test.ts index 9639813e..2934850e 100644 --- a/tests/moduleManagerUx.test.ts +++ b/tests/moduleManagerUx.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; import {handlePromptPanelKey, promptPanelControls, promptPanelOwnsKey, renderPromptPanel, type PromptPanelState} from '../src/prompt/PromptPanel.js'; -import {catalogEntries, listedModules, modulesDirty, modulesItemCount, packEntries, type PackListing} from '../src/prompt/ModulesPanel.js'; +import {renderModulesManager, catalogEntries, listedModules, modulesDirty, modulesItemCount, packEntries, type PackListing} from '../src/prompt/ModulesPanel.js'; import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; import {paletteItems} from '../src/ui/CommandPalette.js'; import {firstPartyPacks} from '../src/context/modules.js'; @@ -160,6 +160,31 @@ test('at 50 columns details wrap instead of truncating, keep Reads/History/statu assert.match(listRows.join('\n'), /Modules {2}Catalog {2}Packs|Modules.*Catalog.*Packs/u, 'tab labels stay recognisable'); }); +test('module details show measured time and safe failure state without raw exception paths or values', () => { + const s = state(); + s.context = {status: () => ({state: 'failed', durationMs: 4.2, error: 'EACCES /Users/demo/private/config token=do-not-display', evidence: 'AWS config'})}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const shown = renderPromptPanel(s, 50, [], [], Infinity).map(stripAnsi).join('\n'); + assert.match(shown, /unavailable \(failed\).*took 4 ms/su); + assert.match(shown, /AWS config/u); + assert.doesNotMatch(shown, /Users|private|token=|do-not-display/u); + assert.match(shown, /cloud\.aws/u, 'the trusted capability is named'); + assert.match(shown, /freshness TTL/u); + assert.match(shown, /queue delay/u); +}); + +test('module details distinguish enabled settings from a condition that currently does not match', () => { + const s = state(); + s.context = {conditionMet: () => false, status: () => ({state: 'fresh', collectedAt: 1000}), now: 2000}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const shown = renderPromptPanel(s, 100, [], [], Infinity).map(stripAnsi).join('\n'); + assert.match(shown, /Enabled.*yes/u); + assert.match(shown, /current condition does not match/u); + assert.doesNotMatch(shown, /Why.*data is available/u); +}); + test('external prompt providers keep the manager useful: unavailable surfaces say why, settings are kept, the Status Strip still works', async () => { const {surfaceAvailability} = await import('../src/context/surfaceRouter.js'); const starship = {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider: 'starship' as const}; @@ -248,3 +273,32 @@ test('/prompt → Modules: A saves module settings only, leaving other draft edi rmSync(root, {recursive: true, force: true}); } }); + +test('short module details expose every diagnostic row through bounded scrolling without editing settings', () => { + const s = state(true); + s.context = {status: () => ({state: 'failed', durationMs: 4.2, hasValue: false, error: 'private-do-not-show'}), now: 4000}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const before = JSON.stringify(s.draft); + const full = renderModulesManager(s, 50).map(stripAnsi); + const first = renderModulesManager(s, 50, s.context, 12).map(stripAnsi); + assert.ok(first.length <= 12, 'details honor the actual panel budget'); + const seen = new Set(first); + for (let step = 0; step < full.length; step++) { + handlePromptPanelKey({kind: 'down'}, s); + for (const row of renderModulesManager(s, 50, s.context, 12).map(stripAnsi)) seen.add(row); + } + for (const row of full) assert.ok(seen.has(row), 'every source/cache/timing/why row is reachable: ' + row); + assert.match([...seen].join('\n'), /Source[\s\S]*Cache[\s\S]*Timing[\s\S]*Why/u); + assert.doesNotMatch([...seen].join('\n'), /private-do-not-show/u); + assert.equal(JSON.stringify(s.draft), before, 'reading does not edit or persist preferences'); + const bottom = renderModulesManager(s, 50, s.context, 12); + handlePromptPanelKey({kind: 'down'}, s); + assert.deepEqual(renderModulesManager(s, 50, s.context, 12), bottom, 'scroll stops at the final row'); + handlePromptPanelKey({kind: 'pageUp'}, s); + assert.notDeepEqual(renderModulesManager(s, 50, s.context, 12), bottom, 'PageUp reads earlier details'); + renderModulesManager(s, 120, s.context, 1000); + assert.equal(renderModulesManager(s, 50, s.context, 12).length, 12, 'resize clamps the viewport'); + handlePromptPanelKey(ESC, s); + assert.equal(s.detail, undefined, 'Esc returns to the same module selection'); +}); diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts index 440e7a3b..df48bbf9 100644 --- a/tests/muxInterop.test.ts +++ b/tests/muxInterop.test.ts @@ -182,7 +182,8 @@ test('a program that switches to the alternate screen gets the full terminal in await app.waitFor(/❯/); const mark = app.mark; app.pty.write(`printf '\\e[?1049h'; until [ "$(stty size)" = "30 100" ]; do sleep 0.05; done; echo FULL-SIZE; printf '\\e[?1049l'\r`); - await app.waitFor(/FULL-SIZE/, mark); + // The next command is typed once this one has completed: while it runs, Enter would queue it. + await app.waitFor(/FULL-SIZE[\s\S]*Completed/, mark); await app.run('echo BACK-AT-COMPOSER', /BACK-AT-COMPOSER/); } finally { await sandbox.dispose(); 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/outputReferences.test.ts b/tests/outputReferences.test.ts new file mode 100644 index 00000000..b87e9c89 --- /dev/null +++ b/tests/outputReferences.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {findOutputReferences, MAX_REFERENCES, referenceFollowUp} from '../src/output/references.js'; + +const refs = (command: string, output: string) => findOutputReferences({command, output}); +const labels = (command: string, output: string) => refs(command, output).map(reference => reference.label); + +test('source locations: relative, absolute, with columns; a bare word or a number is not a location', () => { + const found = refs('npm test', 'at src/main.ts:42:7\n /abs/path/file.py:9\nsee 3:45 and version 1.2:3 and foo\n'); + assert.deepEqual(found.map(item => [item.kind, item.value, item.line, item.column]), [['file', 'src/main.ts', 42, 7], ['file', '/abs/path/file.py', 9, undefined]]); +}); + +test('links: http(s) only, trailing punctuation trimmed, brackets kept only when balanced', () => { + assert.deepEqual(labels('curl x', 'see https://example.com/a?b=1. And (https://example.com/x) or https://en.wikipedia.org/wiki/Foo_(bar), ok'), + ['https://example.com/a?b=1', 'https://example.com/x', 'https://en.wikipedia.org/wiki/Foo_(bar)']); + assert.deepEqual(labels('x', 'ftp://example.com/a file:///etc/passwd javascript:alert(1) data:text/html,x'), []); +}); + +test('a link with credentials is a secret, not a destination', () => { + assert.deepEqual(labels('git clone x', 'https://user:token@github.com/a/b.git https://token@github.com/a/b.git'), []); +}); + +test('an international host is shown as the punycode it really is', () => { + const [link] = refs('x', 'https://аpple.com/login'); + assert.equal(link?.kind, 'url'); + assert.match(link!.value, /^https:\/\/xn--/u); +}); + +test('hostile text: controls, bidi and zero-width characters make a reference unusable, never repaired', () => { + assert.deepEqual(labels('x', 'https://exa​mple.com/a https://example.com/‮evil src/\u0007bell.ts:3 src/a‮.ts:3'), []); + // An escape sequence around a path is stripped as terminal styling; the path itself stays. + assert.deepEqual(labels('x', '\u001b[31msrc/main.ts:3\u001b[0m'), ['src/main.ts:3']); + assert.deepEqual(labels('x', 'src/main.ts:3\u001b]8;;https://evil.example\u0007 click\u001b]8;;\u0007'), ['src/main.ts:3']); +}); + +test('commits are only recognised in Git output, in the places Git prints them', () => { + const log = 'commit a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\nAuthor: x\n\n fixed 1234567 things\n* 9fceb02 subject\n[main 4f2a1c9] message\n'; + assert.deepEqual(labels('git log', log), ['a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', '9fceb02', '4f2a1c9']); + assert.deepEqual(labels('cat file', log), []); + assert.deepEqual(labels('FOO=1 git log --oneline', '1234567 all digits is not a commit\ndead6ee ok\n'), ['dead6ee']); +}); + +test('devices are only recognised in `adb devices`, and only a ready device has a follow-up', () => { + const output = 'List of devices attached\nR58M12ABC\tdevice\nemulator-5554\toffline\n192.168.1.5:5555\tdevice\n* daemon started successfully\n'; + const found = refs('adb devices', output); + assert.deepEqual(found.map(item => item.value), ['R58M12ABC', 'emulator-5554', '192.168.1.5:5555']); + assert.deepEqual(refs('adb devices -l', output).length, 3); + assert.deepEqual(refs('echo', output), []); + assert.deepEqual(referenceFollowUp(found[0]!), ['adb', '-s', 'R58M12ABC', 'shell']); + assert.equal(referenceFollowUp(found[1]!), undefined); +}); + +test('follow-ups are argv the person reviews: a hostile value never becomes one', () => { + assert.equal(referenceFollowUp({id: 'x', kind: 'commit', value: 'a1b2c3d; rm -rf ~', label: ''}), undefined); + assert.equal(referenceFollowUp({id: 'x', kind: 'device', value: 'x; reboot', label: '', state: 'device'}), undefined); + assert.equal(referenceFollowUp({id: 'x', kind: 'file', value: 'a.ts', label: '', line: 1}), undefined); + assert.deepEqual(referenceFollowUp({id: 'x', kind: 'commit', value: 'dead6ee', label: ''}), ['git', 'show', 'dead6ee']); +}); + +test('repeats are one reference; the list and the work are bounded for any output', () => { + assert.equal(refs('x', 'src/a.ts:1\nsrc/a.ts:1\nsrc/a.ts:2\n').length, 2); + const huge = Array.from({length: 5000}, (_, index) => `src/file${index}.ts:${index + 1}`).join('\n'); + assert.equal(refs('x', huge).length, MAX_REFERENCES); + const started = Date.now(); + refs('x', `${'a'.repeat(3_000_000)}\n${'https://example.com/'.repeat(2000)}\n`); + assert.ok(Date.now() - started < 3000); +}); diff --git a/tests/outputReferencesApp.test.ts b/tests/outputReferencesApp.test.ts new file mode 100644 index 00000000..ba6a47d1 --- /dev/null +++ b/tests/outputReferencesApp.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {blockPaletteItems} from '../src/ui/BlockActions.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + return app; +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +function run(output: OutputBuffer, command: string, text: string, exitCode: number) { + output.beginCommand(command, [command]); + output.write(text); + output.complete(exitCode); + return output.recent(1)!; +} + +test('the block menu offers references only when the output has some', () => { + const output = new OutputBuffer(); + const plain = run(output, 'echo hi', 'hi\n', 0); + assert.equal(blockPaletteItems(plain).some(item => item.action.kind === 'block' && item.action.id === 'references'), false); + const failing = run(output, 'npm test', 'FAIL src/main.ts:42:7\nsee https://example.com/doc\n', 1); + assert.equal(blockPaletteItems(failing).some(item => item.action.kind === 'block' && item.action.id === 'references'), true); +}); + +test('the references menu lists an action and Copy for each, and says so when there are none', async () => { + const instance = app(); + try { + const record = run(instance['output'], 'npm test', 'FAIL src/main.ts:42:7\nsee https://example.com/doc\n', 1); + await instance['runBlockAction'](record.startId, 'references'); + const items = instance['paletteState']!.items; + assert.deepEqual(items.map(item => item.label), ['Open src/main.ts:42:7', 'Copy src/main.ts:42:7', 'Open https://example.com/doc', 'Copy https://example.com/doc']); + instance['paletteState'] = undefined; + const quiet = run(instance['output'], 'echo hi', 'hi\n', 0); + await instance['runBlockAction'](quiet.startId, 'references'); + assert.equal(instance['paletteState'], undefined); + } finally { dispose(instance); } +}); + +test('Inspect commit stages a command for review: it never runs, and never replaces what is typed', async () => { + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push(instance['editor'].text); }}); + const record = run(instance['output'], 'git log --oneline', '* dead6ee subject\n', 0); + const [reference] = (await import('../src/output/references.js')).findOutputReferences(record); + instance['editor'].insert('ls -la'); + await instance['runReference'](record.startId, reference!.id, 'stage'); + assert.equal(instance['editor'].text, 'ls -la', 'typed text is untouched'); + instance['editor'].clear(); + await instance['runReference'](record.startId, reference!.id, 'stage'); + assert.equal(instance['editor'].text, 'git show dead6ee'); + assert.deepEqual(submitted, [], 'staging never submits'); + } finally { dispose(instance); } +}); + +test('an action on a reference that is gone, or a hostile one, does nothing', async () => { + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + const record = run(instance['output'], 'git log', 'commit dead6ee; rm -rf ~\n', 0); + await instance['runReference'](record.startId, 'commit:dead6ee; rm -rf ~', 'stage'); + await instance['runReference'](999_999, 'commit:dead6ee', 'stage'); + assert.equal(instance['editor'].text, ''); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('failure navigation walks failed blocks by command record: nonzero exits and interrupts count, error text does not', () => { + const instance = app(); + try { + const output = instance['output']; + const first = run(output, 'make a', 'error: looked bad but exited 0\n', 0); + const older = run(output, 'make b', 'boom\n', 2); + run(output, 'make c', 'fine\n', 0); + const newest = run(output, 'sleep 99', '', 130); + assert.deepEqual(instance['failedBlockIndexes'](), [0, 2]); + instance['goToFailure']('previous'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, newest.startId); + instance['goToFailure']('previous'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, older.startId); + const before = instance['focusedCommandIndex']; + instance['goToFailure']('previous'); + assert.equal(instance['focusedCommandIndex'], before, 'no older failure: focus stays'); + instance['goToFailure']('next'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, newest.startId); + assert.notEqual(first.startId, newest.startId); + } finally { dispose(instance); } +}); + +test('the palette offers failure navigation only when there is a failure', () => { + const instance = app(); + try { + run(instance['output'], 'echo ok', 'ok\n', 0); + instance['openPalette'](); + assert.equal(instance['paletteState']!.items.some(item => item.action.kind === 'failure'), false); + instance['paletteState'] = undefined; + run(instance['output'], 'false', '', 1); + instance['openPalette'](); + assert.equal(instance['paletteState']!.items.filter(item => item.action.kind === 'failure').length, 2); + } finally { dispose(instance); } +}); + +test('failure keys preserve the composer and are inactive while a program owns input', () => { + const instance = app(); + try { + run(instance['output'], 'false', '', 1); + instance['editor'].insert('echo draft'); + instance['handleKey']({kind: 'previousFailure'}); + assert.equal(instance['focusedCommandIndex'], 0); + assert.equal(instance['editor'].text, 'echo draft'); + instance['focusedCommandIndex'] = undefined; + instance['onShellExec']('sleep 30'); + instance['handleKey']({kind: 'previousFailure'}); + assert.equal(instance['focusedCommandIndex'], undefined); + } finally { dispose(instance); } +}); diff --git a/tests/outputReferencesLive.test.ts b/tests/outputReferencesLive.test.ts new file mode 100644 index 00000000..5fb30913 --- /dev/null +++ b/tests/outputReferencesLive.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until, type Frontend} from './helpers/liveFrontend.js'; + +test('live: failure keys and reference copies use restored command records and preserve a narrow composer draft', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + try { + const bin = join(sandbox.root, 'fake-bin'); + const clipboard = join(sandbox.root, 'clipboard'); + mkdirSync(bin); + writeFileSync(clipboard, 'UNCHANGED'); + for (const name of ['pbcopy', 'wl-copy', 'xclip']) { + const path = join(bin, name); + // Publish complete clipboard bytes in one rename; truncation is not completion. + writeFileSync(path, `#!/bin/sh\ntemporary="${clipboard}.pending.$$"\ntrap 'rm -f "$temporary"' EXIT\ncat > "$temporary" && mv "$temporary" "${clipboard}"\n`); + chmodSync(path, 0o755); + } + const env = {PATH: `${bin}:${process.env.PATH}`, NO_COLOR: '1', NMSH_ICONS: 'safe', + ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}; + const first = sandbox.launch([], {cols: 100, rows: 30}, env); + await first.waitFor(/notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + await first.run(`printf 'OLDER-%s\\n' FAILURE; false`, /OLDER-FAILURE/u); + await first.run(`printf 'error:%s\\n' HARMLESS`, /error:HARMLESS/u); + await first.run(`printf 'src/%s.ts:42:7\\nhttps://example.com/%s\\n' latest docs; false`, /src\/latest.ts:42:7/u); + await first.run(`printf 'LAST-%s\\n' SUCCESS`, /LAST-SUCCESS/u); + const [session] = await sandbox.sessions(); + assert.ok(session); + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'detached shell'); + const back = sandbox.launch(['--attach', session.id], {cols: 50, rows: 24}, env); + await back.waitFor(/LAST-SUCCESS/u); + assert.equal(readFileSync(clipboard, 'utf8'), 'UNCHANGED', 'restoring output does not copy anything'); + back.pty.write('echo DRAFT-UNCHANGED'); + + async function palette(target: Frontend, query: string, expect: RegExp) { + const mark = target.mark; + target.pty.write('\u001bOP'); + await target.waitFor(/Command palette/u, mark); + target.pty.write(query); + await target.waitFor(expect, mark); + target.pty.write('\r'); + } + async function copyOutput(key: string, expected: string) { + writeFileSync(clipboard, 'UNCHANGED'); + back.pty.write(key); + await palette(back, 'copy output', /Copy output/u); + await until(() => readFileSync(clipboard, 'utf8') !== 'UNCHANGED', 15_000, 'selected failure clipboard bytes'); + assert.equal(readFileSync(clipboard, 'utf8'), expected); + } + // Successful output containing "error" is skipped. Navigation chooses records, not matching output rows. + await copyOutput('\u001b[19~', 'src/latest.ts:42:7\nhttps://example.com/docs'); + await copyOutput('\u001b[19~', 'OLDER-FAILURE'); + await copyOutput('\u001b[19;2~', 'src/latest.ts:42:7\nhttps://example.com/docs'); + + await palette(back, 'files links', /Files, links and more/u); + const mark = back.mark; + back.pty.write('copy src'); + await back.waitFor(/Copy src\/latest.ts:42:7/u, mark); + writeFileSync(clipboard, 'UNCHANGED'); + back.pty.write('\r'); + await until(() => readFileSync(clipboard, 'utf8') !== 'UNCHANGED', 15_000, 'explicit file reference copy'); + assert.equal(readFileSync(clipboard, 'utf8'), 'src/latest.ts:42:7'); + assert.doesNotMatch(back.output, /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + + // Enter belongs to the focused historical block until Esc returns to the composer. + // Assert that opening its menu cannot execute the preserved draft. + const focusMark = back.mark; + back.pty.write('\r'); + await back.waitFor(/Command palette/u, focusMark); + assert.equal((await sandbox.transcripts().list()).flatMap(item => item.transcript.records) + .some(record => record.command === 'echo DRAFT-UNCHANGED'), false); + back.pty.write('\u001b[27u'); // close the block menu + back.pty.write('\u001b[27u'); // return focus to the composer + back.pty.write('\r'); + await until(async () => (await sandbox.transcripts().list()).some(item => + item.transcript.records.some(record => record.command === 'echo DRAFT-UNCHANGED')), + 15_000, 'the original composer draft executes only on its explicit Enter'); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + assert.equal(records.filter(record => record.command === 'echo DRAFT-UNCHANGED').length, 1); + assert.equal(records.some(record => record.command.includes('copy output') || record.command === 'copy src'), false, + 'action searches never became shell commands'); + assert.equal(existsSync(join(sandbox.config, 'nmsh', 'pins.json')), false, 'navigation never saved commands'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/pasteBatch.test.ts b/tests/pasteBatch.test.ts new file mode 100644 index 00000000..7530a346 --- /dev/null +++ b/tests/pasteBatch.test.ts @@ -0,0 +1,258 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import {MAX_BATCH_CHARACTERS, splitBatch, type BatchShell} from '../src/input/pasteBatch.js'; +import {shellSubmission} from '../src/shell/submission.js'; +import {mkdtemp, access, rm} from 'node:fs/promises'; +import {join} from 'node:path'; + +const texts = (input: string, shell: BatchShell) => { + const result = splitBatch(input, shell); + assert.ok(result.ok, result.ok ? '' : result.reason); + return result.ok ? result.commands.map(command => command.text) : []; +}; +const refuses = (input: string, shell: BatchShell) => { const result = splitBatch(input, shell); assert.equal(result.ok, false, JSON.stringify(result)); return result.ok ? '' : result.reason; }; + +const POSIX: BatchShell[] = ['zsh', 'bash']; + +test('independent lines are separate commands, in order; blank lines vanish between them', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('cd ~/work\n\nls -la\ngit status\n', shell), ['cd ~/work', 'ls -la', 'git status']); + assert.deepEqual(texts(' echo "a b" \r\necho c', shell), ['echo "a b"', 'echo c']); + } + const result = splitBatch('a\n\nb\nc', 'bash'); + assert.deepEqual(result.ok && result.commands.map(command => [command.firstLine, command.lastLine]), [[1, 1], [3, 3], [4, 4]]); +}); + +test('chains and pipelines on one line stay one command: splitting them would change what runs', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('make && make install\necho a; echo b\nls | wc -l', shell), ['make && make install', 'echo a; echo b', 'ls | wc -l']); + } +}); + +test('a trailing backslash, && , || or | continues the command onto the next line', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('docker run \\\n -it \\\n alpine\nls', shell), ['docker run \\\n -it \\\n alpine', 'ls']); + assert.deepEqual(texts('make &&\n make test ||\n echo failed\nls |\n wc -l', shell), ['make &&\n make test ||\n echo failed', 'ls |\n wc -l']); + } +}); + +test('quotes, including multiline strings, hold their newlines', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('echo "line one\nline two"\necho \'a\nb\'\necho x', shell), ['echo "line one\nline two"', 'echo \'a\nb\'', 'echo x']); + assert.deepEqual(texts('echo "it\'s ok"\necho \'say "hi"\'', shell), ['echo "it\'s ok"', 'echo \'say "hi"\'']); + assert.deepEqual(texts('echo "a \\" b"\necho c', shell), ['echo "a \\" b"', 'echo c']); + } + for (const shell of POSIX) assert.deepEqual(texts("echo $'it\\'s\nnew'\necho z", shell), ["echo $'it\\'s\nnew'", 'echo z']); + assert.deepEqual(texts("echo 'it\\'s'\necho z", 'fish'), ["echo 'it\\'s'", 'echo z']); +}); + +test('command substitutions and groups hold their newlines', () => { + for (const shell of POSIX) { + assert.deepEqual(texts('x=$(\n ls\n pwd\n)\necho "$x"\n(\n cd /tmp\n ls\n)\n{ echo a\n echo b\n}', shell), ['x=$(\n ls\n pwd\n)', 'echo "$x"', '(\n cd /tmp\n ls\n)', '{ echo a\n echo b\n}']); + assert.deepEqual(texts('echo `date\n`\necho ${HOME:-\n}', shell).length, 2); + } + assert.deepEqual(texts('set x (\n ls\n)\necho $x', 'fish'), ['set x (\n ls\n)', 'echo $x']); +}); + +test('heredocs keep their bodies, including lines that look like commands', () => { + for (const shell of POSIX) { + assert.deepEqual(texts('cat < f\n$HOME\nEOF\ncat <<-END\n\tbody\n\tEND\npwd", shell), ["cat <<'EOF' > f\n$HOME\nEOF", 'cat <<-END\n\tbody\n\tEND', 'pwd']); + assert.deepEqual(texts('cat < { + const block = 'if [ -f x ]; then\n echo yes\nelse\n echo no\nfi'; + const loop = 'for f in *.txt; do\n echo "$f"\ndone'; + const cases = 'case "$1" in\n a) echo A ;;\n b|c) echo BC ;;\nesac'; + const fn = 'greet() {\n echo hi\n}'; + const fn2 = 'function bye {\n echo bye\n}'; + const loop2 = 'while read line; do\n echo "$line"\ndone < file'; + const test2 = '[[ -n $x && ( $y == z || $y == w ) ]]'; + for (const shell of POSIX) { + assert.deepEqual(texts([block, loop, cases, fn, fn2, loop2, test2, 'echo done'].join('\n'), shell), [block, loop, cases, fn, fn2, loop2, test2, 'echo done']); + } + const fishBlock = 'if test -f x\n echo yes\nelse if test -d x\n echo dir\nelse\n echo no\nend'; + const fishFor = 'for f in *.txt\n echo $f\nend'; + const fishFn = 'function greet\n echo hi\nend'; + const fishSwitch = 'switch $x\n case a\n echo A\n case b c\n echo BC\nend'; + const fishBegin = 'begin\n echo a\n echo b\nend | cat'; + assert.deepEqual(texts([fishBlock, fishFor, fishFn, fishSwitch, fishBegin, 'echo done'].join('\n'), 'fish'), [fishBlock, fishFor, fishFn, fishSwitch, fishBegin, 'echo done']); + assert.deepEqual(texts('test -f x\nand echo yes\nor echo no\nls', 'fish'), ['test -f x\nand echo yes\nor echo no', 'ls']); +}); + +test('comments stay with the command they precede; trailing ones with the command above', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('# install\nnpm i\n# build\nnpm run build # inline\n# done', shell), ['# install\nnpm i', '# build\nnpm run build # inline\n# done']); + } + for (const shell of ['bash', 'fish'] as const) assert.deepEqual(texts('echo a # it\'s fine\necho b', shell), ['echo a # it\'s fine', 'echo b']); +}); + +test('zsh does not treat # as a comment unless told to, so a comment with syntax in it is refused, never shown as a comment', () => { + // Interactive zsh runs everything after the # as ordinary shell text: `; echo x` and `$( )` execute. + for (const hidden of ['# note; echo EXECUTED', '# note $(touch /tmp/x)', 'ls # `id`', '# a && b', '# it\'s', '# a > b', '# \\']) { + assert.match(refuses(`${hidden}\nls`, 'zsh'), /comment contains shell syntax/u, hidden); + } + // Bash and Fish comments are comments. + assert.deepEqual(texts('# note; echo not-run\nls', 'bash'), ['# note; echo not-run\nls']); + assert.deepEqual(texts('# note; echo not-run\nls', 'fish'), ['# note; echo not-run\nls']); +}); + +test('comments inside substitutions and expansion in heredoc markers are refused; marker quotes are removed like the shell does', () => { + for (const shell of POSIX) { + assert.match(refuses('x=$(echo hi # )\nrm -rf ~\n)\nls', shell), /comment inside a substitution/u); + assert.match(refuses('x=$(echo hi\n# note\n)\nls', shell), /comment inside a substitution/u); + assert.deepEqual(texts('echo $(( 1 + 2 ))\nx=$(echo "a#b")\necho ${#x} $#\nls', shell), ['echo $(( 1 + 2 ))', 'x=$(echo "a#b")', 'echo ${#x} $#', 'ls']); + assert.deepEqual(texts('cat < { + for (const shell of POSIX) { + assert.deepEqual(texts('(( x = 1 << 2 ))\necho $((1<<3))\nls', shell), ['(( x = 1 << 2 ))', 'echo $((1<<3))', 'ls']); + assert.deepEqual(texts('echo $(( (1 << 2) + 1 ))\nls', shell), ['echo $(( (1 << 2) + 1 ))', 'ls']); + assert.match(refuses('x=$(cat < { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.match(refuses('echo "never closed\nls', shell), /double quote/u); + assert.match(refuses("echo 'never closed\nls", shell), /single quote/u); + assert.match(refuses('echo $(ls\nls', shell), /parenthesis/u); + assert.match(refuses('make &&', shell), /middle of a command/u); + assert.match(refuses('echo ok\n)', shell), /parenthesis/u); + } + for (const shell of POSIX) { + assert.match(refuses('fi\nls', shell), /fi/u); + assert.match(refuses('if true; then\n echo hi', shell), /never closed/u); + assert.match(refuses('cat < { + assert.match(refuses(`${'$('.repeat(5000)}${')'.repeat(5000)}`, 'bash'), /deeply/u); + assert.match(refuses(`${'('.repeat(5000)}`, 'bash'), /deeply/u); + assert.match(refuses(`${'if true; then\n'.repeat(1000)}`, 'bash'), /deeply|never closed/u); + const started = Date.now(); + const many = Array.from({length: 40_000}, (_, index) => `echo line ${index}`).join('\n'); + assert.equal(texts(many, 'zsh').length, 40_000); + assert.ok(Date.now() - started < 3000, `${Date.now() - started} ms`); + assert.equal(texts(`${'\n'.repeat(200_000)}echo x${'\n'.repeat(200_000)}`, 'bash').length, 1); +}); + +test('the commands are the paste, in order: joined back they hold every non-blank source character', () => { + const source = '# setup\ncd /tmp && ls\ncat < { + const args = shell === 'fish' ? ['--no-config', '--no-execute', '-c', code] : ['-n']; + const result = spawnSync(shell, args, {input: shell === 'fish' ? undefined : code, encoding: 'utf8', timeout: 5000}); + if (result.status !== 0) console.error(`${shell} parse status ${result.status}: ${result.error?.message ?? result.stderr}`); + return result.error ? undefined : result.status === 0; +}; +const FIXTURES: Record = { + bash: 'cd /tmp\nif [ -f x ]; then\n echo yes\nelse\n echo no\nfi\nfor f in a b; do\n echo "$f"\ndone\ncat < { + assert.equal(parses(shell, FIXTURES[shell]), true, 'the fixture is valid shell'); + const commands = texts(FIXTURES[shell], shell); + assert.ok(commands.length >= 8, String(commands.length)); + for (const command of commands) assert.equal(parses(shell, command), true, `${command}`); + }); +} + +/** + * The differential check: run a paste whole, and run the commands the splitter made of it one after another, each + * submitted the way the queue does (one native shell group). Both must do exactly the same thing. + * Only echo, printf, variables and control flow run here; nothing touches the disk or the network. + */ +const PIECES: Record = { + bash: ['cat < string[]> = { + bash: script => ['bash', '--noprofile', '--norc', '-c', script], + zsh: script => ['zsh', '-f', '-c', script], + fish: script => ['fish', '--no-config', '-c', script], +}; +const execute = (shell: BatchShell, script: string) => { + const [command, ...args] = RUN[shell](script); + const result = spawnSync(command!, args, {encoding: 'utf8', timeout: 8000, cwd: tmpdir(), env: {PATH: process.env.PATH ?? '', HOME: tmpdir()}}); + return `${result.stdout}\n--stderr--\n${result.stderr.replace(/line \d+|-c: line \d+|:\d+:/gu, '')}\n--status ${result.status}`; +}; +for (const shell of ['bash', 'zsh', 'fish'] as const) { + test(`${shell}: running a paste whole and running its split commands one after another do exactly the same`, {skip: parses(shell, 'true') === undefined ? `${shell} is not installed` : false, timeout: 120_000}, () => { + let seed = shell.length * 7919; + const random = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; + let compared = 0; + const rounds = Number(process.env.NMSH_FUZZ_ROUNDS ?? 40); + for (let round = 0; round < rounds; round += 1) { + const count = 2 + Math.floor(random() * 5); + const pieces = Array.from({length: count}, () => PIECES[shell][Math.floor(random() * PIECES[shell].length)]!); + const paste = pieces.join('\n'); + const result = splitBatch(paste, shell); + if (!result.ok) continue; + compared += 1; + const whole = execute(shell, paste); + const parts = execute(shell, result.commands.map(command => shellSubmission(command.text, shell)).join('\n')); + assert.equal(parts, whole, `${shell}: ${JSON.stringify(paste)} split into ${JSON.stringify(result.commands.map(command => command.text))}`); + } + assert.ok(compared >= Math.floor(rounds * 0.6), `only ${compared} comparable pastes`); + }); +} + +test('the real shell checks every command on its own before a split is shown; a boundary through a construct is refused', async () => { + const {verifyCommands} = await import('../src/input/pasteBatchCheck.js'); + for (const shell of ['bash', 'zsh', 'fish'] as const) { + if (parses(shell, 'true') === undefined) continue; + assert.deepEqual(await verifyCommands(texts(FIXTURES[shell], shell), shell), {ok: true}); + // A command cut in the middle is not whole syntax, however the cut came about. + const fragments = shell === 'fish' ? ['echo "never closed', 'if true\n echo hi'] : ['x=$(echo hi # )', 'echo "never closed', 'if true; then\n echo hi', 'cat < { + const {verifyCommands} = await import('../src/input/pasteBatchCheck.js'); + const directory = await mkdtemp(join(tmpdir(), 'nmsh-maple-parse-only-')); + const marker = join(directory, 'must-not-exist'); + try { + for (const shell of ['bash', 'zsh', 'fish'] as const) { + if (parses(shell, 'true') === undefined) continue; + const substitution = shell === 'fish' ? `(touch '${marker}')` : `$(touch '${marker}')`; + assert.deepEqual(await verifyCommands([`echo ${substitution}\ntouch '${marker}'`], shell), {ok: true}); + await assert.rejects(access(marker), {code: 'ENOENT'}, shell); + } + } finally { await rm(directory, {recursive: true, force: true}); } +}); diff --git a/tests/pasteBatchApp.test.ts b/tests/pasteBatchApp.test.ts new file mode 100644 index 00000000..951965b5 --- /dev/null +++ b/tests/pasteBatchApp.test.ts @@ -0,0 +1,264 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {batchReviewKey, createBatchReview, renderBatchReview} from '../src/input/BatchReview.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const SEQUENCE = 'cd /tmp\nrm -rf build\nfor f in a b; do\n echo "$f"\ndone\ncat < ({kind: 'text', value}); + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + // These tests inspect the accepted queue, not execution. Keep it paused even + // if shell startup finishes while the asynchronous syntax check is pending. + app['session'].queue({op: 'pause'}); + return app; +} +/** B verifies every command with the real shell before the review appears. */ +async function reviewing(instance: TerminalApp): Promise { + const deadline = Date.now() + 8000; + while (!instance['batchReview'] && Date.now() < deadline) await new Promise(done => setTimeout(done, 10)); +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +const queued = (instance: TerminalApp) => instance['queueState'].entries.map(entry => entry.text); + +test('/ps is an NMSh command that reads the clipboard once, on request', () => { + assert.deepEqual(parseSlashCommand('/ps'), {kind: 'pasteClipboard'}); + assert.notEqual(parseSlashCommand('/ps extra')?.kind, 'pasteClipboard'); + assert.equal(parseSlashCommand('ps aux'), undefined); +}); + +test('a reviewed batch lists the commands the shell would run; nothing is queued by pasting or reviewing', () => { + const review = createBatchReview(SEQUENCE, 'bash', 100)!; + assert.deepEqual(review.entries.map(entry => entry.text), ['cd /tmp', 'rm -rf build', 'for f in a b; do\n echo "$f"\ndone', 'cat < { + const review = createBatchReview(SEQUENCE, 'bash', 100)!; + const key = (value: Key) => batchReviewKey(review, value, 26); + key({kind: 'down'}); + assert.equal(key(text('x')), undefined); + assert.deepEqual(review.entries.map(entry => entry.text).slice(0, 2), ['cd /tmp', 'for f in a b; do\n echo "$f"\ndone']); + key(text('j')); + assert.equal(review.cursor, 2); + key(text('k')); + key(text('k')); + assert.equal(review.entries[0]!.text.startsWith('for f'), true); + assert.equal(key(text('t'))?.kind, 'text'); + assert.equal(key(text('e'))?.kind, 'edit'); + assert.equal(key({kind: 'escape'})?.kind, 'back'); + assert.equal(key({kind: 'interrupt'})?.kind, 'cancel'); + assert.equal(key({kind: 'enter'})?.kind, 'queue'); + review.capacity = 2; + assert.equal(key({kind: 'enter'}), undefined, 'more commands than the queue has room for: no partial queueing'); +}); + +test('the review fits narrow windows and shows every selected command in full, safely', () => { + const review = createBatchReview('echo ok\nprintf "a\\033[31mred"\n\u001b[2Jclear', 'bash', 100)!; + for (const columns of [30, 50, 80, 120]) { + const rows = renderBatchReview(review, columns, 20); + assert.ok(rows.length <= 20); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.ok(!renderBatchReview(review, 80, 20).join('\n').includes('\u001b[2J')); +}); + +test('pasting a multi-command block offers B; Enter on the review queues exactly the reviewed commands, in order, once', async () => { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: SEQUENCE}); + assert.equal(instance['pastePreview']?.batch, true); + assert.deepEqual(queued(instance), [], 'pasting alone queues nothing'); + instance['handleKey'](text('b')); + await reviewing(instance); + assert.ok(instance['batchReview']); + assert.deepEqual(queued(instance), [], 'reviewing alone queues nothing'); + instance['handleKey'](text('x')); + instance['handleKey']({kind: 'enter'}); + assert.equal(instance['batchReview'], undefined); + assert.equal(instance['pastePreview'], undefined); + assert.deepEqual(queued(instance), ['rm -rf build', 'for f in a b; do\n echo "$f"\ndone', 'cat < { + for (const choice of ['t', 'e'] as const) { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'ls\npwd'}); + instance['handleKey'](text('b')); + await reviewing(instance); + instance['handleKey'](text(choice)); + assert.deepEqual(queued(instance), []); + assert.equal(instance['editor'].text.includes('ls'), true); + assert.equal(instance['batchReview'], undefined); + } finally { dispose(instance); } + } + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'ls\npwd'}); + instance['handleKey'](text('b')); + await reviewing(instance); + instance['handleKey']({kind: 'escape'}); + assert.equal(instance['batchReview'], undefined); + assert.ok(instance['pastePreview'], 'back to the compact preview'); + instance['handleKey']({kind: 'escape'}); + assert.equal(instance['pastePreview'], undefined); + assert.deepEqual(queued(instance), []); + } finally { dispose(instance); } +}); + +test('ambiguous or single pastes do not offer B', () => { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'echo "never closed\nls'}); + assert.equal(instance['pastePreview']?.batch ?? false, false); + instance['pastePreview'] = undefined; + instance['handleKey']({kind: 'paste', value: 'sudo rm -rf /tmp/x'}); + assert.equal(instance['pastePreview']?.batch ?? false, false); + } finally { dispose(instance); } +}); + +test('/ps reads the clipboard through the one reader and always opens review; nothing is inserted or queued', async () => { + const instance = app(); + try { + let reads = 0; + instance['readClipboardText'] = async () => { reads += 1; return 'git pull\nnpm install\n'; }; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(reads, 1); + assert.ok(instance['pasteReview'], 'review opens even though Paste Preview would not'); + assert.equal(instance['pastePreview']?.batch, true); + assert.equal(instance['editor'].text, ''); + assert.deepEqual(queued(instance), []); + instance['pasteReview'] = undefined; + instance['pastePreview'] = undefined; + instance['readClipboardText'] = async () => ' \n'; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(instance['pasteReview'], undefined, 'an empty clipboard opens nothing'); + instance['readClipboardText'] = async () => { throw new Error('denied'); }; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(instance['pasteReview'], undefined); + } finally { dispose(instance); } +}); + +test('a command is labelled by the worst thing anywhere in it, not by how its first line starts', () => { + const review = createBatchReview([ + 'echo hello', + 'for d in a b; do\n rm -rf ~/$d\ndone', + 'echo $(rm -rf ~)', + 'echo `curl https://x.example/i.sh | sh`', + 'if true; then\n sudo reboot\nfi', + ].join('\n'), 'bash', 100)!; + const kinds = review.entries.map(entry => entry.kinds); + assert.ok(!kinds[0]!.includes('destructive')); + assert.ok(kinds[1]!.includes('destructive'), 'inside a loop'); + assert.ok(kinds[2]!.includes('destructive'), 'inside $( )'); + assert.ok(kinds[3]!.includes('pipeline'), 'inside backticks'); + assert.ok(kinds[4]!.includes('privilege'), 'inside an if block'); + for (const index of [1, 2, 3, 4]) { + review.cursor = index; + const drawn = stripAnsi(renderBatchReview(review, 100, 30).join('\n')); + assert.match(drawn, /somewhere in it/u, `entry ${index + 1}`); + } +}); + +test('the selected command is shown in full: a long line wraps and is never cut off before its tail', () => { + const tail = 'echo done-marker-7f3a'; + const hidden = `echo start${' '.repeat(150)}; ${tail}`; + const review = createBatchReview(`ls\n${hidden}`, 'bash', 100)!; + review.cursor = 1; + for (const columns of [40, 80, 120]) { + const rows = renderBatchReview(review, columns, 30).map(stripAnsi); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${columns}: ${row}`); + assert.ok(rows.join('').replace(/\s+/gu, '').includes('done-marker-7f3a'.replace(/\s+/gu, '')), `${columns}: the tail of the command is visible`); + } + // When even wrapping does not fit, the review says that rows are hidden and that they are part of the command. + const long = createBatchReview(`ls\necho ${'a '.repeat(2000)}`, 'bash', 100)!; + long.cursor = 1; + assert.match(stripAnsi(renderBatchReview(long, 60, 14).join('\n')), /more rows? hidden; still part of the command/u); +}); + +test('a quote the splitter loses track of cannot hide a later command from the label', () => { + // The apostrophe in the heredoc body opens a "string" for a naive splitter that runs to the end of the command. + const review = createBatchReview(['echo start', "for x in a; do\n cat < { + const instance = app(); + try { + const content = "printf '%s\\n' '$(kept as data)'\necho next"; + Object.defineProperty(instance, 'readClipboardText', {value: async () => content}); + await instance['pasteClipboard']('/ps'); + assert.equal(instance['clips'].list().length, 0); + instance['handleKey'](text('c')); + const [clip] = instance['clips'].list(); + assert.equal(clip?.text, content); + assert.equal(instance['pasteReview']?.text, content); + assert.equal(instance['queueState'].entries.length, 0); + instance['pasteReview'] = undefined; + instance['pastePreview'] = undefined; + instance['openClips'](); + const item = instance['paletteState']!.items.find(item => item.id === `clip:${clip!.id}:review`)!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](item); + assert.equal(instance['pasteReview']?.text, content); + assert.equal(instance['editor'].text, ''); + instance['handleKey']({kind: 'enter'}); + assert.equal(instance['editor'].text, content); + assert.equal(instance['queueState'].entries.length, 0); + } finally { dispose(instance); } +}); + +test('composer clips preserve the draft, reject staging over text, and disappear with the frontend', async () => { + const instance = app(); + try { + instance['editor'].insert('echo draft'); + instance['openPalette'](); + const keep = instance['paletteState']!.items.find(item => item.id === 'clip:keep-composer')!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](keep); + assert.equal(instance['editor'].text, 'echo draft'); + instance['openClips'](); + const use = instance['paletteState']!.items.find(item => item.id.endsWith(':review'))!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](use); + assert.equal(instance['pasteReview'], undefined); + assert.equal(instance['editor'].text, 'echo draft'); + instance['stop'](0); + assert.deepEqual(instance['clips'].list(), []); + } finally { dispose(instance); } +}); + +test('clipboard-change suggestions are opt-in metadata only; /ps remains the explicit text read', async () => { + const instance = app(); + let textReads = 0, metadataReads = 0; + try { + Object.defineProperty(instance, 'clipboardChangeCounter', {value: async () => { metadataReads++; return 3; }}); + Object.defineProperty(instance, 'readClipboardText', {value: async () => { textReads++; return 'echo reviewed'; }}); + assert.equal(instance['clipboardChangeWatch'], undefined); + assert.deepEqual(parseSlashCommand('/clips suggestions on'), {kind: 'clips', suggestions: true}); + instance['configureClipboardChanges'](true); + await new Promise(done => setImmediate(done)); + assert.equal(textReads, 0); + assert.ok(metadataReads <= 1); + instance['clipboardChangeWatch']!['options'].changed(); + assert.equal(instance['clipboardChangeSuggested'], true); + assert.equal(instance['editor'].text, ''); + assert.equal(instance['queueState'].entries.length, 0); + await instance['pasteClipboard']('/ps'); + assert.equal(textReads, 1); + assert.equal(instance['clipboardChangeSuggested'], false); + assert.equal(instance['pasteReview']?.text, 'echo reviewed'); + instance['configureClipboardChanges'](false); + assert.equal(instance['clipboardChangeWatch']?.enabled, false); + } finally { dispose(instance); } +}); diff --git a/tests/pasteBatchLive.test.ts b/tests/pasteBatchLive.test.ts new file mode 100644 index 00000000..eea07af7 --- /dev/null +++ b/tests/pasteBatchLive.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * A pasted sequence through the real composer and PTY: reviewed first, nothing runs until Enter in the batch review, + * then the session's queue runs the commands in the same shell, in order, and pauses at a failure. Harmless + * commands only (echo, cd, false inside the sandbox). + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +const SEQUENCES = { + zsh: 'cd sub\necho "in-$(basename $PWD)"\nfor f in a b; do\n echo "loop-$f"\ndone\nfalse\necho "AFTER-FAILURE"', + bash: 'cd sub\necho "in-$(basename $PWD)"\nfor f in a b; do\n echo "loop-$f"\ndone\nfalse\necho "AFTER-FAILURE"', + fish: 'cd sub\necho "in-"(basename $PWD)\nfor f in a b\n echo "loop-$f"\nend\nfalse\necho "AFTER-FAILURE"', +} as const; + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: a pasted sequence is reviewed, queued on Enter, run in order in the same shell, and paused by a failure`, + {skip: !supported ? 'unsupported platform' : !available ? `${shell} is not installed` : false, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + mkdirSync(join(sandbox.home, 'sub')); + const frontend = sandbox.launch([], {cols: 100, rows: 34}, {}); + try { + await frontend.waitFor(/notMyShell|zsh|bash|fish/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + frontend.pty.write(`\u001b[200~${SEQUENCES[shell]}\u001b[201~`); + await frontend.waitFor(/B queue as commands/u, mark); + await pause(300); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'pasting runs nothing'); + frontend.pty.write('b'); + await frontend.waitFor(/Review commands[\s\S]*5 commands/u, mark); + await pause(300); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'reviewing runs nothing'); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/in-sub[\s\S]*loop-a[\s\S]*loop-b/u, mark, 30_000); + await frontend.waitFor(/Command failed · exit 1/u, mark, 30_000); + await pause(1200); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /AFTER-FAILURE\n/u, 'the queue paused at the failing command'); + // The rest is still queued; the same shell kept its directory. + await frontend.run('pwd', /\/sub\b/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/pinStore.test.ts b/tests/pinStore.test.ts new file mode 100644 index 00000000..a76cac6b --- /dev/null +++ b/tests/pinStore.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {addPin, addRecipe, applies, changeItem, cleanName, inScope, keepProblem, loadPins, MAX_PINS, removeItem} from '../src/pins/PinStore.js'; +import {expandSteps, placeholdersIn, quotedPlaceholder, valueProblem} from '../src/pins/recipes.js'; +import {fishQuote, posixQuote} from '../src/shell/adapters/ShellAdapter.js'; + +function sandbox() { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-pins-')); + return {path: join(directory, 'nmsh', 'pins.json'), directory, cleanup: () => rmSync(directory, {recursive: true, force: true})}; +} + +test('nothing exists until something is saved; a save creates a private file', () => { + const {path, cleanup} = sandbox(); + try { + assert.deepEqual(loadPins(path), {ok: true, value: {version: 1, pins: [], recipes: []}}); + assert.equal(existsSync(path), false, 'reading never creates the file'); + const pin = addPin({command: 'git status', name: ' my status ', scope: 'directory', cwd: '/work/app'}, path, 5); + assert.ok(pin.ok); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.deepEqual(loadPins(path), {ok: true, value: {version: 1, pins: [{id: pin.ok ? pin.value.id : '', command: 'git status', name: 'my status', scope: 'directory', cwd: '/work/app', createdAt: 5}], recipes: []}}); + assert.deepEqual(readdirSync(join(path, '..')).sort(), ['pins.json'], 'no temporary or lock files are left'); + } finally { cleanup(); } +}); + +test('secrets are refused, not stored; oversized, empty and duplicate pins too', () => { + const {path, cleanup} = sandbox(); + try { + for (const command of ['curl -H "Authorization: Bearer abc.def.ghi" x', 'export TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', 'mysql --password=hunter2 db']) { + const result = addPin({command}, path); + assert.equal(result.ok, false, command); + assert.match(result.ok ? '' : result.reason, /secret/u); + } + assert.equal(existsSync(path), false, 'a refused save writes nothing'); + assert.match(keepProblem(' ') ?? '', /nothing to keep/u); + assert.match(keepProblem('x'.repeat(70_000)) ?? '', /too long/u); + assert.equal(keepProblem('cd ~/Projects && ls'), undefined, 'private paths are fine'); + assert.ok(addPin({command: 'ls'}, path).ok); + assert.match(addPin({command: 'ls'}, path).ok ? '' : (addPin({command: 'ls'}, path) as {reason: string}).reason, /already pinned/u); + } finally { cleanup(); } +}); + +test('a file this version cannot read is reported and never replaced', () => { + const {path, directory, cleanup} = sandbox(); + try { + assert.ok(addPin({command: 'ls'}, path).ok); + for (const content of ['{ not json', '{"version":2,"pins":[]}', '[]']) { + writeFileSync(path, content); + const loaded = loadPins(path); + assert.equal(loaded.ok, false); + const result = addPin({command: 'pwd'}, path); + assert.equal(result.ok, false, 'a save does not overwrite it'); + assert.equal(readFileSync(path, 'utf8'), content); + } + assert.equal(readdirSync(join(directory, 'nmsh')).filter(name => name !== 'pins.json').length, 0); + } finally { cleanup(); } +}); + +test('hostile or damaged entries are dropped on load; names are single clean lines', () => { + const {path, cleanup} = sandbox(); + try { + assert.ok(addPin({command: 'ls'}, path).ok); + writeFileSync(path, JSON.stringify({version: 1, pins: [ + {id: 'good', command: 'ls', name: 'a‮b\nc\u0007d', scope: 'directory', cwd: 'relative/path', createdAt: 1}, + {id: '../escape', command: 'ls'}, {id: 'nocommand'}, {id: 'big', command: 'x'.repeat(70_000)}, 'string', null, + ], recipes: [ + {id: 'r1', name: 'Deploy', steps: ['a', 'b']}, {id: 'r2', name: '', steps: ['a']}, {id: 'r3', name: 'Bad', steps: ['a', 7]}, {id: 'r4', name: 'Empty', steps: []}, + ]})); + const loaded = loadPins(path); + assert.ok(loaded.ok); + if (!loaded.ok) return; + assert.deepEqual(loaded.value.pins.map(pin => [pin.id, pin.name, pin.scope, pin.cwd]), [['good', 'a b c d', 'global', undefined]]); + assert.deepEqual(loaded.value.recipes.map(recipe => recipe.id), ['r1']); + assert.equal(cleanName('x'.repeat(500)).length, 80); + } finally { cleanup(); } +}); + +test('rename, scope and delete act on the item with that id only, and report a vanished item', () => { + const {path, cleanup} = sandbox(); + try { + const a = addPin({command: 'ls', cwd: '/w'}, path, 1); + const b = addPin({command: 'pwd'}, path, 2); + assert.ok(a.ok && b.ok); + if (!a.ok || !b.ok) return; + assert.ok(changeItem(a.value.id, {name: 'list'}, path).ok); + assert.ok(changeItem(a.value.id, {scope: 'directory'}, path).ok); + assert.match((changeItem(b.value.id, {scope: 'directory'}, path) as {reason: string}).reason, /not pinned from a directory/u); + assert.ok(removeItem(b.value.id, path).ok); + assert.match((removeItem(b.value.id, path) as {reason: string}).reason, /no longer exists/u); + const loaded = loadPins(path); + assert.deepEqual(loaded.ok && loaded.value.pins.map(pin => [pin.name, pin.scope]), [['list', 'directory']]); + } finally { cleanup(); } +}); + +test('limits: pins, recipes and steps are bounded', () => { + const {path, cleanup} = sandbox(); + try { + for (let index = 0; index < MAX_PINS; index += 1) assert.ok(addPin({command: `echo ${index}`}, path).ok || index > 0); + const loaded = loadPins(path); + assert.ok(loaded.ok && loaded.value.pins.length === MAX_PINS); + assert.match((addPin({command: 'one more'}, path) as {reason: string}).reason, /at most 500/u); + assert.match((addRecipe({name: 'big', steps: Array.from({length: 101}, () => 'ls')}, path) as {reason: string}).reason, /at most 100/u); + } finally { cleanup(); } +}); + +test('recipes: named once (any case), secrets refused, steps kept in order', () => { + const {path, cleanup} = sandbox(); + try { + const recipe = addRecipe({name: ' Deploy app ', description: 'push it', steps: ['git pull', 'npm run build', 'scp -r dist {{host}}:/srv'], scope: 'directory', cwd: '/w/app'}, path, 9); + assert.ok(recipe.ok); + assert.equal(recipe.ok && recipe.value.name, 'Deploy app'); + assert.match((addRecipe({name: 'deploy APP', steps: ['ls']}, path) as {reason: string}).reason, /already exists/u); + assert.match((addRecipe({name: 'bad', steps: ['ls', 'export API_KEY=sk-abcdefghijklmnopqrstuvwxyz']}, path) as {reason: string}).reason, /secret/u); + assert.match((addRecipe({name: '', steps: ['ls']}, path) as {reason: string}).reason, /needs a name/u); + assert.match((addRecipe({name: 'none', steps: []}, path) as {reason: string}).reason, /at least one/u); + assert.ok(changeItem(recipe.ok ? recipe.value.id : '', {steps: ['one', 'two']}, path).ok); + const loaded = loadPins(path); + assert.deepEqual(loaded.ok && loaded.value.recipes[0]!.steps, ['one', 'two']); + } finally { cleanup(); } +}); + +test('scope: directory items apply in their directory and below; global ones everywhere; "all" shows everything', () => { + const items = [ + {id: 'g', scope: 'global' as const, createdAt: 1}, + {id: 'd', scope: 'directory' as const, cwd: '/w/app', createdAt: 2}, + {id: 'o', scope: 'directory' as const, cwd: '/other', createdAt: 3}, + ]; + assert.deepEqual(inScope(items, '/w/app').map(item => item.id), ['d', 'g']); + assert.deepEqual(inScope(items, '/w/app/src').map(item => item.id), ['d', 'g']); + assert.deepEqual(inScope(items, '/w/application').map(item => item.id), ['g'], 'a sibling with the same prefix is not inside'); + assert.deepEqual(inScope(items, '/elsewhere').map(item => item.id), ['g']); + assert.deepEqual(inScope(items, '/elsewhere', true).map(item => item.id), ['o', 'd', 'g']); + assert.equal(applies(items[1]!, '/w/app'), true); +}); + +test('two NMSh processes saving at once lose nothing', () => { + const {path, cleanup} = sandbox(); + try { + const script = ` + import {addPin} from ${JSON.stringify(join(process.cwd(), 'src/pins/PinStore.ts'))}; + for (let index = 0; index < 15; index += 1) addPin({command: process.argv[2] + ' ' + index}, process.argv[3]); + `; + const scriptPath = join(path, '..', '..', 'writer.mts'); + writeFileSync(scriptPath, script); + const children = ['alpha', 'beta', 'gamma'].map(name => spawnSync(process.execPath, ['--import=tsx', scriptPath, name, path], {encoding: 'utf8', timeout: 60_000})); + for (const child of children) assert.equal(child.status, 0, child.stderr); + const loaded = loadPins(path); + assert.ok(loaded.ok); + assert.equal(loaded.ok && loaded.value.pins.length, 45); + assert.equal(loaded.ok && new Set(loaded.value.pins.map(pin => pin.id)).size, 45); + } finally { cleanup(); } +}); + +test('placeholders: found in order, quoted as one word for the shell, never inside quotes', () => { + assert.deepEqual(placeholdersIn(['scp {{file}} {{host}}:/srv', 'ssh {{host}} ls', 'echo {{bad name}} {{x_1}}']), ['file', 'host', 'x_1']); + assert.equal(quotedPlaceholder('echo "{{name}}"'), 'name'); + assert.equal(quotedPlaceholder("echo '{{name}}'"), 'name'); + assert.equal(quotedPlaceholder('echo {{name}} "done"'), undefined); + assert.equal(quotedPlaceholder('echo \\"{{name}}'), undefined, 'an escaped quote opens nothing'); + const steps = ['scp {{file}} {{host}}:/srv']; + assert.deepEqual(expandSteps(steps, {file: "it's a file.txt", host: 'box'}), {ok: true, steps: ["scp 'it'\\''s a file.txt' 'box':/srv"]}); + assert.deepEqual(expandSteps(['rm {{f}}'], {f: 'a; reboot $(id)'}, fishQuote), {ok: true, steps: ["rm 'a; reboot $(id)'"]}); + assert.deepEqual(expandSteps(['echo {{a}} {{a}}'], {a: 'x y'}, posixQuote), {ok: true, steps: ["echo 'x y' 'x y'"]}); + assert.match((expandSteps(steps, {file: 'x'}) as {reason: string}).reason, /\{\{host\}\}/u); + assert.match((expandSteps(['rm {{f}}'], {f: '-rf'}) as {reason: string}).reason, /\{\{f\}\}/u, 'an option-looking value is refused'); +}); + +test('values: no options, controls, bidi or oversize; a value is never expanded by the shell', () => { + for (const bad of ['', '-rf', '--help', 'a\nb', 'a\u0007b', 'a‮b', 'x'.repeat(501)]) assert.ok(valueProblem(bad), JSON.stringify(bad)); + for (const good of ['./-rf', 'plain', '$(id)', '`id`', '~/x', 'a b', "it's", 'ü😀']) assert.equal(valueProblem(good), undefined, good); + for (const hostile of ['$(touch /tmp/pwn)', '`id`', '$HOME', '*', 'a;b', "x' ; echo y '", '${IFS}']) { + const result = expandSteps(['printf %s {{v}}'], {v: hostile}); + assert.ok(result.ok); + if (!result.ok) return; + const run = spawnSync('sh', ['-c', result.steps[0]!], {encoding: 'utf8', timeout: 5000, cwd: tmpdir()}); + assert.equal(run.stdout, hostile, `the shell printed the value, unexpanded: ${hostile}`); + } +}); diff --git a/tests/pinsLive.test.ts b/tests/pinsLive.test.ts new file mode 100644 index 00000000..9ed843b2 --- /dev/null +++ b/tests/pinsLive.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * Pins through the real composer and PTY: pin a finished command from the palette, find it in /pins, and stage it. + * Staging puts the command in the composer; it is not run until Enter. The pins file is the sandbox's own. + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +test('live: Pin command keeps a finished command; /pins stages it for review and never runs it', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {}); + try { + await frontend.waitFor(/notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + const flag = join(sandbox.home, 'pinned-flag'); + await frontend.run(`touch ${flag}`, /Completed/u); + assert.ok(existsSync(flag)); + let mark = frontend.mark; + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Command palette/u, mark); + frontend.pty.write('pin comm'); + await frontend.waitFor(/Pin command[\s\S]*Keep this command to reuse later/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/Pinned\. \/pins lists what you kept/u, mark); + const file = join(sandbox.config, 'nmsh', 'pins.json'); + await until(() => existsSync(file), 5000, 'pins.json written'); + assert.match(readFileSync(file, 'utf8'), new RegExp(`"command": "touch ${flag}"`, 'u')); + assert.doesNotMatch(readFileSync(file, 'utf8'), /READY/u, 'only the pinned command is kept, never output'); + await frontend.run(`rm ${flag}`, /Completed/u); + assert.equal(existsSync(flag), false); + mark = frontend.mark; + frontend.pty.write('/pins\r'); + await frontend.waitFor(/Pins and recipes[\s\S]*touch /u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/Staged in the composer for you to review/u, mark); + await pause(800); + // Staged, not run: the command sits in the composer, and nothing created the file. + assert.equal(existsSync(flag), false, 'staging did not run the command'); + frontend.pty.write('\r'); + await until(() => existsSync(flag), 15_000, 'Enter runs the staged command'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/pinsPanel.test.ts b/tests/pinsPanel.test.ts new file mode 100644 index 00000000..d9d04f5c --- /dev/null +++ b/tests/pinsPanel.test.ts @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {addPin, addRecipe, loadPins, pinsPath} from '../src/pins/PinStore.js'; +import {createPinsPanel, hiddenCount, panelItems, pinsPanelKey, renderPinsPanel} from '../src/pins/PinsPanel.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {blockPaletteItems} from '../src/ui/BlockActions.js'; +import {queuePanelKey} from '../src/queue/QueuePanel.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +// Pins are written under XDG_CONFIG_HOME: keep every test out of the real configuration. +const configHome = mkdtempSync(join(tmpdir(), 'nmsh-pins-ui-')); +process.env.XDG_CONFIG_HOME = configHome; +test.after(() => rmSync(configHome, {recursive: true, force: true})); +const reset = () => rmSync(join(configHome, 'nmsh'), {recursive: true, force: true}); + +const text = (value: string): Key => ({kind: 'text', value}); +const typing = (panel: ReturnType, value: string) => { for (const char of value) pinsPanelKey(panel, text(char)); }; +const data = () => { const loaded = loadPins(); assert.ok(loaded.ok); return loaded.ok ? loaded.value : {version: 1 as const, pins: [], recipes: []}; }; + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + // Assert on accepted entries without racing or executing the first recipe step. + app['session'].queue({op: 'pause'}); + return app; +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +const until = async (condition: () => boolean, ms = 8000) => { const end = Date.now() + ms; while (!condition() && Date.now() < end) await new Promise(done => setTimeout(done, 10)); }; + +test('/pins is the one entry point; the block menu offers Pin command', () => { + assert.deepEqual(parseSlashCommand('/pins'), {kind: 'pins'}); + const output = new OutputBuffer(); + output.beginCommand('make test', ['make test']); + output.write('ok\n'); + output.complete(0); + assert.ok(blockPaletteItems(output.recent(1)!).some(item => item.action.kind === 'block' && item.action.id === 'pin')); +}); + +test('the panel: select, show other folders, rename, remove only after y, and nothing is stored by it', () => { + reset(); + addPin({command: 'git status', cwd: '/w/app', scope: 'directory', name: 'st'}, undefined, 1); + addPin({command: 'ls -la'}, undefined, 2); + addPin({command: 'make', cwd: '/other', scope: 'directory'}, undefined, 3); + const panel = createPinsPanel(data(), '/w/app'); + assert.deepEqual(panelItems(panel).map(item => item.kind === 'pin' && item.pin.command), ['git status', 'ls -la']); + assert.equal(hiddenCount(panel), 1); + pinsPanelKey(panel, text('a')); + assert.equal(panelItems(panel).length, 3); + pinsPanelKey(panel, text('a')); + assert.equal(pinsPanelKey(panel, text('x')), undefined, 'x only asks'); + assert.equal(pinsPanelKey(panel, text('n')), undefined, 'n is not y'); + assert.equal(panel.mode, 'list'); + pinsPanelKey(panel, text('x')); + assert.deepEqual(pinsPanelKey(panel, text('y')), {kind: 'delete', id: (panelItems(panel)[0] as {pin: {id: string}}).pin.id}); + pinsPanelKey(panel, text('r')); + assert.equal(panel.input, 'st'); + typing(panel, '!!'); + assert.deepEqual(pinsPanelKey(panel, {kind: 'enter'}), {kind: 'rename', id: (panelItems(panel)[0] as {pin: {id: string}}).pin.id, name: 'st!!'}); + assert.equal(data().pins.length, 3, 'the panel itself changed nothing'); + assert.equal(pinsPanelKey(panel, {kind: 'escape'})?.kind, 'close'); +}); + +test('Enter on a pin stages its command; on a recipe it asks for each value, validated, then returns them', () => { + reset(); + addPin({command: 'echo staged'}, undefined, 1); + const recipe = addRecipe({name: 'Copy', steps: ['cp {{src}} {{dest}}', 'ls {{dest}}']}, undefined, 2); + assert.ok(recipe.ok); + const panel = createPinsPanel(data(), '/w'); + assert.equal(panelItems(panel)[0]!.kind, 'recipe'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + assert.equal(panel.mode, 'fill'); + typing(panel, '-rf'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + assert.match(panel.message?.text ?? '', /start with "-"/u, 'an option-looking value is refused'); + panel.input = ''; + typing(panel, 'a file.txt'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + typing(panel, './out'); + const action = pinsPanelKey(panel, {kind: 'enter'}); + assert.deepEqual(action && action.kind === 'use' && action.values, {src: 'a file.txt', dest: './out'}); + assert.equal(panel.mode, 'list'); + pinsPanelKey(panel, {kind: 'down'}); + assert.deepEqual(pinsPanelKey(panel, {kind: 'enter'}), {kind: 'stage', command: 'echo staged'}); + // Escape leaves a half-answered recipe without using it. + pinsPanelKey(panel, {kind: 'up'}); + pinsPanelKey(panel, {kind: 'enter'}); + typing(panel, 'x'); + pinsPanelKey(panel, {kind: 'escape'}); + assert.equal(panel.mode, 'list'); + assert.equal(panel.fill, undefined); +}); + +test('the queue panel offers "save as recipe" only when something is queued', () => { + const empty = {entries: []}; + assert.equal(queuePanelKey({selected: 0}, text('s'), empty), undefined); + assert.deepEqual(queuePanelKey({selected: 0}, text('s'), {entries: [{id: 1, text: 'ls', addedAt: 1}]}), {kind: 'saveRecipe'}); +}); + +test('the panel fits narrow windows and draws hostile text safely', () => { + reset(); + addPin({command: 'echo \u001b[2J‮ hidden', name: 'x'.repeat(60)}, undefined, 1); + addRecipe({name: 'R'.repeat(50), steps: ['echo {{a}}', 'echo \u001b[31mred']}, undefined, 2); + const panel = createPinsPanel(data(), '/w', {composerText: 'ls', canEdit: true}); + for (const columns of [30, 50, 100]) { + for (const selected of [0, 1]) { + panel.selected = selected; + const rows = renderPinsPanel(panel, columns, 22); + assert.ok(rows.length <= 22); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + assert.ok(!rows.join('').includes('\u001b[2J') && !rows.join('').includes('‮')); + } + } +}); + +test('from a block: Pin command keeps the command, refuses secrets, and runs nothing', async () => { + reset(); + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + const output = instance['output']; + output.beginCommand('make test', ['make test']); output.write('ok\n'); output.complete(0); + await instance['runBlockAction'](output.recent(1)!.startId, 'pin'); + assert.deepEqual(data().pins.map(pin => [pin.command, pin.scope]), [['make test', 'global']]); + output.beginCommand('export TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', ['x']); output.write(''); output.complete(0); + await instance['runBlockAction'](output.recent(1)!.startId, 'pin'); + assert.equal(data().pins.length, 1, 'a secret was not stored'); + assert.equal(readFileSync(pinsPath(), 'utf8').includes('ghp_'), false); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('history selection: palette pins the selected record with its own cwd, never the search text', async () => { + reset(); + const a = app(); + try { + a['editor'].insert('/history selected'); + a['historyQuery'] = 'selected'; + a['historyResults'] = [{id: 'history-1', source: 'nmsh', command: 'echo selected', cwd: '/original/project'}]; + a['openPalette'](); + const item = a['paletteState']!.items.find(item => item.id === 'history:pin:history-1'); + assert.ok(item); + await a['runPaletteItem'](item); + assert.equal(data().pins[0]?.command, 'echo selected'); + assert.equal(data().pins[0]?.cwd, '/original/project'); + assert.equal(a['editor'].text, '/history selected'); + assert.equal(a['queueState'].entries.length, 0); + // A deleted/refreshed history entry must not be confused with another selection. + a['historyResults'] = []; + reset(); + await a['runPaletteItem'](item); + assert.equal(data().pins.length, 0); + } finally { dispose(a); } +}); + +test('staging a pin puts it in an empty composer for review and never over typed text', async () => { + reset(); + addPin({command: 'git log --oneline | head'}, undefined, 1); + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + await instance['runSlash']('/pins', parseSlashCommand('/pins')!); + assert.ok(instance['pinsPanel']); + instance['editor'].insert('ls'); + await instance['handleKey']({kind: 'enter'}); + await until(() => instance['pinsPanel']?.message !== undefined); + assert.equal(instance['editor'].text, 'ls'); + assert.match(instance['pinsPanel']!.message!.text, /composer has text/u); + instance['editor'].clear(); + await instance['handleKey']({kind: 'enter'}); + await until(() => !instance['pinsPanel']); + assert.equal(instance['editor'].text, 'git log --oneline | head'); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('a recipe saved from the queue: values are quoted, the expanded commands are reviewed, and Enter there queues them', async () => { + reset(); + const instance = app(); + try { + instance['openPins']({naming: ['mkdir -p {{dir}}', 'cd {{dir}} && git init', 'echo done']}); + for (const char of 'Start repo') await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + await until(() => data().recipes.length === 1); + assert.equal(data().recipes[0]!.name, 'Start repo'); + // Use it: the value is asked for, quoted as one word, and the commands wait for confirmation. + await instance['handleKey']({kind: 'enter'}); + for (const char of "my project; touch x") await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + await until(() => Boolean(instance['batchReview'])); + const review = instance['batchReview']!; + assert.match(review.title ?? '', /Recipe: Start repo/u); + assert.deepEqual(review.entries.map(entry => entry.text), ["mkdir -p 'my project; touch x'", "cd 'my project; touch x' && git init", 'echo done']); + assert.deepEqual(instance['queueState'].entries.map(entry => entry.text), [], 'reviewing queues nothing'); + await instance['handleKey']({kind: 'enter'}); + assert.deepEqual(instance['queueState'].entries.map(entry => entry.text), ["mkdir -p 'my project; touch x'", "cd 'my project; touch x' && git init", 'echo done']); + // Editing or deleting the recipe afterwards cannot change what is already queued. + await instance['openPins'](); + await instance['handleKey'](text('x')); + await instance['handleKey'](text('y')); + await until(() => data().recipes.length === 0); + assert.equal(instance['queueState'].entries.length, 3); + } finally { dispose(instance); } +}); + +test('a recipe with a placeholder inside quotes is not saved (it would be quoted twice)', async () => { + reset(); + const instance = app(); + try { + instance['openPins']({naming: ['echo "{{name}}"']}); + for (const char of 'Quoted') await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + assert.equal(data().recipes.length, 0); + assert.match(instance['pinsPanel']!.message!.text, /inside quotes/u); + assert.equal(instance['pinsPanel']!.mode, 'name', 'still asking, with the name kept'); + } finally { dispose(instance); } +}); + +test('the remove prompt and every label draw hostile pinned text safely', () => { + reset(); + addPin({command: 'echo \u001b[2J\u001b]0;pwned\u0007 ‮ hidden'}, undefined, 1); + const panel = createPinsPanel(data(), '/w'); + panel.mode = 'delete'; + const drawn = renderPinsPanel(panel, 100, 22).join(''); + assert.ok(!drawn.includes('\u001b[2J') && !drawn.includes('\u001b]0;') && !drawn.includes('\u0007') && !drawn.includes('‮')); +}); 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/queueConditionOwnershipLive.test.ts b/tests/queueConditionOwnershipLive.test.ts new file mode 100644 index 00000000..8ef22b39 --- /dev/null +++ b/tests/queueConditionOwnershipLive.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; +import {inForeground, uniqueSleep} from './helpers/processState.js'; + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = ['darwin', 'linux'].includes(process.platform); + +for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: queue conditions survive failure, interrupt and reattach without transferring approval`, { + skip: !supported ? 'unsupported platform' : !available ? `${shell} not installed` : false, timeout: 120_000, + }, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell, liveActivity: {style: 'classic'}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + try { + const first = sandbox.launch(); + await first.waitFor(/notMyShell|zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + mkdirSync(join(sandbox.home, 'scope')); + await first.run('cd scope', /Completed/u); + const marker = join(sandbox.home, 'conditions-ran'); + const follow = (word: string) => `printf '%s\\n' ${word} >> "${marker}"`; + const afterFailure = follow('after-failure'); + const afterInterrupt = follow('after-interrupt'); + const approved = follow('approved'); + const contents = () => existsSync(marker) ? readFileSync(marker, 'utf8') : ''; + const hold = (name: string, code: number) => { + const release = join(sandbox.home, name); + const command = `sh -c 'printf "CONDITION-%s\\n" HOLD; until test -f "$1"; do sleep 0.05; done; exit ${code}' nmsh-fixture "${release}"`; + return {release, command}; + }; + + const failed = hold('release-failure', 3); + let mark = first.mark; + first.pty.write(`${failed.command}\r`); + await first.waitFor(/CONDITION-HOLD/u, mark); + first.pty.write(`${afterFailure}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('\u001b[27u'); + assert.equal(contents(), '', 'setting the condition does not dispatch while the original owns the shell'); + writeFileSync(failed.release, ''); + await until(() => contents() === 'after-failure\n', 20_000, 'one follow-up after exit 3'); + await first.waitFor(/from queue/u, mark); + + const duration = uniqueSleep(91 + index); + mark = first.mark; + first.pty.write(`sleep ${duration}\r`); + await until(() => inForeground(duration), 20_000, 'sleep owns input before interrupt'); + first.pty.write(`${afterInterrupt}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('\u001b[27u'); + await first.waitFor(/Enter queues the next command/u, mark); + first.pty.write('\u0003'); + await first.waitFor(/Queue paused:[\s\S]*was interrupted/u, mark); + await first.run('echo AFTER-INTERRUPT', /AFTER-INTERRUPT/u); + assert.equal(contents(), 'after-failure\n', 'always does not bypass interruption'); + await first.run('/queue clear', /Cleared 1 queued command/u); + + const gated = hold('release-approval', 0); + mark = first.mark; + first.pty.write(`${gated.command}\r`); + await first.waitFor(/CONDITION-HOLD/u, mark); + first.pty.write(`${approved}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('w'); + await first.waitFor(/asks first/u, mark); + first.pty.write('\u001b[27u'); + writeFileSync(gated.release, ''); + await first.waitFor(/next needs your approval/u, mark); + await until(async () => (await sandbox.transcripts().list()).some(item => + item.transcript.records.some(record => record.command === gated.command && record.exitCode === 0)), + 15_000, 'the holding command completes before detaching'); + const [session] = await sandbox.sessions(); + assert.ok(session); + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'approval remains pending while detached'); + assert.equal(contents(), 'after-failure\n'); + + const back = sandbox.launch(['--attach', session.id], {cols: 100, rows: 30}, {NO_COLOR: '1', NMSH_ICONS: 'safe'}); + await back.waitFor(/next needs your approval/u); + back.pty.resize(50, 24); + await back.run('/queue', /Command queue[\s\S]*asks first/u); + assert.equal(contents(), 'after-failure\n', 'reattach and inspection do not approve'); + mark = back.mark; + back.pty.write('a'); + await until(() => contents() === 'after-failure\napproved\n', 20_000, 'only the explicitly approved entry runs'); + back.pty.write('\u001b[27u'); + await back.run('echo CONDITIONS-DONE', /CONDITIONS-DONE/u); + assert.equal(contents(), 'after-failure\napproved\n', 'no duplicate or inherited approval'); + assert.doesNotMatch(back.output.slice(mark), /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + for (const command of [afterFailure, approved]) { + const matches = records.filter(record => record.command === command); + assert.equal(matches.length, 1, 'each reviewed command recorded once'); + assert.equal(matches[0]!.historicalContext?.cwd, join(sandbox.home, 'scope'), 'the queue keeps the real shell directory'); + assert.match(matches[0]!.lifecycleText, /from queue/u); + } + assert.equal(records.some(record => record.command === afterInterrupt), false, 'interrupted follow-up never became a command'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/queueConditions.test.ts b/tests/queueConditions.test.ts new file mode 100644 index 00000000..1254f5f5 --- /dev/null +++ b/tests/queueConditions.test.ts @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandQueue, type QueueState} from '../src/session/CommandQueue.js'; +import {QueueDispatcher, type QueueShell} from '../src/session/QueueDispatcher.js'; +import {queueOpFrom, queueStateFrom} from '../src/session/SessionProtocol.js'; +import {conditionNote, nextCondition, queuePanelKey, renderQueuePanel} from '../src/queue/QueuePanel.js'; +import {queueRow} from '../src/status/queueStatus.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +function harness() { + const target: QueueShell & {submitted: string[]} = {adapter: {id: 'zsh'}, submitted: [], isReady: true, submit(command: string) { this.submitted.push(command); }}; + const dispatcher = new QueueDispatcher(target); + const finish = (exitCode = 0) => { dispatcher.onExec(target.submitted.at(-1) ?? 'manual'); dispatcher.onPrompt(exitCode); }; + return {target, dispatcher, finish}; +} +const ids = (state: QueueState) => state.entries.map(entry => entry.id); + +test('the default is unchanged: a failure pauses before the next entry, which waits for resume', () => { + const {target, dispatcher, finish} = harness(); + dispatcher.onExec('make'); + dispatcher.request({op: 'add', text: 'echo after'}); + dispatcher.onPrompt(2); + assert.deepEqual(target.submitted, []); + assert.equal(dispatcher.state.paused?.reason, 'failed'); + dispatcher.request({op: 'resume'}); + assert.deepEqual(target.submitted, ['echo after']); + finish(); +}); + +test('"always" runs after a failure, but never after an interrupt', () => { + const failed = harness(); + failed.dispatcher.onExec('make'); + failed.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + failed.dispatcher.onPrompt(2); + assert.deepEqual(failed.target.submitted, ['echo cleanup']); + assert.equal(failed.dispatcher.state.paused, undefined); + + const stopped = harness(); + stopped.dispatcher.onExec('sleep 99'); + stopped.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + stopped.dispatcher.onInterrupt(); + stopped.dispatcher.onPrompt(130); + assert.deepEqual(stopped.target.submitted, [], 'Ctrl+C stops the queue whatever follows'); + assert.equal(stopped.dispatcher.state.paused?.reason, 'interrupted'); + + // An "always" entry further down does not skip the ones before it. + const behind = harness(); + behind.dispatcher.onExec('make'); + behind.dispatcher.request({op: 'add', text: 'echo plain'}); + behind.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + behind.dispatcher.onPrompt(2); + assert.deepEqual(behind.target.submitted, []); + assert.equal(behind.dispatcher.state.paused?.reason, 'failed'); +}); + +test('an entry that asks first never runs until that exact entry is approved, and is approved once', () => { + const {target, dispatcher, finish} = harness(); + dispatcher.onPrompt(0); + dispatcher.request({op: 'add', text: 'echo one'}); + finish(); + dispatcher.request({op: 'add', text: 'echo risky', condition: 'approve'}); + dispatcher.request({op: 'add', text: 'echo last'}); + assert.deepEqual(target.submitted, ['echo one'], 'waits at the entry that asks first, and nothing behind it jumps ahead'); + const [asking] = dispatcher.state.entries; + assert.equal(dispatcher.queue.awaitingApproval?.id, asking!.id); + // Not the wrong entry, not an entry without the condition. + dispatcher.request({op: 'approve', id: dispatcher.state.entries[1]!.id}); + assert.deepEqual(target.submitted, ['echo one']); + dispatcher.request({op: 'approve', id: asking!.id}); + assert.deepEqual(target.submitted, ['echo one', 'echo risky']); + finish(); + assert.deepEqual(target.submitted, ['echo one', 'echo risky', 'echo last'], 'approval does not carry over to the next entry'); +}); + +test('editing an approved entry, or changing its condition, withdraws the approval', () => { + const queue = new CommandQueue(); + queue.apply({op: 'add', text: 'rm -r build', condition: 'approve'}); + const id = queue.state.entries[0]!.id; + assert.equal(queue.apply({op: 'approve', id}), undefined); + assert.equal(queue.take()?.text, 'rm -r build'); + + queue.apply({op: 'add', text: 'rm -r build', condition: 'approve'}); + const second = queue.state.entries[0]!.id; + queue.apply({op: 'approve', id: second}); + queue.apply({op: 'edit', id: second, text: 'rm -rf /'}); + assert.equal(queue.take(), undefined, 'the approval was for the text that was approved'); + queue.apply({op: 'approve', id: second}); + queue.apply({op: 'edit', id: second, text: 'rm -rf /'}); + assert.equal(queue.state.entries[0]!.approved, true, 'saving the same text changes nothing'); + queue.apply({op: 'condition', id: second}); + queue.apply({op: 'condition', id: second, condition: 'approve'}); + assert.equal(queue.take(), undefined, 'a condition change starts again'); + assert.match(queue.apply({op: 'approve', id: 999}) ?? '', /already ran/u); + queue.apply({op: 'condition', id: second}); + assert.match(queue.apply({op: 'approve', id: second}) ?? '', /does not ask/u); +}); + +test('the protocol carries conditions and approval, and drops what it cannot understand', () => { + assert.deepEqual(queueOpFrom('{"op":"add","text":"ls","condition":"approve"}'), {op: 'add', text: 'ls', condition: 'approve'}); + assert.deepEqual(queueOpFrom('{"op":"add","text":"ls"}'), {op: 'add', text: 'ls'}); + assert.equal(queueOpFrom('{"op":"add","text":"ls","condition":"yolo"}'), undefined); + assert.deepEqual(queueOpFrom('{"op":"condition","id":3,"condition":"always"}'), {op: 'condition', id: 3, condition: 'always'}); + assert.deepEqual(queueOpFrom('{"op":"condition","id":3}'), {op: 'condition', id: 3}); + assert.equal(queueOpFrom('{"op":"condition","id":3,"condition":7}'), undefined); + assert.deepEqual(queueOpFrom('{"op":"approve","id":3}'), {op: 'approve', id: 3}); + assert.equal(queueOpFrom('{"op":"approve"}'), undefined); + const state = queueStateFrom(JSON.stringify({entries: [{id: 1, text: 'a', addedAt: 1, condition: 'approve', approved: true}, {id: 2, text: 'b', addedAt: 2, condition: 'always', approved: true}, {id: 3, text: 'c', addedAt: 3, condition: 'bogus', approved: true}]}))!; + assert.deepEqual(state.entries.map(entry => [entry.condition, entry.approved]), [['approve', true], ['always', undefined], [undefined, undefined]]); +}); + +test('the panel offers "when it runs" and "approve" only where the service supports them', () => { + const state: QueueState = {entries: [{id: 1, text: 'one', addedAt: 1}, {id: 2, text: 'two', addedAt: 2, condition: 'approve'}]}; + const panel = {selected: 0}; + assert.equal(queuePanelKey(panel, {kind: 'text', value: 'w'}, state, false), undefined, 'an older service is never sent a condition'); + assert.deepEqual(queuePanelKey(panel, {kind: 'text', value: 'w'}, state, true), {kind: 'change', change: {op: 'condition', id: 1, condition: 'always'}}); + assert.equal(nextCondition('always'), 'approve'); + assert.equal(nextCondition('approve'), undefined); + assert.equal(queuePanelKey(panel, {kind: 'text', value: 'a'}, state, true), undefined, 'only an entry that asks first can be approved'); + panel.selected = 1; + assert.deepEqual(queuePanelKey(panel, {kind: 'text', value: 'a'}, state, true), {kind: 'change', change: {op: 'approve', id: 2}}); + assert.equal(conditionNote(state.entries[1]!), 'asks first'); + for (const columns of [30, 60, 100]) { + const rows = renderQueuePanel(panel, state, undefined, columns, 24, true); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.match(stripAnsi(renderQueuePanel(panel, state, undefined, 100, 24, true).join('\n')), /asks first/u); +}); + +test('the status line says the queue waits for the person', () => { + const state: QueueState = {entries: [{id: 2, text: 'rm -r build', addedAt: 2, condition: 'approve'}]}; + assert.match(stripAnsi(queueRow(state, 100)!), /next needs your approval: rm -r build · \/queue/u); + for (const columns of [20, 40, 60]) assert.ok(displayWidth(stripAnsi(queueRow(state, columns)!)) <= columns); + assert.doesNotMatch(stripAnsi(queueRow({entries: [{...state.entries[0]!, approved: true}]}, 100)!), /approval/u); + assert.deepEqual(ids(state), [2]); +}); diff --git a/tests/queueConditionsLive.test.ts b/tests/queueConditionsLive.test.ts new file mode 100644 index 00000000..f02f821c --- /dev/null +++ b/tests/queueConditionsLive.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * Queue conditions through the real composer, PTY and session service, with harmless commands only (touch inside the + * sandbox). "Asks first" holds an entry until that entry is approved in /queue; "runs even after a failure" lets one + * entry go on after a plain failure, where the default pauses the queue. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +type Shell = 'zsh' | 'bash' | 'fish'; + +async function start(shell: Shell) { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {}); + try { + await frontend.waitFor(/notMyShell|zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'the shell authenticated prompt'); + await frontend.run('echo READY', /READY/u); + } catch (error) { + try { await sandbox.dispose(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'startup and cleanup failed'); } + throw error; + } + return {sandbox, frontend}; +} + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: an entry that asks first waits for its approval; approving runs exactly that entry`, + {skip: !supported ? 'unsupported platform' : !available ? `${shell} is not installed` : false, timeout: 120_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + const marker = join(sandbox.home, 'approved'); + let mark = frontend.mark; + frontend.pty.write('sleep 2\r'); + await frontend.waitFor(/Running sleep 2|Cooking|[A-Z][a-z]+…/u, mark); + frontend.pty.write(`touch ${marker}\r`); + await frontend.waitFor(/≡ Queued \(1\) · next: touch/u, mark); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('\u001b'); + await frontend.waitFor(/next needs your approval: touch/u, mark, 20_000); + await pause(1500); + assert.equal(existsSync(marker), false, 'nothing runs while it waits for approval'); + mark = frontend.mark; + frontend.pty.write('/queue\r'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('a'); + await until(() => existsSync(marker), 15_000, 'the approved entry runs'); + await frontend.waitFor(/from queue/u, mark); + } finally { await sandbox.dispose(); } + }); +} + +test('live: "runs even after a failure" goes on after a plain failure; the default pauses', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const cleanup = join(sandbox.home, 'cleanup'); + const mark = frontend.mark; + frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); + await frontend.waitFor(/Running sh -c|[A-Z][a-z]+…/u, mark); + frontend.pty.write(`touch ${cleanup}\r`); + await frontend.waitFor(/≡ Queued \(1\)/u, mark); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('\u001b'); + await until(() => existsSync(cleanup), 20_000, 'the cleanup entry ran after the failure'); + await frontend.waitFor(/Command failed · exit 3/u, mark); + } finally { await sandbox.dispose(); } +}); 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/sessionProtocol.test.ts b/tests/sessionProtocol.test.ts index e144ae69..dd60b972 100644 --- a/tests/sessionProtocol.test.ts +++ b/tests/sessionProtocol.test.ts @@ -20,6 +20,8 @@ const samples: ProtocolMessage[] = [ journalId: 'j1', ackedSeq: 17}, {type: 'ack', seq: 17, journalId: 'j1'}, {type: 'exec', command: 'make', seq: 18, at: 1700000000001}, + {type: 'exec', command: '{ for i in 1 2; do\n echo $i\ndone\n}', seq: 18, at: 1700000000001, queued: 'for i in 1 2; do\n echo $i\ndone'}, + {type: 'attached', sessionId: 's1', pid: 42, cwd: '/tmp', fullscreen: 0, running: 'sleep 6', runningSince: 1, runningQueued: 'sleep 6', ackedSeq: 3}, {type: 'output', data: 'x', seq: 19, at: 1700000000002}, {type: 'prompt', exitCode: 2, cwd: '/w', seq: 20, at: 1700000000003}, {type: 'replayed', truncatedBytes: 0}, 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/smartFolding.test.ts b/tests/smartFolding.test.ts index 2a3f29db..e8d670ae 100644 --- a/tests/smartFolding.test.ts +++ b/tests/smartFolding.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {evaluateFold, foldWindow, FOLD_HEAD_LINES, FOLD_TAIL_LINES, isFoldable, MIN_AUTO_FOLD_LINES, OUTPUT_FOLDING_MODES, shouldAutoFold} from '../src/output/FoldPolicy.js'; +import {evaluateFold, foldHighlights, foldWindow, FOLD_HEAD_LINES, FOLD_TAIL_LINES, isFoldable, LONG_OUTPUT_LINES, MIN_AUTO_FOLD_LINES, OUTPUT_FOLDING_MODES, shouldAutoFold} from '../src/output/FoldPolicy.js'; import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; import {OutputBuffer, type SecondaryActivity} from '../src/output/OutputBuffer.js'; import {CommandClassifier} from '../src/output/Classifier.js'; @@ -15,42 +15,83 @@ const varied = (count: number) => lines(count, index => `${WORDS[index % WORDS.l const repetitive = (count: number) => lines(count, index => `processing item ${index} of ${count}`); const install = (count: number) => lines(count, index => index % 3 === 0 ? `npm http fetch GET 200 https://registry.npmjs.org/pkg-${index} 12ms` : `downloading pkg-${index}@1.${index}.0`); -test('hard rules: short output and failures never auto-fold', () => { +test('hard rules: short output never auto-folds; shorter failures stay expanded', () => { assert.equal(fold('seq 5', lines(5, String)).fold, false); assert.equal(fold('seq 15', repetitive(15)).fold, false); assert.equal(fold('build', repetitive(MIN_AUTO_FOLD_LINES)).fold, false, 'the floor is inclusive'); - const failed = fold('npm install', install(300), 1); + const failed = fold('npm install', install(100), 1); assert.equal(failed.fold, false); assert.match(failed.reasons.join(), /failed/u); }); -test('useful varied output stays expanded, even when long', () => { +test('below the long-output size, useful varied output stays expanded', () => { assert.equal(fold('describe', varied(25)).fold, false); - assert.equal(fold('describe', varied(120)).fold, false, 'varied human-readable output is not boring'); - assert.equal(fold('git log', repetitive(400)).fold, false, 'git log is the requested result'); + assert.equal(fold('describe', varied(110)).fold, false, 'varied human-readable output is not boring'); + assert.equal(fold('git log', repetitive(100)).fold, false, 'git log is the requested result'); assert.equal(fold('git status', lines(80, index => `\tmodified: src/file${index}.ts`)).fold, false); - assert.equal(fold('rg TODO', repetitive(200)).fold, false); + assert.equal(fold('rg TODO', repetitive(100)).fold, false); }); -test('large repetitive, progress, and install output folds', () => { - const big = fold('./generate', repetitive(400)); - assert.equal(big.fold, true, big.reasons.join('; ')); - assert.equal(fold('./download', lines(60, index => `${index}% complete`), 0, {progressRewrites: true, sustainedStreaming: true}).fold, true); - assert.equal(fold('npm install', install(150)).fold, true); - assert.equal(fold('npm test', lines(500, index => `✔ test case ${WORDS[index % 12]} ${index} (${index % 7}.2ms)`)).fold, true); +test(`long output folds whatever the command and status (${LONG_OUTPUT_LINES}+ lines); no list of known tools is needed`, () => { + // The reported bug: large diagnostics never folded (varied text, the odd "error" word, ps/env/adb-style output). + const getprop = lines(600, index => `[ro.vendor.prop.${WORDS[index % 12]}.${index}]: [${index % 5 ? 'enabled' : 'error-reporting'}]`); + for (const [command, output, exitCode] of [['adb shell getprop', getprop, 0], ['describe', varied(250), 0], ['git log', repetitive(400), 0], + ['ps aux', varied(LONG_OUTPUT_LINES), 0], ['npm install', install(300), 1], ['rg TODO', repetitive(200), 0]] as const) { + const decision = fold(command, output, exitCode); + assert.equal(decision.fold, true, `${command}: ${decision.reasons.join('; ')}`); + } + for (const count of [5, 50]) assert.equal(fold('diag', varied(count)).fold, false, `${count} lines stay open`); + for (const count of [200, 500, 5000]) assert.equal(fold('diag', varied(count)).fold, true, `${count} lines fold`); + // Logical lines vs. wrapped rows: 40 very long lines are several screens; 40 short ones are not. + assert.equal(fold('jq -c .', lines(40, index => `{"id":${index},"payload":"${'x'.repeat(600)}"}`)).fold, true); + assert.equal(fold('jq -c .', lines(40, index => `{"id":${index}}`)).fold, false); }); -test('diagnostics, stack traces, compiler errors, and diffs stay visible', () => { - const trace = `${repetitive(200)}\nTraceback (most recent call last):\n File "a.py", line 3, in \n File "b.py", line 9, in f\nValueError: bad`; +test('shorter diagnostics, stack traces, compiler errors and diffs stay visible', () => { + const trace = `${repetitive(80)}\nTraceback (most recent call last):\n File "a.py", line 3, in \n File "b.py", line 9, in f\nValueError: bad`; assert.equal(fold('python job.py', trace).fold, false); - const node = `${repetitive(200)}\nTypeError: x is undefined\n at run (/app/a.js:3:9)\n at main (/app/b.js:10:2)`; + const node = `${repetitive(80)}\nTypeError: x is undefined\n at run (/app/a.js:3:9)\n at main (/app/b.js:10:2)`; assert.equal(fold('node job.js', node).fold, false); - assert.equal(fold('make', `${install(200)}\nsrc/a.c:10:5: error: expected ';'`).fold, false); - assert.equal(fold('npm test', `${lines(200, index => `✔ passes ${index}`)}\n✖ fails badly`).fold, false); - const diff = lines(300, index => index % 40 === 0 ? `@@ -${index},3 +${index},4 @@` : index % 2 ? `+ added ${index}` : `- removed ${index}`); + assert.equal(fold('make', `${install(80)}\nsrc/a.c:10:5: error: expected ';'`).fold, false); + assert.equal(fold('npm test', `${lines(80, index => `✔ passes ${index}`)}\n✖ fails badly`).fold, false); + const diff = lines(100, index => index % 40 === 0 ? `@@ -${index},3 +${index},4 @@` : index % 2 ? `+ added ${index}` : `- removed ${index}`); assert.equal(fold('cmp', `diff --git a/x b/x\n${diff}`).fold, false); }); +test('a long folded log keeps its important lines in view: errors near the middle or end, the first frame of a trace', () => { + const body = lines(400, index => index === 180 ? 'src/build.ts:42:7: error TS2322: wrong type' + : index === 390 ? 'FAILED: 3 tests' : index === 391 ? ' at run (/app/a.js:3:9)' : index === 392 ? ' at main (/app/b.js:10:2)' : `step ${index} ok`); + const all = body.split('\n'); + const picked = foldHighlights(all, FOLD_HEAD_LINES, all.length - FOLD_TAIL_LINES).map(index => all[index]); + assert.deepEqual(picked, ['src/build.ts:42:7: error TS2322: wrong type', 'FAILED: 3 tests', ' at run (/app/a.js:3:9)']); + assert.deepEqual(foldHighlights(lines(400, index => `step ${index} ok`).split('\n'), 3, 395), [], 'no invented importance'); + const output = new OutputBuffer(); + run(output, 'make all', body, 2); + const rows = output.wrapped(100).map(row => row.plain); + assert.equal(output.recent(1)!.expanded, false, 'a long failure folds'); + for (const line of ['src/build.ts:42:7: error TS2322: wrong type', 'FAILED: 3 tests', ' at run (/app/a.js:3:9)']) assert.ok(rows.includes(line), line); + assert.ok(rows.some(row => /^\s*389 lines hidden · 3 important lines shown · Ctrl\+O/u.test(row)), rows.find(row => /hidden/u.test(row))); + assert.equal(output.recent(1)!.output.split('\n').length, 400, 'stored output unchanged'); +}); + +test('Smart never folds a block under the reader: scrolled back or selecting at completion, it finishes expanded', () => { + const output = new OutputBuffer(); + output.beginCommand('seq 1 500', ['❯ seq 1 500']); + output.write(lines(500, String)); + assert.equal(output.complete(0, {holdOpen: true})!.expanded, true); + run(output, 'seq 1 501', lines(501, String)); + assert.equal(output.recent(1)!.expanded, false); +}); + +test('ANSI color and carriage-return progress count as the lines they leave, not as bytes or redraws', () => { + const colored = lines(200, index => `\u001b[32m✔\u001b[0m case ${WORDS[index % 12]} ${index}`); + assert.equal(fold('npm test', colored).fold, true); + const progress = Array.from({length: 2000}, (_, index) => `\r${index / 20}%`).join('') + '\ndone'; + const output = new OutputBuffer(); + run(output, 'curl -O big.iso', progress); + assert.equal(output.recent(1)!.expanded, true, 'a redrawn progress line is one line, not 2000'); +}); + test('the fold window keeps a head and a tail, and short blocks collapse to the row alone', () => { assert.deepEqual(foldWindow(400), {head: FOLD_HEAD_LINES, tail: FOLD_TAIL_LINES}); assert.deepEqual(foldWindow(5), {head: 0, tail: 0}); @@ -97,12 +138,12 @@ test('/copy, the transcript, and /resume keep the complete original output and t assert.ok(restored.wrapped(100).some(row => row.plain === 'processing item 250 of 400')); }); -test('failures and useful output finish expanded in the buffer', () => { +test('shorter failures and useful output finish expanded in the buffer', () => { const failed = new OutputBuffer(); - run(failed, 'npm install', install(300), 1); + run(failed, 'npm install', install(100), 1); assert.equal(failed.recent(1)!.expanded, true); const log = new OutputBuffer(); - run(log, 'git log --oneline', lines(200, index => `${(0xabc000 + index).toString(16)} commit ${index}`)); + run(log, 'git log --oneline', lines(100, index => `${(0xabc000 + index).toString(16)} commit ${index}`)); assert.equal(log.recent(1)!.expanded, true); }); 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..7c1fc409 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 () => { @@ -227,25 +249,33 @@ test('service: shutting down the shell during a blocked startup leaves no sessio }); for (const command of ['less', 'vim']) { - test(`frontend: early ${command} cannot steal ownership from SIGINT-ignoring startup`, async () => { - const sandbox = new LiveSandbox({}, {NMSH_STARTUP_NOTICE_MS: '1500'}); - try { - const answered = join(sandbox.home, 'answered'); - zshrc(sandbox, `if [[ -t 0 ]]; then trap '' INT; read -k1 "?Blocked> "; print "$REPLY" > ${answered}; fi\n`); - const app = sandbox.launch(); - await app.waitFor(/❯/); - app.pty.write(`${command}\r`); - await app.waitFor(/Shell startup is still running/); - assert.equal(existsSync(answered), false); - const [{pid}] = await sandbox.sessions(); - app.pty.write('q'); - app.pty.write('\u0003'); - assert.equal(await app.waitExit(), 130); - await until(async () => (await sandbox.sessions()).length === 0, 15000, 'aborted session removed'); - await until(() => !processAlive(pid), 15000, 'shell exited'); - assert.equal(existsSync(answered), false); - } finally { await sandbox.dispose(); } - }); + for (const delayedJournal of [false, true]) { + test(`frontend: early ${command} cannot steal ownership from SIGINT-ignoring startup${delayedJournal ? ' with delayed journal' : ''}`, async () => { + const sandbox = new LiveSandbox({}, { + NMSH_STARTUP_NOTICE_MS: delayedJournal ? '300' : '1500', + NMSH_TEST_EARLY_COMMAND: command, + NMSH_TEST_DELAY_EARLY_JOURNAL: delayedJournal ? '1' : '0', + }); + try { + const answered = join(sandbox.home, 'answered'); + zshrc(sandbox, `if [[ -t 0 ]]; then trap '' INT; read -k1 "?Blocked> "; print "$REPLY" > ${answered}; fi\n`); + // Submit through the actual composer pipeline before the notice, even + // when journaling prevents a transient composer from ever being drawn. + const app = sandbox.launch([], {cols: 100, rows: 30}, {}, resolve('tests/fixtures/earlyStartupSubmission.ts')); + await app.waitFor(/TEST-EARLY-SUBMISSION/); + await app.waitFor(/Shell startup is still running/); + if (delayedJournal) assert.match(strip(app.output), /TEST-JOURNAL-WAIT/); + assert.equal(existsSync(answered), false); + const [{pid}] = await sandbox.sessions(); + app.pty.write('q'); + app.pty.write('\u0003'); + assert.equal(await app.waitExit(), 130); + await until(async () => (await sandbox.sessions()).length === 0, 15000, 'aborted session removed'); + await until(() => !processAlive(pid), 15000, 'shell exited'); + assert.equal(existsSync(answered), false); + } finally { await sandbox.dispose(); } + }); + } } import {TerminalApp} from '../src/app/TerminalApp.js'; 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/stickyControls.test.ts b/tests/stickyControls.test.ts new file mode 100644 index 00000000..22d8c6e6 --- /dev/null +++ b/tests/stickyControls.test.ts @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer, type CompletedCommand} from '../src/output/OutputBuffer.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; + +/** + * Sticky block controls: while a completed block's header is scrolled away, its Copy and Actions ride on the sticky + * header row (NMSh's own viewport rendering; native scrollback is out of reach and never claimed). Ownership is the + * block's startId from the row model, never screen coordinates. + */ +const sgr = (button: number, x: number, y: number) => `\u001B[<${button};${x};${y}M`; + +function run(output: OutputBuffer, command: string, lines: number): number { + const startId = output.beginCommand(command, [`❯ ${command}`]); + output.write(Array.from({length: lines}, (_, index) => `${command}-out-${index}\n`).join('')); + output.complete(0); + output.setCompletionLifecycle('✔ Completed · 1 ms · 10:00'); + return startId; +} + +function app(columns = 80, rows = 20, folding: 'never' | 'smart' = 'never') { + const instance = new TerminalApp(); + const output = instance['output'] as OutputBuffer; + // Expanded blocks, so the viewport can scroll through their output (a folded block is covered separately). + output.setOutputFolding(folding); + const ids = {alpha: run(output, 'alpha', 3), bravo: run(output, 'bravo', 40), charlie: run(output, 'charlie', 40)}; + instance['output'].presenter.setLayout('normal'); + instance['promptConfiguration'].composerPosition = 'bottom'; + Object.defineProperty(instance, 'dimensions', {value: () => ({columns, rows})}); + const frames: string[][] = []; + instance['renderer'].render = ((frame: {rows: string[]}) => { frames.push(frame.rows); }) as never; + const copies: Array<{records: CompletedCommand[]; includeStatus: boolean}> = []; + // The clipboard boundary: what would be written, and success, without touching the person's clipboard. + Object.defineProperty(instance, 'copyRecords', {value: async (records: CompletedCommand[], includeStatus: boolean) => { copies.push({records, includeStatus}); return true; }}); + const frame = () => { instance['render'](); return frames.at(-1)!.map(stripAnsi); }; + /** Put the row whose text is `plain` at the top of the transcript viewport. */ + const scrollTo = (plain: string) => { + const wrapped = output.wrapped(columns); + const index = wrapped.findIndex(row => row.plain === plain); + const viewport = instance['historyViewport']; + const height = instance['planFrame'](columns, rows).viewportRows; + viewport.scrollLines(wrapped.length, height, index - viewport.resolve(wrapped.length, height)); + }; + const done = () => { instance['stop'](0); instance['session'].kill(); }; + return {instance, output, ids, frame, scrollTo, copies, done}; +} + +const controlsIn = (rows: string[]) => rows.filter(row => /\[Actions\]/u.test(row)); + +test('sticky row carries Copy and Actions for the block in view, and they follow the block you scroll into', () => { + const {frame, scrollTo, done} = app(); + try { + scrollTo('bravo-out-10'); + let rows = frame(); + assert.match(rows[0]!, /^❯ bravo.*\[Copy\] \[Actions\]$/u); + assert.equal(controlsIn(rows).length, 1, 'one set of controls on screen'); + scrollTo('charlie-out-5'); + rows = frame(); + assert.match(rows[0]!, /^❯ charlie.*\[Copy\] \[Actions\]$/u, 'now the next block'); + // The real header in view: no sticky row and no sticky controls (hover/focus keep their own affordance). + scrollTo('❯ charlie'); + rows = frame(); + assert.equal(controlsIn(rows).length, 0); + assert.match(rows[0]!, /^❯ charlie\s*$/u); + } finally { done(); } +}); + +test('sticky Copy copies that block\'s full stored output; the label confirms in place without moving Actions', async () => { + const {instance, frame, scrollTo, copies, ids, done} = app(); + try { + scrollTo('bravo-out-30'); + const before = frame()[0]!; + const copyColumn = before.indexOf('[Copy]') + 1; + const actionsColumn = before.indexOf('[Actions]') + 1; + instance['onInput'](sgr(0, copyColumn + 2, 1)); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(copies.length, 1); + assert.equal(copies[0]!.records[0]!.startId, ids.bravo); + assert.equal(copies[0]!.records[0]!.output.split('\n').length, 40, 'every line, not the visible part'); + assert.equal(copies[0]!.includeStatus, false, 'the setting decides'); + const after = frame()[0]!; + assert.match(after, /\[✓ Copied\] \[Actions\]$/u); + assert.equal(after.indexOf('[Actions]') + 1, actionsColumn, 'Actions did not move'); + assert.equal(displayWidth(after), displayWidth(before)); + // The confirmation belongs to bravo only. + scrollTo('charlie-out-5'); + assert.match(frame()[0]!, /\[Copy\] \[Actions\]$/u); + } finally { done(); } +}); + +test('sticky Actions opens that block\'s palette; elsewhere on the row a click still jumps to its header', () => { + const {instance, frame, scrollTo, ids, done} = app(); + try { + scrollTo('bravo-out-10'); + const row = frame()[0]!; + instance['onInput'](sgr(0, row.indexOf('[Actions]') + 3, 1)); + const palette = instance['paletteState'] as {items: Array<{id: string}>} | undefined; + assert.ok(palette); + assert.ok(palette.items.every(item => item.id.startsWith(`block:${ids.bravo}:`)), 'actions for bravo'); + instance['paletteState'] = undefined; + instance['onInput'](sgr(0, 3, 1)); + const wrapped = instance['output'].wrapped(80); + assert.equal(wrapped[instance['historyViewport'].start]!.lineIndex, ids.bravo, 'jumped to the real header'); + } finally { done(); } +}); + +test('narrow widths keep Actions, then nothing, and never cover the header\'s start', () => { + for (const [columns, expected] of [[60, /\[Copy\] \[Actions\]$/u], [30, /❯ bravo.*\[Actions\]$/u], [24, /^❯ bravo/u]] as const) { + const {frame, scrollTo, done} = app(columns, 20); + try { + scrollTo('bravo-out-10'); + const row = frame()[0]!; + assert.match(row, expected, `${columns} columns`); + assert.ok(displayWidth(row) <= columns); + if (columns === 24) assert.doesNotMatch(row, /\[Actions\]|\[Copy\]/u); + assert.match(row, /^❯ bravo/u, 'the command stays readable'); + } finally { done(); } + } +}); + +test('no sticky controls while a command runs, behind a panel, or in passthrough', () => { + const {instance, frame, scrollTo, done} = app(); + try { + scrollTo('bravo-out-10'); + assert.equal(controlsIn(frame()).length, 1); + instance['running'] = {command: 'sleep 9', startedAt: Date.now(), interrupted: false, cleared: false, startId: 9999, cwd: '/'}; + assert.equal(controlsIn(frame()).length, 0, 'block actions wait for the prompt, as they always have'); + instance['running'] = undefined; + instance['copyPicker'] = {records: [], query: '', searching: false, cursor: 0, chosen: new Set(), includeStatus: false, preview: true}; + assert.equal(instance['stickyHeader'](instance['output'].wrapped(80), instance['historyViewport'].start), undefined, 'a panel suppresses sticky'); + instance['copyPicker'] = undefined; + } finally { done(); } +}); + +test('a folded block: the sticky Copy still copies every stored line, not the folded view', async () => { + const {instance, output, ids, copies, done} = app(80, 20, 'smart'); + try { + // Folded: the whole block fits; open the palette on it and copy from there, the same path the sticky Copy uses. + assert.ok(output.wrapped(80).length < 60, 'folded'); + await instance['copyBlock'](ids.charlie); + assert.equal(copies[0]!.records[0]!.output.split('\n').length, 40); + } finally { done(); } +}); 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/themeFamilies.test.ts b/tests/themeFamilies.test.ts index 270e9ad8..281fade2 100644 --- a/tests/themeFamilies.test.ts +++ b/tests/themeFamilies.test.ts @@ -22,10 +22,46 @@ import {UI_COLORS} from '../src/ui/palette.js'; import {colorPickerKey, createColorPicker, hsvToRgb, renderColorPicker, rgbToHsv, xterm256} from '../src/ui/ColorPicker.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; import {setIconStyle} from '../src/ui/glyphs.js'; +import {addTheme} from '../src/appearance/themeLibraryActions.js'; const HEX = /^#[0-9a-f]{6}$/u; const plain = (rows: string[]) => rows.map(stripAnsi).join('\n'); +test('Starship and Oh My Posh imports preview lossy mapping before saving and preserve provider files', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-maple-theme-preview-')); + try { + const providers = [ + {name: 'starship.toml', kind: 'starship', text: '[directory]\nstyle = "fg:#112233"\n[custom.untrusted]\ncommand = "touch NEVER_EXECUTED"\nstyle = "fg:#ff0000"\n'}, + {name: 'theme.omp.json', kind: 'oh-my-posh', text: JSON.stringify({blocks: [{segments: [{type: 'path', foreground: '#112233', background: '#334455', template: '{{ .Env.SECRET }} $(touch NEVER_EXECUTED)'}]}]})}, + ]; + for (const provider of providers) { + await writeFile(join(directory, provider.name), provider.text); + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => []}; + const state = createThemeStudio(context, 'import'); + state.importPath = provider.name; + assert.equal(studioKey(state, {kind: 'enter'}, 'none', directory, context), undefined); + assert.ok(state.importPreview, provider.name); + assert.ok(state.importPreview.warnings.length, 'unsupported logic must be disclosed'); + assert.equal(context.themes.length, 0, 'preview stores nothing'); + const shown = plain(renderThemeStudio(state, context, 100, 50, 'none', [])); + assert.match(shown, /Import preview/u); + studioKey(state, {kind: 'escape'}, 'none', directory, context); + assert.equal(state.importPreview, undefined); + assert.equal(context.themes.length, 0); + studioKey(state, {kind: 'enter'}, 'none', directory, context); + const action = studioKey(state, {kind: 'enter'}, 'none', directory, context); + assert.equal(action?.kind, 'importTheme'); + if (action?.kind !== 'importTheme') continue; + assert.equal(action.origin.kind, provider.kind); + const saved = addTheme(structuredClone(DEFAULT_PROMPT_CONFIGURATION), action.theme, action.origin); + assert.ok(saved.ok); + if (saved.ok) assert.equal(saved.config.themes.length, 1); + assert.equal(await readFile(join(directory, provider.name), 'utf8'), provider.text); + await assert.rejects(readFile(join(directory, 'NEVER_EXECUTED')), {code: 'ENOENT'}); + } + } finally { await rm(directory, {recursive: true, force: true}); } +}); + test('theme families: canonical variants, valid ids, licenses recorded, NMSh themes unchanged', () => { assert.deepEqual(THEME_FAMILIES.map(family => family.id), ['nmsh', 'catppuccin', 'dracula', 'tokyonight', 'gruvbox', 'rosepine', 'nord', 'solarized', 'onedark', 'custom']); for (const family of THEME_FAMILIES.filter(item => item.id !== 'nmsh' && item.id !== 'custom')) { diff --git a/tests/themeImporters.test.ts b/tests/themeImporters.test.ts index fed80fce..8068dbff 100644 --- a/tests/themeImporters.test.ts +++ b/tests/themeImporters.test.ts @@ -161,3 +161,65 @@ test('bounded and binary input; unknown formats are refused with the supported l assert.match(errors(run('just some words\nnothing here', 'notes.txt')), /not a recognized theme format/u); assert.match(errors(run('a: [unclosed', 'bad.yaml')), /not a recognized theme format/u); }); + +const STARSHIP = ` +add_newline = false +palette = "mocha" +format = "$directory$git_branch$character" + +[palettes.mocha] +rosewater = "#f5e0dc" +green = "#a6e3a1" +red = "#f38ba8" +blue = "#89b4fa" + +[directory] +style = "bold bg:blue fg:#11111b" + +[git_branch] +style = "fg:green" +format = "[$symbol$branch]($style) " + +[nodejs] +style = "bright-green" + +[golang] +style = "bg:#00add8" + +[character] +success_symbol = "[➜](bold green)" +error_symbol = "[✗](bold red)" + +[custom.dangerous] +command = "rm -rf ~" +when = "true" +style = "fg:#ff0000" +`; + +test('Starship: static colors, palette names and character symbols become a Native theme; commands and names never run or invent colors', () => { + const result = preview(run(STARSHIP, 'starship.toml')); + assert.equal(result.format, 'starship'); + valid(result); + assert.equal(result.theme.prompt.cwd, '#89b4fa', 'bg: from the palette name wins over fg:'); + assert.equal(result.theme.prompt.gitBranch, '#a6e3a1'); + assert.equal(result.theme.prompt.go, '#00add8'); + assert.equal(result.theme.prompt.success, '#a6e3a1'); + assert.equal(result.theme.prompt.failure, '#f38ba8'); + assert.equal(result.theme.basedOn, 'Starship import'); + const warnings = result.warnings.join('\n'); + assert.match(warnings, /bright-green/u, 'an ANSI color name is reported, not turned into a color'); + assert.match(warnings, /1 \[custom\] module ignored: their commands are never run/u); + assert.match(warnings, /configures \d+ modules/u); + assert.doesNotMatch(JSON.stringify(result.theme), /#ff0000/u, 'a custom module\'s color is not imported'); + assert.ok(result.mapping.some(item => item.from === '[directory] style')); +}); + +test('Starship: an unknown palette, no usable colors, hostile names and oversized input are handled without guessing', () => { + assert.match(errors(run('[directory]\nstyle = "bold cyan"\n', 'starship.toml', 'starship')), /No static Starship style colors/u); + const missing = preview(run('palette = "nope"\n[git_branch]\nstyle = "fg:#112233"\n', 'starship.toml')); + assert.match(missing.warnings.join(' '), /"nope" is not defined/u); + const hostile = preview(run('[git_branch]\nstyle = "fg:#112233"\n', '\u001b[31mevil\u0007.toml', 'starship')); + assert.doesNotMatch(hostile.theme.name, /[\u0000-\u001f]/u); + assert.match(errors(run(`# ${'x'.repeat(IMPORT_SIZE_LIMIT)}\n[git_branch]\nstyle = "fg:#112233"\n`, 'starship.toml')), /larger than 256 KiB/u); + assert.match(errors(run('[unrelated]\nvalue = 1\n', 'a.toml')), /not an Oh My Posh config, a Starship config or a WezTerm/u); +}); 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))!; diff --git a/tests/worktreeDiscovery.test.ts b/tests/worktreeDiscovery.test.ts index bbbf4fb3..dfb3ed07 100644 --- a/tests/worktreeDiscovery.test.ts +++ b/tests/worktreeDiscovery.test.ts @@ -249,20 +249,40 @@ test('bounds: worktree count cap and bounded status concurrency', async () => { test('performance: 100 linked worktrees come from one list call plus bounded status probes', {timeout: 120_000}, async () => { const fx = await createRepo(); try { + const setupStarted = performance.now(); for (let index = 0; index < 100; index += 1) fx.git('worktree', 'add', '-q', '--no-checkout', '-b', `perf/${index}`, '--', join(fx.root, `p${index}`), 'HEAD'); + const setupMs = performance.now() - setupStarted; const calls: string[] = []; + const observations: {operation: string; elapsedMs: number; code: number | null; timedOut: boolean; truncated: boolean; stderr: string; list?: string}[] = []; let active = 0, peak = 0; const counting: GitRunner = async (cwd, args, options) => { calls.push(args[0]!); active += 1; peak = Math.max(peak, active); - try { return await git(cwd, args, options); } finally { active -= 1; } + const started = performance.now(); + try { + const result = await git(cwd, args, options); + observations.push({operation: args[0]!, elapsedMs: performance.now() - started, + code: result.code, timedOut: result.timedOut, truncated: result.truncated, stderr: result.stderr.slice(0, 1000), + ...(args[0] === 'worktree' ? {list: result.stdout} : {})}); + return result; + } finally { active -= 1; } }; const started = performance.now(); const snapshot = await discoverWorktrees(counting, fx.repo, {concurrency: 8}); const elapsed = performance.now() - started; - assert.equal(snapshot.worktrees.length, 101); - assert.equal(calls.filter(call => call === 'worktree').length, 1); - assert.equal(calls.filter(call => call === 'status').length, 101); - assert.ok(peak <= 8, `peak concurrency ${peak}`); - assert.ok(elapsed < 60_000, `discovery took ${Math.round(elapsed)}ms`); + try { + assert.equal(snapshot.worktrees.length, 101); + assert.equal(calls.filter(call => call === 'worktree').length, 1); + assert.equal(calls.filter(call => call === 'status').length, 101); + assert.ok(peak <= 8, `peak concurrency ${peak}`); + assert.ok(elapsed < 60_000, `discovery took ${Math.round(elapsed)}ms`); + } catch (error) { + // Preserve the original assertion and deadlines. A successful list with + // missing records is different from a bounded per-worktree probe failure. + if (error instanceof Error) error.message += `; discovery observation=${JSON.stringify({setupMs, elapsedMs: elapsed, + records: snapshot.worktrees.length, peak, list: observations.find(item => item.operation === 'worktree'), + statusCalls: calls.filter(call => call === 'status').length, + failedProbes: observations.filter(item => item.timedOut || item.truncated || (item.code !== 0 && item.code !== 1))})}`; + throw error; + } } finally { await fx.dispose(); } });