diff --git a/AGENTS.md b/AGENTS.md index 0fa00eb8..a5a1f3d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,9 @@ Input awareness (waiting-for-input state, hidden and single-key input routed to ## Surfaces released in v0.20.0 Workspace sidebar and detail panel (`/sidebar`, `/panel`, `/keys`, `keymap.json`; one column plan in `src/app/workspaceHost.ts`/`workspaceFrame.ts`), background tasks (`/background`, `/bg`, task bar; session-owned `SessionTasks`, protocol feature `background-tasks`), native modules for .NET, Swift, Zig, Deno, Elixir, Dart, Flutter, build systems, Mercurial and Jujutsu, managed Claude `/model`, `/effort` and mod toggles, related PRs in `/worktrees`; the first-run starting point (`/startup`, `src/setup/startup/`), the guided setup, the shared optional-tools flow (`src/tools/ToolsPanel.ts`), the execution timeline and Queue settings, queue history and typed summaries (`/queue history`, `/queue save-summary`), queued output folding, multi-line paste choices, `/copy` output navigation and `/actions`, the custom color editor and the per-installation update notice. Physical QA status: [v0.20 record](docs/testing/v020-physical-qa.md). +## Release candidate surfaces (v0.21.0, not published) +`/view` (read-only viewer for files and completed output; `src/viewer/document.ts`, `ViewerPanel.ts`), `/edit` (a small editor for one text file with atomic, conflict-checked saves; `src/viewer/fileEdit.ts`, `EditorPanel.ts`, `textBuffer.ts`), Semantic Output first phase (deterministic recognizers in `src/output/semantic.ts`; presentation rows only, raw output and plain `/copy` unchanged; `/copy --semantic|--both`), `/track` (one live result of a finite command on the `/watch` scheduler, window-owned; `src/tasks/WatchTasks.ts`, `repeatSafety.ts`, `trackBlock.ts`), Bun, Nix shell and container modules, `/modules explain` and `/modules timings`, five theme families, and the parser's bounded live region for multi-row progress (#388). State: [v0.21 ledger](docs/development/v021-ledger.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 7adfa9c6..a2929159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.21.0] - Unreleased (release candidate) + +**Native viewing and editing, Semantic Output and `/track`.** NMSh still runs your real zsh, Bash or Fish in one persistent session. This release reads files and completed output without leaving it (`/view`) and makes a quick edit to a small text file (`/edit`). Semantic Output puts a test run's or a build's counts and failures in one row above its raw output, and `/track` keeps one live, refreshed result of a command in the transcript. Bun, Nix shell and container modules come with `/modules explain` and `/modules timings`, plus five more theme families. Multi-row progress displays now redraw in place. + +This is a release candidate: it is published only after its physical-terminal QA passes ([v0.21 physical QA](docs/testing/v021-physical-qa.md); state and evidence in the [v0.21 ledger](docs/development/v021-ledger.md)). + +### Added + +- **`/view`, a native read-only viewer** for files and completed output ([#397](https://github.com/raiseCatError/notMyShell/issues/397)). `/view README.md` opens a file relative to the shell's directory (`~` for home): Markdown renders (`m` shows the source), source files get syntax colors and line numbers, `.diff`/`.patch` files and `git diff`/`git show` output read as diffs. `/view N` opens completed output N (numbered as `/copy` numbers it) and `/view` the latest; Actions → View output does the same for a block. Find with `/` and `n`/`N`, go to a line with `:`, `w` toggles wrapping (Left/Right scroll sideways), `l` line numbers, `c` copies the whole text, Esc closes. Viewing never changes the file or the transcript. + - Limits: regular files only (directories, devices and pipes are refused), at most 8 MiB and 200,000 lines (the rest is named, with `less` or an editor as the way to read it), binary and UTF-16 files are refused, invalid UTF-8 is shown with `�` and a note. Control characters, escape sequences and invisible format characters are shown as visible marks, never interpreted. Open in pager, `less`, `bat` and Glow are unchanged. +- **`/edit`, a small native editor** for one text file ([#402](https://github.com/raiseCatError/notMyShell/issues/402)). `/edit notes.md` (or `e` in `/view`) opens the file in a panel: type, select with Shift, Ctrl+Z / Ctrl+Y undo and redo, Ctrl+F find, Ctrl+C / Ctrl+X copy and cut, Ctrl+S save, Esc close (it asks about unsaved changes). A path that does not exist yet is created on the first save. It is for quick edits, not an IDE: `vim`, `nano`, `micro` and your `$EDITOR` still run as usual. + - Safe saving: the new text is written beside the file and renamed over it, keeping its mode, group, line endings, BOM and final newline; a symlink stays a symlink. If the file changed, was replaced or was deleted since you opened it, nothing is written until you choose Keep editing, Overwrite or Reload. A new file never replaces one that appeared meanwhile. + - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. +- **Semantic Output** for test runs and builds ([#386](https://github.com/raiseCatError/notMyShell/issues/386), first phase). A completed block whose output NMSh recognizes gets one overview row with the counts the tool printed (`✔ node --test · 1,772 passed · 0 failed · 25 skipped`, `✘ TypeScript · 3 errors in 2 files`) and, when it listed failures or diagnostics, a details row that opens them, taken verbatim from the output. Recognized: Node's test runner (spec and TAP), Jest, Vitest, pytest, `cargo test`, `go test`, TypeScript, Rust and GCC/Clang diagnostics; anything else stays raw only, and short passing output gets nothing. Details start open when the raw output is folded; click the details row or use Actions → Show or hide details. The output itself, its folding, search, selection and plain `/copy` are unchanged. `/copy --semantic` copies the reading (raw for an output without one, as the note says) and `/copy --both` the raw output and the reading under labelled NMSh separators. Settings → Transcript → Semantic Output turns it off. +- **Native modules: Bun, Nix shell and container** ([#364](https://github.com/raiseCatError/notMyShell/issues/364)). Bun's requested version comes from `.bun-version` or `package.json` (`packageManager`, `engines.bun`) and the active one from its install location; the Nix shell module shows `pure`/`impure` from `IN_NIX_SHELL`; the container module names Docker, Podman, a dev container or Codespaces from marker files and host variables. None runs anything; all are off until enabled in `/modules`, which recommends Bun and Nix when their files are present. +- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. +- **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`. +- **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). + +### Fixed + +- Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Rows are the terminal's rows: a line wider than the window counts as the rows it occupies, so a display that redraws over its own wrapped line no longer erases the log lines above it at narrow widths. A status row an installer reserves below a scroll region is not written into the log. Absolute cursor positioning is not mapped. +- A queued command whose command word was still being classified when the next prompt reported new shell knowledge no longer keeps the unclassified color ([#408](https://github.com/raiseCatError/notMyShell/issues/408)). +- With a slow zsh configuration (one that takes longer than a second and a half to load, as nvm or a large framework can), command colors no longer stop for the rest of the session: the first lookup waited on the configuration, timed out, and ended the helper that tells known commands from unknown ones. +- Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. +- `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). +- `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. +- A background task that exits cleanly no longer shows "exited, processes left" (with Delete refused) while its own guard checks that the task's process group is empty; the task finishes on the guard's verdict. +- Configured completion: a helper that times out at startup no longer leaves its private folder behind, or throws from a timer (`ENOTEMPTY` on macOS), when its shell writes late. + +### Compatibility +- Node.js 22 or newer; zsh, Bash 4.4+ and Fish, as in v0.20.0. CI covers Node 22 and 26 on Ubuntu, Node 26 on macOS, and a Linux portability subset on Fedora. +- The session protocol is unchanged (version 2): a session service started by v0.20.0 keeps serving the sessions it owns, and new windows attach to it. `/view`, `/edit`, `/track`, Semantic Output, the new modules and themes work at once. The background-task fix above needs a session service from this release. +- `/track` needs `sh` and POSIX process groups (macOS, Linux, WSL 2). + +### Upgrade notes +- **From v0.20.0:** settings, themes, modules, sessions and history carry over; existing theme ids and saved choices are unchanged, and Lavender stays the default. +- Semantic Output is on for every configuration that has not turned it off (Settings → Transcript → Semantic Output). The Bun, Nix shell and container modules are off until enabled in `/modules`. +- End the sessions of a v0.20 session service (or restart it once they end) to get the background-task fix. +- Homebrew: `brew upgrade raiseCatError/tap/nmsh` installs whatever version the tap's formula points to; check `nmsh --version`. + +### Known limitations +- `/track` is window-owned: trackers stop with the window that started them, and a resumed session shows the last result, not a live one. Samples run `sh -c` with the shell's PATH, not its aliases or functions. +- Semantic Output is a first phase: built-in recognizers only (no `jc`, `delta` or other external providers), the raw output is not fully collapsed behind the reading, and find searches the raw output only. +- `/edit` does not carry over extended attributes or ACLs, and unsaved text is lost if the window itself closes; the size and file-kind limits are listed under Added. +- Progress redraws: absolute cursor rows (`CSI H`/`f`/`d`) are not mapped and scroll regions are not emulated ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). +- `/modules explain` covers the Context Rail and Status Strip; the Main Prompt has no per-segment truncation trace ([#365](https://github.com/raiseCatError/notMyShell/issues/365)). +- Oxocarbon, GitHub Primer, Material/Palenight and Monokai are deferred, with reasons in `docs/design/theme-families.md` ([#401](https://github.com/raiseCatError/notMyShell/issues/401)). +- Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). +- On a heavily loaded machine, a reply typed within about a second of starting a command that reads without a prompt can be queued instead of answering it ([#426](https://github.com/raiseCatError/notMyShell/issues/426); also in v0.20.0). +- Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. Everything listed under v0.20.0's known limitations still applies. + ## [0.20.0] - 2026-10-18 **Terminal workflow, first-run setup, background tasks & native modules.** The first stable release since v0.18.0. A new first run lets you pick a starting point under a live aurora and review it before anything applies; queued commands read as a chronological timeline with saved summaries; completed output can be selected, copied and acted on from the keyboard; optional tools are discovered, reviewed and installed through one flow; long-running commands can run as background tasks beside the shell; and the native module catalog covers twelve more runtimes and build tools. Every new control is reached through an explicit command, key or review, and the persistent shell, the queue and program input keep their behavior. diff --git a/README.md b/README.md index 8861926e..1065e637 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Latest stable release: [v0.20.0 — Terminal workflow, first-run setup, background tasks & native modules](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0), the first stable release since v0.18.0. It includes the terminal workflow from the v0.19.0 candidate, which was never published separately. The `master` branch includes this released work; see the [changelog](CHANGELOG.md#0200---2026-10-18) for its scope, upgrade notes and known limitations. +Release candidate: v0.21.0 (`/view`, `/edit`, Semantic Output, `/track`, more native modules and theme families) is on `release/v0.21.0` and is not published until its physical-terminal QA passes; see the [changelog](CHANGELOG.md). + ## See notMyShell in motion https://github.com/user-attachments/assets/7f2cbe74-ad86-4d13-bbe3-04e334cff81b @@ -171,6 +173,7 @@ Type `/` in the composer for the full list, or `/help` for everything grouped by | Look and motion | `/appearance`, `/theme`, `/theme-bridge`, `/chroma`, `/motion`, `/chrome`, `/glyphs`, `/strip`, `/screensaver` | | Tools | `/tools`, `/providers`, `/configure`, `/tmux`, `/integrations`, `/dotfiles` | | Sessions and history | `/resume`, `/sessions`, `/history`, `/find`, `/filter`, `/copy`, `/clear` | +| Files and output (v0.21 candidate) | `/view` (files and completed output), `/edit` (a small text file), `/track` (one live result of a command), `/copy --semantic` | | Agents and repositories | `/claude` (`/claude new`), `/ai`, `/mods` (`/extensions`), `/worktrees`, `/github` (`/prs`, `/issues`) | | Shells | `/shell` (switch zsh / Bash / Fish in place), `/zsh` (hand off to an ordinary shell) | | Everyday extras | `/btw`, `/watch`, `/open`, `/zoomies` (`/caffeinate`, `/awake`), `/update`, `/doctor` | diff --git a/ROADMAP.md b/ROADMAP.md index 224e77e7..cccdb398 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,13 +8,13 @@ | **Not released separately** | v0.19.0 — Terminal workflow candidate; never tagged or published, its work first ships in v0.20.0 | | **Previous release** | [v0.18.0 — Context Engine, Managed Agents & Workspaces](https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0) | | **macOS distribution** | Homebrew: `brew install raiseCatError/tap/nmsh`; source installation remains available | -| **Next implementation candidates (not yet committed)** | [v0.21 candidate issues](https://github.com/raiseCatError/notMyShell/issues?q=is%3Aissue+is%3Aopen+label%3Acandidate%3Av0.21) — viewer/text editing, Semantic Output, `/track`, native modules, bundled themes and selected defects. The maintainer will choose final scope after implementation and verification. | +| **Release candidate** | v0.21.0 — native viewing and editing (`/view`, `/edit`), Semantic Output (first phase), `/track`, Bun, Nix shell and container modules with `/modules explain`/`timings`, five theme families, multi-row progress redraws — on `release/v0.21.0`, not published until its [physical QA](docs/testing/v021-physical-qa.md) passes ([ledger](docs/development/v021-ledger.md)) | | **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 | | **Unscheduled** | [Future / Backlog](https://github.com/raiseCatError/notMyShell/milestone/7) | | **Development branch** | `dev` | | **Public planning** | [Open issues](https://github.com/raiseCatError/notMyShell/issues); the owner-only project board is not a public status source | -GitHub issues define actionable remaining work. Closed issues represent completed work, not a promise that future refinements are finished. The latest published release and package version are 0.20.0. +GitHub issues define actionable remaining work. Closed issues represent completed work, not a promise that future refinements are finished. The latest published release is 0.20.0; the release candidate's package version is 0.21.0. ## Native modules and Context Engine — released foundation, ongoing expansion @@ -158,6 +158,17 @@ 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.21.0 Native viewing, Semantic Output and `/track` + +Planned in [#410](https://github.com/raiseCatError/notMyShell/issues/410) and merged into `dev` as #411–#424, then the +release audit's fixes. Shipped scope: `/view` ([#397](https://github.com/raiseCatError/notMyShell/issues/397)) and `/edit` +([#402](https://github.com/raiseCatError/notMyShell/issues/402) viewer and editor phases), Semantic Output first phase +([#386](https://github.com/raiseCatError/notMyShell/issues/386)), `/track` ([#393](https://github.com/raiseCatError/notMyShell/issues/393)), +Bun, Nix shell and container modules ([#364](https://github.com/raiseCatError/notMyShell/issues/364)), `/modules explain` and +`timings` ([#365](https://github.com/raiseCatError/notMyShell/issues/365)), five theme families +([#401](https://github.com/raiseCatError/notMyShell/issues/401)), and the #388/#408 fixes. Not published until its physical QA +passes; see the [changelog](CHANGELOG.md) for compatibility, upgrade notes and known limitations. + ## Released — v0.20.0 Terminal workflow, first-run setup, background tasks & native modules Released on 2026-10-18 through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392), built cumulatively on the unpublished v0.19 candidate: the standalone module work merged through diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c04e6051..36d910ac 100644 --- a/docs/design/native-module-coverage.md +++ b/docs/design/native-module-coverage.md @@ -26,9 +26,9 @@ module behaves identically to its upstream namesake. | 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` | Pack `nmsh.vcs`: `hg` (bookmark/branch), `jj` (repository state) | **Done: Jujutsu and Mercurial** ([#374](https://github.com/raiseCatError/notMyShell/pull/374)), from 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, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)). Remaining candidates: Bun, Kotlin/Scala, Lua, Haskell.** 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 | +| 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, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)); Bun (v0.21: `.bun-version`, `package.json` `packageManager`/`engines.bun`, the install location). Remaining candidates: Kotlin/Scala, Lua, Haskell.** 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**: done ([#373](https://github.com/raiseCatError/notMyShell/pull/373): Bazel, Gradle, Maven, CMake, Meson, Make) 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** | +| 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; mise through `tools`. **Done in v0.21: Nix shell** (`IN_NIX_SHELL`, pure/impure) **and container** (Docker/Podman marker files, Codespaces and dev container host variables; pack `nmsh.system`) | | 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 | @@ -55,3 +55,16 @@ directories (including hostile and symlinked files), and the explain/timings vie 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. + +## v0.21 review (2026-10-18) + +Bun, Nix shell and container were added with the same rules (declarative files, allowlisted variables and marker-file +presence only; nothing run). The `requires` bound of a Context Pack rose from 16 to 24 so a pack can name as many +capabilities as it may hold modules. Kotlin/Scala, Lua and Haskell stay candidates: each needs a declarative version +source that is reliable enough to show (a Gradle Kotlin plugin version, a rockspec, a Stack resolver) and was not judged +worth a module per language in this release. Diagnostics (#365): `/modules explain` and `/modules timings` give a +plain-text report of routing, visibility reasons, source states and the slowest last collections, from the engine's +snapshots only. The Context Rail and Status Strip record how their width fitting treated each routed module in the last +live frame (left out, or shortened), and explain says so; previews never overwrite that record. The Main Prompt and +Right Context truncate their text rather than drop modules, so they have nothing to report; a per-segment truncation +trace there remains future work. diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 95ebf25b..6dbe2bd9 100644 --- a/docs/design/theme-families.md +++ b/docs/design/theme-families.md @@ -27,6 +27,11 @@ are derived, so older configurations load unchanged: | Nord | Nord | | | Solarized | Dark, Light | | | One Dark | One Dark, One Light | | +| Ayu | Dark, Mirage, Light | v0.21; Mirage is the family default | +| Kanagawa | Wave, Dragon, Lotus | v0.21 | +| Everforest | Dark, Light | v0.21; medium contrast (soft/hard change only the editor background) | +| Flexoki | Dark, Light | v0.21 | +| Night Owl | Night Owl, Light Owl | v0.21 | | Custom | the user's theme (`customTheme`) | NMSh Theme JSON | Settings shows `Theme family`, then nested `Variant` (hidden for one-variant @@ -54,6 +59,30 @@ NMSh never fetches palettes at runtime. | Nord | github.com/nordtheme/nord `src/nord.css` | MIT | Copyright (c) 2016-present Sven Greb | | Solarized | github.com/altercation/solarized (README) | MIT | Copyright (c) 2011 Ethan Schoonover | | One Dark / One Light | github.com/atom/one-dark-syntax, one-light-syntax `styles/colors.less` (HSL converted to hex) | MIT | Copyright (c) 2016 GitHub Inc. | +| Ayu | github.com/ayu-theme/ayu-colors `themes/dark.yaml`, `mirage.yaml`, `light.yaml` (d461f029) | MIT | Copyright (c) Konstantin Pschera (LICENSE gives no year) | +| Kanagawa | github.com/rebelot/kanagawa.nvim `lua/kanagawa/colors.lua`, `themes.lua` (bb85e4bf) | MIT | Copyright (c) 2021 Tommaso Laurenzi | +| Everforest | github.com/sainnhe/everforest `palette.md` (85a86eb6) | MIT | Copyright (c) 2019 sainnhe | +| Flexoki | github.com/kepano/flexoki `README.md` palette tables (8d723bac) | MIT | Copyright (c) 2023 Steph Ango | +| Night Owl | github.com/sdras/night-owl-vscode-theme `themes/Night Owl-color-theme.json`, `Night Owl-Light-color-theme.json` (cc291eba) | MIT | Copyright (c) 2018 Sarah Drasner | + +v0.21 mapping notes. Values are used exactly as each source writes them; the mapping to NMSh roles follows the +theme's own semantics where it has them: + +- **Ayu**: the YAML's base palette colors (not the generated syntax tones), `editor.fg`, `ui.fg`, `ui.panel.bg`, + `common.accent.tint` and `common.error`. Ayu defines one text color, so primary and secondary are the same. +- **Kanagawa**: each theme's roles from `themes.lua`: project = `fun`, path = `bg_p2`, branch = `keyword`, + Node = `string`, Go = `type`, Python = `identifier`, separator = `nontext`, subtle = `comment`, selection = + `bg_visual`, status = `ok`/`error`. +- **Everforest**: backgrounds by their position in `palette.md` (bg_dim, bg0…bg5), the seven accents and three greys. +- **Flexoki**: dark uses the 400 accents and base tiers (tx base-200, tx-2 base-500, tx-3 base-700, ui-2 base-850, + ui-3 base-800); light uses the 600 accents, as Flexoki documents its standard palette. +- **Night Owl**: editor, selection, line-number and terminal ANSI colors, and the comment token color. + +Considered for v0.21 and not bundled: **Oxocarbon** (its grays are computed at runtime in HSLuv by the theme's own +code; reproducing them would need running that code or re-implementing it, and the issue asks for no +approximations), **GitHub Primer** (values come from a built `@primer/primitives` release, not the theme +repository), **Material/Palenight** and **classic Monokai** (licensing and canonical source not verified), and +Tier 2 families. The Built-in list in Theme Studio gains `/` to filter by name, family, or dark/light. Only color values are used; no code, assets or names beyond the theme names for identification. Random gallery or theme-site copies were not used. diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md new file mode 100644 index 00000000..d62f5e55 --- /dev/null +++ b/docs/design/v021-terminal-intelligence.md @@ -0,0 +1,189 @@ +# v0.21 — native viewing, terminal intelligence and workflow polish + +Design and acceptance for the v0.21 candidate ([#410](https://github.com/raiseCatError/notMyShell/issues/410)). It +reconciles the candidate issues against the code on `dev` at `6af5cbab` (identical to `master`, after v0.20.0 at +`65e4de55`), names what each slice reuses and owns, and lists the tests that prove it. Progress, commits and open risks +are recorded in the [v0.21 ledger](../development/v021-ledger.md); this document changes only when a decision does. + +v0.21 makes the existing terminal better. NMSh stays a frontend over a real shell and PTY: the shell parses and runs +commands, programs own their bytes, and NMSh owns the composer and its own presentation. Nothing here is a shell +course, an IDE, a multiplexer or an agent harness. Learn Mode (#398) is deferred and unscheduled. + +## Invariants every slice keeps + +- **One input owner at a time.** A panel, viewer or editor owns keys only while it is open; a running program's + foreground read, passthrough and the queue keep their current rules. Nothing new types into the shell. +- **Raw output is authoritative.** Derived views (overviews, track snapshots, viewer rendering) are presentation and + are never written into the stored transcript lines, the journal's raw events, `/copy`'s raw payload or the shell. +- **One geometry plan.** New chrome is planned by `src/app/screenPlan.ts` or drawn as a full panel through the existing + panel slot; no subsystem computes its own rows. +- **Hostile text is inert.** File contents, command output and provider results are data: control and format + characters are escaped or removed before display (`displaySafe`, `stripTerminalControls`), links are only the + existing allowed forms, and nothing is executed because it was opened or recognized. +- **Bounded.** Every new read, buffer, cache, timer and process has a stated limit sized for an 8 GB Mac. +- **Honest degradation.** Unsupported input says so and keeps the existing behavior; nothing is guessed. + +## Slices + +Order follows dependencies: reliability first (later slices run more tests over the same paths), then the viewer +(the editor and Semantic Output reuse its document model), then `/track`, modules, themes and controls. + +### 1. Reliability gates + +**#403 Fish teardown race.** Cause confirmed: an interactive Fish whose generated-completions directory is missing +starts `fish_update_completions` detached (Fish 4.x checks `$__fish_cache_dir/generated_completions`, Fish 3.x +`$__fish_user_data_dir/generated_completions`); the orphaned Python process keeps writing about 1,400 files into the +test home after the shell exits, racing `rmSync`. Production is unaffected: NMSh runs Fish with the person's own +directories, where this one-time generation is Fish's normal behavior. The harness, however, did not isolate +`XDG_DATA_HOME`/`XDG_CACHE_HOME`, so test shells also wrote into ambient caches (observed under `t7run`). +Fix: Fish's "already generated" directories exist in every test home (`scripts/test-shell-home.mjs`, used by the +canonical runner, `LiveSandbox` and the fixtures that start Fish with their own home), and the runner no longer passes +ambient `XDG_DATA_HOME`/`XDG_CACHE_HOME`/`XDG_STATE_HOME` to tests, so local runs fall back to the private home as CI +does. Test: a Fish started in a prepared home spawns no generator (and the same test fails without the marker). + +**#408 queued highlight.** Cause confirmed in production code, not only the test: every prompt marker's shell +knowledge clears `SemanticService`'s cache and resolves in-flight classifications as `unknown`, so a stored command +whose lookup was in flight when the next prompt arrived (a fast queue) keeps the unclassified color permanently. +Fix: a lookup superseded by newer knowledge is asked again against that knowledge (bounded), and a stale answer is +still never cached. The test waits for the shell's startup knowledge and for the classification promises it started +instead of one `setImmediate`. Tests: superseded lookup resolves from the new knowledge; queued and composer paths end +with the same colors; repeated runs are stable. + +**#388 multi-line progress redraws.** `AnsiOutputParser` implements `\r`, `CSI K/G/C/D` and full clears, but ignores +row movement, so every frame of a multi-row progress display (`CSI A` + `CSI 2K` + text + `\n`) becomes new permanent +lines. Fix: a bounded **live region**: the cursor may move up and down only within the running program's own rows, +never above the most recent NMSh-authored line or the command's start, and never more than the PTY's screen height +(a terminal cannot address scrollback either). Supported: `CSI A/B/E/F` (cursor up/down/next/previous line), +`ESC M`, `ESC D`, `ESC E`, save/restore (`ESC 7/8`, `CSI s/u`), `CSI J` 0/1 within the region, `CSI K` 0/1/2. +**Rows are the terminal's rows** (stabilization): once the frontend reports the PTY's width, a stored line wider than +it occupies several rows, so a program that counts the rows it drew (as progress libraries measure wrapping) moves +over exactly those rows. Moving by stored lines instead climbed past a wrapped line into the program's own earlier +output and erased it at narrow widths (reproduced, and in a real PTY at 40 columns). Carriage return, backspace, +`CSI G/C/D` and `CSI K` act on the cursor's row within its line; a resize re-wraps stored lines at the new width, as +a reflowing terminal does. The contract stays deliberately limited: absolute row addressing (`CSI H/f/d` with a row) +is not mapped, because inline programs cannot know where their rows are (cursor position reports are not answered), +so the cursor stays where it is; scroll regions (`CSI r`) are not emulated, except that text a program draws below a +region it set (a reserved status row, such as an installer's progress line) is not stored, so it cannot leak into the +log rows. Copy and search see what the terminal would show at the end; the +intermediate frames are not kept, as a terminal's scrollback does not keep them. Raw events in the session spool are +unchanged and replay through the same parser. Tests: single and multi-row redraws, interleaved stdout, split +sequences, clamping at the fence, NMSh lines inserted mid-redraw, metadata never edited, a real-PTY run of the +fixture from #388, and a bounded-time run of 10k frames. + +### 2. Native `/view` (#397, viewer phase of #402) + +A read-only full-height panel over one **document**: lines of text plus where they came from. + +- Sources: `/view `, `/view N` (completed output N, numbered as `/copy` numbers them), and **View output** in + block Actions. Output documents read the record's stored text; no second capture, no shell involvement. +- Files: `lstat` then `open(O_RDONLY | O_NONBLOCK)` and `fstat` on the descriptor; only regular files are read + (FIFOs, devices and directories are refused with the reason). At most 8 MiB is read; longer files show the first + part with an explicit truncation line. NUL bytes or a high control-character ratio in the first 8 KiB mean binary + and are refused. Invalid UTF-8 is shown with replacement characters and a note. Symlinks are followed (as `less` + does) and the header names the target. Reading is asynchronous and cancellable; nothing is written. +- Modes: **Text** (line numbers, wrap or horizontal scroll), **Markdown** (the existing `src/markdown` renderer for + `.md`/`.markdown` files only; output is never implicitly Markdown; `m` toggles Rendered/Source), **Diff** (unified + diff roles for `.diff`/`.patch` and output whose command is `git diff`/`git show`/`diff -u`), and syntax roles for + source files through the existing `highlightCode` when the extension is known. +- Navigation: arrows, PageUp/PageDown, Home/End, `g`/`G`, `:` go to line, `/` search with `n`/`N`, `w` wrap, + `l` line numbers, `c` copy the whole document's text (raw source), `Esc`/`q` close. Mouse wheel scrolls. +- Safety: every displayed line goes through `displaySafe`; Markdown links stay text unless the target passes + `authoredTargetAllowed`; nothing is executed, fetched or followed. Large files keep only the decoded lines in + memory (bounded by the read cap). + +The existing **Open in pager** action stays for people who prefer `less`; `/view` does not replace `less`, `bat` or +Glow, and running those commands is unchanged. + +### 3. Small native editor (`/edit`, editor phase of #402) + +Opt-in convenience for small text files, built on the viewer's document model and file checks. + +- Opens regular UTF-8 text files up to 1 MiB that the person owns, with one hard link; others open read-only in + `/view` with the reason. Line endings (LF/CRLF), a BOM and the final newline are preserved. +- Editing: insert/delete, multi-line, caret movement by character/word/line/page, Shift selection, cut/copy/paste + through NMSh's clipboard, undo/redo (bounded history), search, and a dirty marker. +- Save (`Ctrl+S`): write a temporary file in the same directory (exclusive create, original mode), `fsync`, then + check the original is still the file loaded (device, inode, size, mtime and content hash) and `rename` over the + resolved real path. A changed or replaced original is a conflict: **Overwrite**, **Keep editing** or **Discard + mine**, nothing silent. Errors (permission, full disk, read-only filesystem) are reported in the editor and the + buffer is kept. Extended attributes and ACLs are not carried over by the rename; documented. +- `Esc` with unsaved changes asks; the composer draft and shell are untouched throughout. Not an IDE: no LSP, + multi-cursor, project tree, plugins or Vim emulation. `vim`, `nano` and other editors run unchanged. + +### 4. Semantic Output (#386, first phase) + +Deterministic, post-completion, optional. One recognizer table, each entry a pure function from +`(command, stored output, exit code)` to a typed result with a role (`overview` or `detail`), the facts it read and a +completeness flag. First recognizers, chosen for usefulness and testability: + +- **Test runs**: Node's test runner/TAP (`# pass`/`# fail`), Jest/Vitest summaries, pytest's final line, `cargo test` + `test result:` lines, Go's `ok`/`FAIL` package lines. Overview: counts as the runner printed them. +- **Compiler and type-checker diagnostics**: `tsc`, GCC/Clang, `rustc`/`cargo`, Go: overview counts plus a detail list + of `file:line` locations and messages taken verbatim from the output. + +Presentation is derived rows in `TranscriptPresenter` (like fold hints), placed after the command header and before +output; they never enter stored lines. The overview appears only when it adds information (long output, or failures +whose locations are listed), never for short readable output. Raw output keeps its existing fold policy; a failure is +never folded away by a semantic view. A late result never moves rows above the reader: it is computed synchronously at +completion from bounded input (first 2 MiB), so it exists before the block is drawn. +`/copy` gains `--raw` (default and current behavior), `--semantic` (the overview and details as text, raw with a note +when there are none) and `--both` (labeled raw then semantic), mutually exclusive, combinable with existing selectors. +Settings → Transcript → Semantic Output: On (default) / Off. External providers (`jc`, `delta`) are not part of +this phase, nor are a fully collapsed raw section (raw keeps its existing fold policy) and a separate semantic search +scope (derived rows are excluded from find and selection). Details open by default when the raw output is folded +(they are what the fold hides) and closed when it is expanded; the reader's toggle is kept per block. + +### 5. `/track` (#393, reconciling draft PR #394) + +`/track` is the `/watch` scheduler (`src/tasks/WatchTasks.ts`) with PR #394's hardening carried over by hand (strict +cadence parsing, refusing substitutions, background operators and heredocs, bounded UTF-8 capture, timeouts, a stop +grace period, at most eight active trackers, copy of the latest snapshot), plus one **live transcript block**: a +header line stored once, and the latest completed snapshot drawn as derived rows that are replaced in place on every +completed sample. One current and one previous result are kept; nothing accumulates per refresh. + +- Samples run as ` -c ` in their own process group with the starting directory captured at creation, + pipes and `NO_COLOR`; they do not see the live shell's aliases, functions or unexported variables, and the block + says so. No overlap: a refresh while a sample runs is refused with the reason. +- States shown truthfully: first run pending, running, last completed time, exit status, timed out, spawn error, + stale (no completed sample in two intervals), paused, stopped. +- Ownership: trackers belong to the window, as `/watch` always has. Detaching or closing the window stops them and + their processes; the block then reads *stopped with this window*. They are not silently restored by reattach. The + service-owned supervisor (#382) is not duplicated; moving scheduling into the session service is a later step. +- Copy and search: the stored header plus, while live, the derived snapshot rows are searchable; `/copy` of a track + block and **C** in the panel copy the latest completed snapshot as plain text, never earlier samples. Stopping a + track writes its final snapshot into the transcript once, so history keeps the last result. +- `/watch` keeps its syntax and safety gate; `/track` and `/watch` manage the same trackers. + +### 6. Native modules and diagnostics (#364, #365) + +Add the matrix's remaining safe candidates with the existing declarative-file pattern: **Bun** (requested version from +`package.json` `packageManager`/`engines.bun`, `.bun-version`, `.tool-versions`/`mise.toml`; active from the install +path), **Nix shell** (`IN_NIX_SHELL` and `name` from the allowlisted live environment) and **container** (`/.dockerenv`, +`/run/.containerenv`, `container` in the environment). Each gets hostile-workspace fixtures; none runs a tool. Update +the coverage matrix with the date and decisions. For #365, close the remaining diagnostic gaps the issue lists after +auditing what v0.20 shipped: per-module cache lifetime and key scope, a slowest-collectors list, and a plain-text +explain export, reading snapshots only (opening diagnostics never starts collection). + +### 7. Theme families (#401) and action controls (#336) + +Add Tier 1 families from their canonical upstream palettes with source, revision and license recorded in +`theme-families.md`: Ayu, Kanagawa, Everforest, Flexoki, Night Owl, GitHub Primer, Oxocarbon, and Material +Palenight and a classic Monokai where licensing is verified. Existing ids and stored preferences are unchanged; +Lavender stays the default. Picker grouping and dark/light filtering are added only if the catalog makes the current +list hard to use. Contrast is checked against each variant's own background with the existing contrast helpers. +Confirmations added in this release (editor save conflicts, `/track` confirmation) use the existing +`src/ui/actionControls.ts` renderer; no new widget layer. + +## Deferred (not in v0.21) + +#398 Learn Mode; #339/#340 managed Codex/OpenCode and remote control; #337/#338 broad worktree and GitHub writes; +#330–#332 workspace expansion; #333 tmux Studio; #334 Setup Cat profiles; #335 keymap remapping; #399 host terminal +configuration; #304 Theme Bridge expansion; #341 executable mods; #296 hosted demo; #389 (no reproduction); #356 +(separate Fish typeahead race); #366 external editor provider registry. Semantic Output providers (`jc`, `delta`), +service-owned `/track` scheduling, viewer follow/tail mode and Markdown editing preview are later phases. + +## Verification + +Focused tests per slice; `npm run verify` before each PR; exact-head CI (macOS and Ubuntu, Node 22 and 26, Fedora) +before merge; `verify:release` and the timing smoke at the integration checkpoint. Physical Ghostty and Terminal.app +checks are listed per slice in the ledger and are not claimed from automated runs. diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..7e5c89e8 --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,77 @@ +# Live command tracking (`/track`) + +`/track` (#393) keeps **one live result** of a finite command in the transcript, refreshed in place, while the shell +stays free for other commands. It runs on the existing `/watch` scheduler (`src/tasks/WatchTasks.ts`), whose hardening +was first written in draft PR #394 and carried into v0.21; `/watch` keeps its own behavior (its manager panel and +what-changed view) and both names manage the same trackers. + +## Usage + +```sh +/track df -h /Volumes/T7Projects +/track --every 5s -- docker system df +/track # the manager (same as /watch) +/track refresh [N] # one refresh now (never a second, overlapping one) +/track pause [N] · /track resume [N] +/track copy [N] # copy the latest completed result, as plain text +/track stop [N] · /track stop all +``` + +`N` is the tracker's position in the manager (the newest when omitted). The `--` after the cadence is optional but +keeps a command's own flags from reading as NMSh's. + +## What the transcript shows + +- The command line and one note, stored once like any NMSh block: what is tracked, where, and how it runs. +- Under them, drawn live and **never stored**: a status row and the latest completed result. The status says only what + is known: first run pending, `live · every 5s · updated 13:49:25 · exit 0 · 12 runs`, a failing exit code, a timeout, + a spawn error, `paused`, `no new result for 1m 20s` (stale), `refreshing…` while a sample runs. +- The result keeps a stable height (the largest seen, at most 12 rows) so a shorter refresh never moves what is below; + a longer one says how many lines are not shown. `/track copy` copies all of it. +- Live rows are presentation: find, selection and `/copy` never include them, and nothing accumulates per refresh. +- Stopping a tracker (`/track stop`, or S in the manager) writes its **last result once** into the transcript, so + history keeps it. A tracker whose window closes leaves only its stored lines. +- Above the composer, each active tracker has a compact row; with more than two, one summary row (`4 trackers · 1 + failing · /track opens the manager`) replaces them. `clear` removes the block from the transcript; the tracker keeps + running and stays in the manager. +- Safe glyphs use ASCII state marks (`*` live, `=` paused, `o` pending, `#` stopped). + +## Execution and safety + +- Every sample is `sh -c ` in its own process group, from the directory the tracker started in, with NMSh's + environment and the live shell's PATH. It does not see the shell's aliases, functions, unexported variables or + options, and it does not follow later `cd`s; the note in the block says so. +- Default interval 5 s; at least 2 s locally and 15 s for remote network targets; at most 60 minutes. Invalid + intervals fail before anything runs. At most 8 active trackers. A sample never overlaps another of the same tracker; + a refresh while one runs is refused with the reason. +- Repeated commands can repeat side effects, so repetition has its own gate (`src/tasks/repeatSafety.ts`) on top of + the shared command classifier, and it asks when unsure: + - **Start at once:** read-only commands, test and check runners, the exact `docker system df`, and read-only network + probes: `ping`, `dig`, `nslookup`, `host`, `traceroute`, `nc -z`, a curl GET or HEAD to standard output (or + `-o /dev/null`), `wget -O -` or `--spider`, and package queries (`npm view`, `outdated`, `audit`). + - **Ask first (an explicit Yes):** unknown commands; `NAME=value` prefixes and `env ` (they change or run + another command); git's `-c`, `--config-env`, `--exec-path` and `--ext-diff`; `git fetch`; anything that sends + data, saves files, signs in, publishes or runs a downloaded package (`curl -X POST`, `-d`, `-o file`, non-HTTP + URLs, plain `wget`, `ssh`, `sftp`, HTTPie, `npm publish`, `npm login`, `npm x`, `pnpm dlx`, `npm audit fix`). + Network targets other than this computer also ask, and their minimum interval is 15 s. + - **Refused:** installing, modifying, destroying, privileged, downloaded-code and `cd` commands; substitutions, + background `&` and heredocs; any output redirection into a file (judged per redirection, so `2>/dev/null` never + excuses `> file`; `2>&1` is fine); options that write or update files (`git … --output`, `sort -o`, `find + -fprint`/`-fls`, `tree -o`, `yq -i`, `--fix`, `--write`, `jest -u`, snapshot updates); `scp` and `rsync` (except + a dry run); `--watch` modes that never finish; shutdown, reboot, halt and power-off. +- Each sample keeps at most 200 kB of stdout and stderr (decoded as UTF-8 across chunk boundaries), says when it was + cut, and has its terminal control sequences removed. A sample is stopped after the lesser of 60 s or three + intervals and shown as timed out. Stopping signals the owned process group (TERM, then KILL after a grace period). + When a sample ends, anything still in its process group (a child that ignored TERM, or one that closed its output and + kept running) gets TERM and then KILL, so trackers never accumulate processes; a closing window waits for that + bounded grace period. Processes that deliberately leave the group (`setsid`, daemons) are not followed. +- A tracker whose directory disappears says so (`the directory … is no longer available`) and keeps trying at its + interval, so a remounted volume resumes on its own. + +## Ownership and limits + +Trackers belong to the window that started them, as `/watch` always has: detaching or closing the window stops them +and their processes. They are not restarted on reattach; the stored lines remain. Service-owned tracking that survives +detach would reuse the session's background-task supervisor (#382) and is not part of v0.21. + +Physical terminal checks are listed in `docs/testing/v021-physical-qa.md`. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md new file mode 100644 index 00000000..11872072 --- /dev/null +++ b/docs/development/v021-ledger.md @@ -0,0 +1,277 @@ +# v0.21 ledger + +Durable state of the v0.21 candidate ([#410](https://github.com/raiseCatError/notMyShell/issues/410)) so a later +session can resume without rediscovering it. Design and acceptance: [v0.21 design](../design/v021-terminal-intelligence.md). +Automated results here are not physical-terminal validation. + +## Baseline (verified 2026-10-18) + +- `master` = `dev` = `6af5cbab`; latest release v0.20.0 (`65e4de55`); CI green on both. +- Open PRs: draft #394 (`/track` first slice, based on an older line; reconciled by hand in slice 5, not merged; + closed as superseded by #416 in the release audit). +- Work happens in disposable worktrees under `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/`; scratch on the T7. + +## Slices + +| # | Slice | Issues | Branch / PR | Merged commit | State | +| --- | --- | --- | --- | --- | --- | +| 0 | Design and ledger | #410 | `docs/v021-design` / [#411](https://github.com/raiseCatError/notMyShell/pull/411) → `dev` | `a412fe53` | Merged into `dev` | +| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` / [#412](https://github.com/raiseCatError/notMyShell/pull/412) → #411 | `3282ad40` | Merged into `dev` | +| 2 | `/view` | #397, #402 | `feature/v021-view` / [#413](https://github.com/raiseCatError/notMyShell/pull/413) → #412 | `e945c75a` | Merged into `dev` | +| 3 | `/edit` | #402 | `feature/v021-edit` / [#414](https://github.com/raiseCatError/notMyShell/pull/414) → #413 | `934ba7ce` | Merged into `dev` | +| 4 | Semantic Output | #386 | `feature/v021-semantic` / [#415](https://github.com/raiseCatError/notMyShell/pull/415) → #414 | `a97b3f77` | Merged into `dev` (one rerun) | +| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | `a5f2fd69` | Merged into `dev` | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | `ac543bd0` | Merged into `dev` (one rerun) | +| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | `0ea7ca39` | Merged into `dev` | +| 8 | Checkpoint ledger | #410 | `docs/v021-checkpoint` / [#419](https://github.com/raiseCatError/notMyShell/pull/419) → #418 | `f8e53541` | Merged into `dev` | +| 9 | `/track` refresh and finalization lifecycle | #393 | `fix/v021-track-review-hardening` / [#420](https://github.com/raiseCatError/notMyShell/pull/420) → #419 | `d612f120` | Merged into `dev` | +| 10 | `/track` review: repetition safety gate, process cleanup, presentation | #393 | `fix/v021-stabilize` / [#421](https://github.com/raiseCatError/notMyShell/pull/421) → #420 | `0eca52d6` | Merged into `dev` | +| 11 | Flaky CI at the cause | #410 | `fix/v021-flaky-tests` / [#422](https://github.com/raiseCatError/notMyShell/pull/422) → #421 | `ac0f780a` | Merged into `dev` | +| 12 | Bugs: #388 wrapped rows, command colors after a slow zshrc, #365 width | #388, #408, #365 | `fix/v021-bugs` / [#423](https://github.com/raiseCatError/notMyShell/pull/423) → #422 | `7cb857f4` | Merged into `dev` | +| 13 | Stabilization ledger | #410 | `docs/v021-stabilization` / [#424](https://github.com/raiseCatError/notMyShell/pull/424) → #423 | `433af8fd` | Merged into `dev` | + +## Decisions + +- #403 is a test-fixture race (Fish's detached completion generator), not a product leak; the fix makes test homes + hermetic rather than adding retries. +- #408 is a production race in `SemanticService` (superseded lookups resolved `unknown`), plus a test that raced the + real shell's startup knowledge. +- `/track` stays window-owned like `/watch` in v0.21; service-owned scheduling is deferred and documented. +- PR #394's `WatchTasks.ts`/`WatchPanel.ts` were adopted as written (neither file changed on `dev` since its base) and + its tests kept; its other edits were re-applied by hand. #394 itself is superseded by the slice 5 PR and was not merged. +- #336: the editor's unsaved-changes and conflict dialogs use the existing `src/ui/actionControls.ts` renderer (slice 3); + no other v0.21 surface needed a new control, so no further consolidation was made. +- #401: Oxocarbon, GitHub Primer, Material/Palenight and Monokai were deferred (see theme-families.md for why). +- `/track` samples run `sh -c` (as `/watch` always has) with the live shell's PATH, not the session shell, so `/watch` + semantics stay the same for Fish users. + +## Physical QA still required + +Checklist: [v0.21 physical QA](../testing/v021-physical-qa.md). Slice 1: section 1 (multi-row progress in Ghostty +and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). Slice 6: section 6 (Bun, Nix, container, reports). Slice 7: section 7 (theme families, filter, contrast, host untouched). + +## Verification notes + +- Slice 1 evidence: each new regression fails on the unfixed code and passes on the fix (Fish generator present; + superseded lookup settled `unknown` and the stored line never recolored; 9 of 10 redraw tests fail on the old parser, + the tenth being the documented unchanged absolute-positioning case). #408 tests passed 16/16 in two concurrent loops. +- Local full `npm run verify` on this 8 GB Mac: run with `NMSH_TEST_TMPDIR` at the T7 storage root + (`/Volumes/T7Tmp/ai-dev`), never a deeper directory: the runner requires test roots under 32 characters for Unix + sockets, and a longer root fails the runner self-test, tmux and session-socket tests for environmental reasons. + Live PTY tests can time out under full-suite load here and pass alone; exact-head CI is the independent gate. + +## Open risks + +- Merging into `dev`: done (see "Ordered integration" below). +- #426 (pre-existing): under heavy load, a reply typed right after a prompt-less read starts can be queued. Its live + test can fail on a loaded host; the test's own race is fixed in #427. +- #388: absolute rows (`CSI H/f/d`) are deliberately not mapped and scroll regions are not emulated (a program's + status row below its region is not stored); the contract is explicit and tested (stabilization). +- Nothing in v0.21 has had a physical-terminal pass yet. Every slice issue stays open until its pass is recorded. +- The three CI flakes seen during the stack were fixed at their cause in #422 (two were product defects). + +## Integration verification (stack head, 2026-10-18) + +Run on `feature/v021-themes` (all eight slices) in a T7 worktree, `NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. + +- `npm run verify:release`: 3,118 tests, 3,115 pass, 2 skipped, 1 fail. The failure, + `nativeCaptureLifecycle` "native cancellation reaps a PTY before it can process the initialization command", timed + out waiting for the inner shell under full-suite load; the file passes 3 of 3 alone. v0.21 does not touch native + capture. +- `npm run typecheck:bench` passes; `npm run bench:smoke` passes (see "Performance" below). +- CI on each stacked PR (Ubuntu and macOS shards, Node 22 compatibility, Quality): green on #411–#419 at their current + heads. Flakes seen once each, all in code v0.21 does not change and all passing on rerun: #415's Ubuntu shard hit + the 10-minute job limit with output stalled in `tests/shellFrameworks.test.ts` (an orphaned Fish process); #417's + macOS shard failed `tests/backgroundSession.test.ts:92` (task removal timing); #417's Ubuntu shard failed + `tests/configuredCompletion.test.ts:89` (the helper's private root still present just after a startup timeout). + Each is worth its own issue. + +## Performance + +- `npm run bench:smoke` at the stack head (`8bb85d5e`) passed every budget: pty-ready p50 23 ms, service-ready p50 + 233 ms, cold context collection p50 48 ms, every-module render p50 39 ms, configured completion warm p50 10 ms, + `transcript/wrap-present-10000` p50 48 ms, `transcript/wrap-present-100000` p50 467 ms. +- `transcript/wrap-present-100000` is noisy on this 8 GB Mac: a `dev` baseline worktree measured p50 501–606 ms + across runs, and earlier stack runs 484–1,161 ms with the slow ones not repeating. No consistent regression was + found. The one per-row cost v0.21 adds, the `/track` live-row lookup, is now skipped entirely when the transcript + has no trackers (#416). +- Bounded by design and covered by tests: `/view` layout per line (a 1 MB single line no longer takes 17 s), file + loads (8 MiB / 200,000 lines), `/edit` (1 MiB), Semantic Output recognizers (linear, 2 MiB scanned, 20 items), + `/track` samples (200 kB, 12 live rows, at most 8 trackers). + +## Stabilization (2026-10-18) + +A second pass after the stack: every open `type:bug` issue, a joint review of `/track` (#416 and #420), the CI +flakes, and the v0.21 integration. Linux evidence comes from a Debian container (Node 26.12, zsh 5.9, Fish 3.6, +Bash 5.2) whose disk is on the T7 (Colima, `/Volumes/T7Projects/.colima-suma`); macOS from this Mac (Node 26.10, +Fish 4.9.3). Every new regression test was run against the unfixed code first and failed there. + +**`/track` review (#421).** Commands started at once and repeated without asking: `echo x > ~/.zshrc 2>/dev/null` +(the shared classifier excused every redirect once `>/dev/null` appeared; paste review called it read-only), +`env rm …`, `npm publish`/`login`/`audit fix`, `npm x`/`pnpm dlx`/`bun x`, `ssh host rm …`, `scp`, `rsync`, +`curl -X POST`/`-d`/`-o`, plain `wget`, `git -c core.fsmonitor=… status`, `git log --output=…`; `npm test 2>&1` was +wrongly refused. A repetition gate (`src/tasks/repeatSafety.ts`) now sits on top of the classifier, and `&>` stays +refused (dash reads it as a background job). A TERM-ignoring child survived `/track stop` because the close handler +cancelled the KILL fallback; leftovers in a sample's process group are now ended. #420's refresh event fires after +the child is wired; a vanished directory is named; many trackers share one summary row; Safe glyphs; result rows +cached per result (1–3 ms to ~0.5 ms per frame with eight large trackers). #420 itself: no re-entrancy, stale +reference or duplicate finalization found. + +**Flaky CI (#422).** `backgroundSession.test.ts:92` was a product defect: a cleanly exiting background task showed +"exited, processes left" (and Delete was refused) until its guard confirmed the group empty; the supervisor now +finishes on the guard's verdict. `configuredCompletion.test.ts:89` was a product defect: removing the helper's +private root raced the helper's own `rm -rf` and late pid writes (rmSync threw ENOTEMPTY on macOS, returned with the +directory present on Linux); the root is renamed away first, then removed entry by entry. The 6-minute Ubuntu hang +was a test that stalled past its timeout with a live shell PTY: the runner now preloads a file teardown that ends, +and names, what a file left running. `nativeCaptureLifecycle`: its tight bound no longer covers a cold `tsx` start. A fourth +flake appeared once on `dev` itself (dispatched CI): `codexAdapter.test.ts:84` read the fake Codex server's log when +the turn settled, before the server had read the client's last response; the fixture now forces that order every run +and the helper waits for what the test asserts. + +**Bugs (#423).** #388: the live region moved by stored lines while programs count terminal rows, so a redraw over a +wrapped line erased the program's earlier output at narrow widths (parser, real PTY at 40 columns, and through the +frontend); rows are now the terminal's rows. Found verifying #408: a zsh configuration slower than 1.5 s made the +first semantic lookup time out and end the helper, so commands lost their known/unknown color for the session; +lookups are now timed from when the helper reads them. #365: `/modules explain` says when the Rail or Strip left a +module out, or shortened it, for width in the last frame. + +**Investigated, not reproducible.** #356 (Fish typeahead after a read): 40/40 on macOS Fish 4.9.3 under 8× parallel +load; 22/22 Linux Fish 3.6 runs that got past startup under 2× load (the other 2 stalled on VM I/O before drawing). +#403: every interactive Fish in tests has a home marked generated; non-interactive uses never start the generator. +#408's generation/re-ask synchronization holds. + +**Also checked.** Semantic Output: 99 adversarial input/recognizer combinations up to a 2 MB single line, none over +150 ms. Docker-specific `/track` checks were not run against a Docker daemon of NMSh's own (the only one available +runs the maintainer's containers); `docker system df` stays the one Docker verb allowed at once, by unit test. + +**Verification (stabilization).** +- CI on each new PR (#420–#423: macOS and Ubuntu shards, Node 22 compatibility, Quality): green at their heads. +- Full CI dispatched on the stack head, which also runs the Fedora portability subset and both timing smokes: + `bb210ce5` and the final head `e8411e1a` (test-only changes since), all jobs green on both. +- Timing smoke against `dev` in the same window (dispatched pair, CI runners): Ubuntu `pty-ready` p50 415 vs 416 ms, + `service-ready` 580 vs 580 ms, `transcript/wrap-present-100000` 811 vs 848 ms; macOS `pty-ready` 28 vs 33 ms, + `transcript/wrap-present-100000` 1,132 vs 1,165 ms. No consistent regression; single runs vary by more than that. +- The paired `dev` run failed a fourth flake (`codexAdapter.test.ts:84`), fixed in #422 (above). +- Local `npm run verify:release` on this 8 GB Mac could not give a clean full-suite result during the session: the + machine was shared with other workloads (two container queue workers at about 120% CPU each in the Colima VM, other + agent sessions, a browser), with load averages of 35–167 and 4.6 GB of swap in use. One run (`bb210ce5`, load 35–67) + had 3,127 of 3,142 passing, 2 skipped and 13 failures, all live tests timing out (tmux, screen, service lifecycle, + per-frame budgets) plus two capture tests whose windows included the owner's cold `tsx` start (fixed in #422). All + seven failing files then passed 69/69 three times in a row at normal load. A second run on `e8411e1a` (load + 3–13): 3,138 of 3,142 passing, 2 skipped, 2 failed, both timing bounds in code v0.21 does not change that pass + alone 5/5 (a Node stand-in for zoxide whose cold start exceeded the 1.5 s query limit, and a 4 s startup budget + for a 5,000-candidate compinit); both tests were hardened in #422. Typecheck of the benchmarks and the timing smoke + ran only in CI (verify stops at the first failing step). A clean local run on `dev` after integration remains a + gate. + +## Ordered integration + +Done on 2026-10-18. #411 to #424 were merged into `dev` in order with regular merge commits (table above). `dev` is +`433af8fd`, whose tree is identical to #424's head `06bc2034`. Every PR head is an ancestor of `dev`, and so is +`master` (`6af5cbab`), so the release needs no conflict resolution. + +## Release audit (2026-10-18) + +An independent pass over the integrated `dev` at `433af8fd`, from a clean detached worktree on the T7 (`npm ci`: 25 +packages, 0 vulnerabilities). Machine: macOS 27 arm64, 8 GB, Node 26.10.0, zsh 5.9, Bash 5.3.20, Fish 4.9.3, with +`NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. It was shared throughout (a browser, ten Docker containers in a VM, another +agent session), with 5.4–6.7 GB of swap in use. + +**CI on the exact commit.** Push run [39135227830](https://github.com/raiseCatError/notMyShell/actions/runs/39135227830) +on `433af8fd`: all 13 jobs green. That covers Quality (Node 26: build, benchmark typecheck, fast suite, diff check), +the full suite on macOS and Ubuntu (Node 26, two shards each), Node 22 compatibility, the Fedora portability subset, +timing smoke on macOS and Ubuntu, and the `CI` and `Verify (22.x, 26.x)` aggregates. + +**Local gates on `433af8fd`.** + +| Gate | Result | +| --- | --- | +| `npm ci` | PASS, 0 vulnerabilities | +| `npm run build` | PASS | +| `npm test` (canonical suite, 4 workers) | FAIL: 3,142 tests, 3,139 pass, 2 skipped, 1 fail, 553 s, load 3 rising to 16 | +| `npm run typecheck:bench` | PASS (run on its own: `verify:release` stops at its first failing step) | +| `npm run bench:smoke` | PASS, every budget, at load 4–5 | +| `git diff --check` | PASS | + +The failure is `inputOwnershipLive` → `live zsh: a waiting read gets the answer typed at once …`. The answer to a +prompt-less `read` was queued, so the read never got it. The file alone passed 3/3 at load 5–8. Four copies at once +(load rising from 8 to 27–33) failed in 2 of 4 copies on `dev` and 3 of 4 on `master` `6af5cbab`, so this is +pre-existing, not from v0.21. Two mechanisms, told apart by a diagnostic run that recorded the queue entries: + +- **Product, pre-existing, open as #426.** A key typed before NMSh has evidence that the command reads input is + held for at most 800 ms while the session looks. On macOS a prompt-less read needs two process-table samples, and + when the look misses the deadline, Enter queues. This was the full-suite failure, and it reappeared at load 20–33 + after the test fix. It was not seen at ordinary load or in CI. +- **Test race, fixed in #427.** The test typed its follow-up `echo BACK-…` as soon as the answer's output appeared + and checked the queue at once. Before the shell's prompt for the answered command arrives, Enter queues, as designed. + This was the CI failure on #425 (Ubuntu) and most of the parallel failures; the recorded entry was the test's own + follow-up. + +**Benchmarks** (`bench:smoke` at `433af8fd`, load 4–5), p50: pty-ready 24 ms, service-ready 233 ms, cold context +collection 50 ms, every-module render 40 ms, configured completion warm 8.8 ms, `transcript/wrap-present-10000` 41 ms, +`transcript/wrap-present-100000` 444 ms. Every one is within budget and within noise of the stack-head numbers above. + +**Code review,** highest impact first: `/edit` saving, `/view` loading, the `/track` lifecycle (`WatchTasks`, +`repeatSafety`, `trackBlock` and the `TerminalApp` wiring), the parser's live region, Semantic Output bounds and caching, +`SemanticService` startup timing, the new module capabilities, and what the Homebrew formula installs. + +- Fixed in #425: a new file in a folder reached through a symlink (macOS `/tmp`, `/var/folders`) hit a false "was + replaced on disk" conflict on its second save. The new file kept its unresolved path as its real path. The + regression fails on `dev`. Physical check 3.9. +- Fixed in #427 (test only): the race above. +- Filed as #426: the product behavior above. +- Noted, not changed: + - The reattach notice says `zsh pid` for every shell (`TerminalApp.finishReplay`, since `b0bd6878`, before shell + backends). Cosmetic. + - `clear` removes `/track` blocks with every other block. The tracker keeps running and its summary row stays, and + `/track stop` writes the final result, which matches `clear`'s contract. + - Stopped trackers keep their last two results until 128 records accumulate. That is bounded, but not small. +- Checked, no defect found: + - `/view` refuses FIFOs and devices before anything blocks, reads in bounded chunks and cuts UTF-8 on a character + boundary. + - `/edit` uses an exclusive temporary file, syncs it, checks the original's identity before renaming, refuses hard + links and keeps symlinks. + - `/track` runs each sample in its own process group, escalates TERM to KILL, ends stragglers after every sample, + refuses overlapping refreshes and ends everything when the window closes. + - `SemanticService` resolves pending lookups when it is killed during startup. + - Semantic readings are cached per record in a `WeakMap`. + - The built-in context packs are copied into `dist`, and the Homebrew formula's install list still covers every + file v0.21 reads at runtime: all new code compiles into `dist`, and no new assets were added. + +**Issues and PRs.** + +- Closed: #403 (a test fixture; verified on `dev` and in CI). Draft PR #394, as superseded by #416 (its scheduler, + panel and all 8 tests are on `dev`). +- Open until their physical passes are recorded: #388, #393, #397, #401, #402, #408 (pass 1.7), #364, #365, #386 and + #336. The work still out of scope stays on each issue: + - #386: external providers, a fully collapsed raw view and a separate semantic search scope. + - #401: four families deferred. + - #402: the provider registry (#366). + - #364: Kotlin/Scala, Lua and Haskell as candidates. + - #365: no per-segment truncation trace for the Main Prompt. + - #336: no other surface needed a new control. +- #356 stays open: it was not reproduced, and passing its reproducer does not close it. +- #410 tracks the release. + +## Release readiness + +**Not released, and not ready to ship until physical QA passes.** Integration, exact-commit CI and the code review are +done. Remaining gates, in order: + +1. Merge #425 and #427 into `dev` once their CI is green. This ledger's PR follows. +2. **Release candidate:** a `release/v0.21.0` branch from `dev` with one commit holding the version (`0.21.0`), the + changelog's `[0.21.0]` candidate section and the status pointers, opened as a draft PR to `master`, as v0.20 did + (#392). Then dispatched CI on its exact head (Fedora and both timing smokes) and a local `verify:release` on it. +3. **Physical QA, mandatory and not run:** every pass in the [checklist](../testing/v021-physical-qa.md), on the + candidate build, recorded in its results table. Headless PTY tests and CI do not count. +4. Then, with the maintainer's go-ahead: date the changelog section, merge the release PR into `master`, then the tag, + GitHub release, Homebrew tap and website, as in the [release runbook](v021-release-runbook.md). Close the issues + whose acceptance is met. + +Known limitations shipped deliberately: `/track` is window-owned (it stops on detach); Semantic Output is its first +phase (no `jc`/`delta` providers); inline absolute cursor positioning is not mapped and scroll regions are not +emulated (#388 contract); the Main Prompt has no per-segment truncation trace (#365); four theme families are deferred +(#401); type-ahead *during* a Fish read stays subject to Fish's own editor queries (#356); on a heavily loaded machine +a reply typed within about a second of starting a prompt-less read can be queued (#426, pre-existing). diff --git a/docs/development/v021-release-runbook.md b/docs/development/v021-release-runbook.md new file mode 100644 index 00000000..211f6e7f --- /dev/null +++ b/docs/development/v021-release-runbook.md @@ -0,0 +1,126 @@ +# v0.21.0 release runbook + +The exact steps from the v0.21 release candidate to a published v0.21.0, following the v0.20.0 release (PR #392, +tag `v0.20.0` on its merge commit, Homebrew tap PR #3, website sync). State and evidence are in the +[ledger](v021-ledger.md). **Nothing here runs without the maintainer's go-ahead after physical QA.** Tagging, the +GitHub release, the tap and the website deploy are public and hard to undo. + +## How a release moves + +`dev` → `release/v0.21.0` (one release-metadata commit) → PR into `master`, merged with a regular merge commit → +annotated tag `v0.21.0` on that merge commit → GitHub release (no uploaded assets; the source archive is GitHub's tag +archive) → Homebrew tap PR (source formula: `url` and `sha256` of the tag archive) → website sync PR into `master` → +manual **Publish NMSh website** dispatch, approved in the `nmsh-production` environment. No npm package is published +(`package.json` is `private`). `master` needs the `Verify (22.x)` and `Verify (26.x)` checks; the old GitHub Pages +deploy (`deploy.yml`) is disabled. + +## 0. Before starting + +- The physical QA results table has a recorded result for every row on the candidate build (or the maintainer has + decided, in writing, which rows a release may go without, as v0.20 did). +- Dispatched CI is green on the **exact** head of `release/v0.21.0`. Code changes need the full dispatch: Fedora and + both timing smokes run only on dispatch and push. +- `npm run verify:release` has run on that head locally, and any failure is explained by evidence. + +```sh +git fetch origin +RC=$(git rev-parse origin/release/v0.21.0) +gh run list --workflow CI --commit "$RC" --json event,conclusion,url # a workflow_dispatch run, success +``` + +## 1. Final release commit (on `release/v0.21.0`) + +1. `CHANGELOG.md`: `## [0.21.0] - Unreleased (release candidate)` becomes `## [0.21.0] - YYYY-MM-DD` (the + publication date); remove the candidate sentence; keep Compatibility, Upgrade notes and Known limitations accurate + to the QA results. Link references at the bottom: `[Unreleased]: …/compare/v0.21.0...HEAD` and + `[0.21.0]: …/compare/v0.20.0...v0.21.0`. +2. `docs/testing/v021-physical-qa.md`: the recorded results and the release decision. +3. README, ROADMAP, AGENTS, SECURITY, CONTRIBUTING and `llms.txt`: v0.21.0 is the current stable release. +4. Commit, push, dispatch CI on the new head and wait for it: + +```sh +gh workflow run CI --ref release/v0.21.0 +FINAL=$(git rev-parse origin/release/v0.21.0) +gh run list --workflow CI --commit "$FINAL" --json event,conclusion,url +``` + +## 2. Merge into `master` + +```sh +gh pr ready +gh pr merge --merge # a regular merge commit, as #392 +git fetch origin +MERGE=$(git rev-parse origin/master) +test "$(git rev-parse "$MERGE^{tree}")" = "$(git rev-parse "$FINAL^{tree}")" && echo "tree matches the verified commit" +``` + +## 3. Tag and GitHub release + +```sh +git tag -a v0.21.0 -m "v0.21.0 — " "$MERGE" +git push origin v0.21.0 +gh release create v0.21.0 --verify-tag --latest --title "v0.21.0 — <title>" --notes-file <notes.md> +``` + +The release body becomes the website's release page at the next sync. Write it from the changelog section (a shorter +summary is fine) and link to the full CHANGELOG. + +## 4. Verify the published source + +In a shell started with `t7run zsh` (so `$T7_RUN_DIR` is a scratch directory on the T7): + +```sh +mkdir -p "$T7_RUN_DIR/v021-archive" && cd "$T7_RUN_DIR/v021-archive" +curl -fsSL -o v0.21.0.tar.gz https://github.com/raiseCatError/notMyShell/archive/refs/tags/v0.21.0.tar.gz +shasum -a 256 v0.21.0.tar.gz # the tap's sha256 +tar -xOzf v0.21.0.tar.gz notMyShell-0.21.0/package.json | grep '"version"' # "0.21.0" +mkdir src && tar -xzf v0.21.0.tar.gz -C src && cd src/notMyShell-0.21.0 +npm ci && npm run build && node dist/index.js --version # notMyShell 0.21.0, build <MERGE short sha> +``` + +## 5. Homebrew tap (`raiseCatError/homebrew-tap`) + +The scheduled `brew bump` failed daily from 2026-10-13 to 10-17 (`depends_on "python"`); v0.20.0 was a manual PR (#3). +Do the same unless an autobump PR for 0.21.0 has already appeared and is correct: + +1. Branch `nmsh-0.21.0`; in `Formula/nmsh.rb` set `url` to `…/archive/refs/tags/v0.21.0.tar.gz` and `sha256` to the + value from step 4. Nothing else needs changing: every file v0.21 reads at runtime is in `dist`, and the formula's + install list is unchanged since v0.20. +2. Open the PR; `tests.yml` (`brew test-bot`) must pass on macOS and Linux; merge (source-only formula, no bottles). +3. Locally: `brew update && brew upgrade raiseCatError/tap/nmsh && nmsh --version`. + +## 6. Website + +After the tap PR is merged (the sync records the tap's version, and the deploy checks it again): + +```sh +cd website && npm ci +npm run sync:upstream -- --published v0.21.0 --version 0.21.0 +npm test && npm run check && npm run build:launch && npm run test:browser +``` + +Open a PR into `master` with the result. Once it is merged, dispatch **Publish NMSh website** from `master` with the +confirmation `nmsh.raisecaterror.com`, approve the `nmsh-production` deployment, and check the live site. + +## 7. After the release + +- Smoke on the published build: a Homebrew install (`nmsh --version`, start in Ghostty, run a command, `/view + README.md`, `/track df -h`, `/track stop`, `exit`) and a source install's `/update` (offers v0.21.0 as the latest + stable release). +- Bring `dev` to `master`: `git switch dev && git pull --ff-only origin dev && git merge --ff-only origin/master && + git push origin dev` (`master` = `dev` after v0.20 too). +- Close the issues whose acceptance is met, with the physical results; keep partial ones open with the remaining + scope (ledger, "Issues and PRs"). + +## Rollback + +- **Before the tag:** nothing is public yet. A bad merge into `master` is reverted through a PR. +- **A bad release after publication:** fix forward with v0.21.1. A pushed tag may already be cached by Homebrew, + GitHub's archive and users, so do not move or recreate it. Meanwhile: + - `gh release edit v0.20.0 --latest` makes `/update` (it reads `releases/latest`) offer v0.20.0 again. + - Mark v0.21.0 as a prerelease (`gh release edit v0.21.0 --prerelease`) so it is no longer offered. + - Revert the tap PR (formula back to the v0.20.0 `url` and `sha256`) through a tap PR. + - Website: revert its sync PR on `master` and dispatch the publish again (approval required), or roll the Pages + project back to its previous deployment. +- **The tag points at the wrong commit and nothing has consumed it yet** (no release, tap or sync): delete it + (`git push --delete origin v0.21.0`) and tag the verified merge commit. Otherwise fix forward. diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md new file mode 100644 index 00000000..f6f4b2bd --- /dev/null +++ b/docs/testing/v021-physical-qa.md @@ -0,0 +1,234 @@ +# v0.21 physical QA + +## Status + +- **Candidate, not released.** Every v0.21 slice is merged into `dev` (#411–#424, `433af8fd`); see the + [ledger](../development/v021-ledger.md). Nothing below has been run yet. The [walkthrough](#walkthrough) groups the + passes into three sittings with ready-made fixtures. +- **What does not count:** headless PTY fixtures, synthetic keys, captures of a window driven that way, and green CI. + Each item needs a person at a real terminal with a real keyboard and pointer. +- **Build under test:** the release candidate commit, built from a clean checkout (`npm ci && npm run build`); record + `node dist/index.js --version` (version and build commit) in every row. +- **Isolation:** `scripts/qa/v021-fixtures.sh` makes a throwaway folder with its own NMSh configuration + (`XDG_CONFIG_HOME`) and session service (`NMSH_RUNTIME_DIR`), so the QA build neither reads your settings nor + attaches to your running sessions. `HOME` is unchanged: your shell's own startup files run as usual, and copy + items replace your clipboard. + +## Results + +| Pass | Host and version | OS | Shell and version | Commit | Result | Notes | +|---|---|---|---|---|---|---| +| 1 Multi-row progress (#388) | Ghostty | macOS | zsh | | Not run | | +| 1 Multi-row progress (#388) | Terminal.app | macOS | Bash or Fish | | Not run | | +| 2 `/view` | Ghostty | macOS | zsh | | Not run | | +| 2 `/view` | Terminal.app | macOS | zsh | | Not run | | +| 3 `/edit` | Ghostty | macOS | zsh | | Not run | | +| 3 `/edit` | Terminal.app | macOS | Bash or Fish | | Not run | | +| 4 Semantic Output | Ghostty | macOS | zsh | | Not run | | +| 5 `/track` | Ghostty | macOS | zsh | | Not run | | +| 5 `/track` | Terminal.app | macOS | Fish | | Not run | | +| 6 Modules | Ghostty | macOS | zsh | | Not run | | +| 7 Themes | Ghostty | macOS | zsh | | Not run | | +| 7 Themes | Terminal.app | macOS | zsh | | Not run | | + +Not available on the QA Mac, with the coverage that exists instead: 1.5b (`apt` progress; Linux only: parser and live +PTY tests, Ubuntu and Fedora CI), 4.3 `cargo build` (not installed; Rust diagnostics recognizer tests), 6.2 +(`nix-shell`; the module reads only `IN_NIX_SHELL`, unit-tested), 6.3 inside a container (marker files and host +variables, unit-tested; the host half is checked here). Each stays *Not run* in the table, never *Pass*. + +## Walkthrough + +Three sittings, about 90 minutes in all. Every command below is typed in NMSh unless it says "second tab". + +**Prepare once.** In a plain terminal window (not inside NMSh): + +```sh +cd /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v021-qa # the release candidate build +node dist/index.js --version # record version and build commit +scripts/qa/v021-fixtures.sh /Volumes/T7Tmp/ai-dev/q21 +``` + +Each sitting starts from a plain window: `/Volumes/T7Tmp/ai-dev/q21/nmsh` (zsh), `… nmsh fish` or `… nmsh bash`. NMSh +opens in `q21/work`; paths below are relative to it. Quit with `exit`. If a launch offers to resume a detached +session (after 5.8), press R and `exit` it. + +**Reporting.** One line per pass, for example `Pass 2 · Ghostty 1.3.1 · zsh 5.9: PASS` or `Pass 2: FAIL 2.7 (the FIFO +hung)`; a screenshot helps. Each result is recorded in the table with the build commit. A skipped item is *Not run*. + +### Sitting A: Ghostty, zsh (passes 1–7, about 60 minutes) + +1. **Progress.** 1.1 as listed, then at 40 columns. 1.2 `node progress/multirow.mjs`: two `layer` rows count up in + place; `LOG pulling two layers`, `LOG checkpoint 1`–`4` and `LOG done` all stay, in order. 1.3 `node + progress/multirow.mjs 120` (30 s) while you resize. 1.4 `/copy`, then `pbpaste`: the final rows once. 1.5 as listed. + 1.5a at 40 columns `node progress/wrapped.mjs`: both `LOG kept line` lines and `LOG done` stay, with one `Downloading + … 100%` and one `eta 0s`. 1.6 `vim view/tabs.txt`, `less view/README.md`, `top` (htop is not installed). 1.7 `sleep + 5`, then `ls` Ctrl+Q and `pwd` Ctrl+Q while it runs. +2. **`/view`.** 2.1 `/view view/README.md`. 2.2 `/view view/ViewerPanel.ts`. 2.3 in either. 2.4 as listed. 2.5 `/view + view/tabs.txt`, `c`, Esc, `pbpaste | cat -vet`: `^I` between columns, `$` only at line ends. 2.6 `/view + view/hostile.txt`. 2.7 `/view view/folder`, `view/missing.txt`, `/dev/zero`, `view/fifo` (returns at once), + `/bin/ls`, `view/big.log` (20 MB: the first 8 MiB with a note). 2.8 as listed, with `node progress/multirow.mjs 120` + running. 2.9 in step 8. +3. **`/edit`.** 3.1 `cd edit/empty`, then as listed, with `cat -vet notes.txt` (macOS `cat` has no `-A`): `^I` and a + final `$`. 3.2–3.4 on that file. 3.5 second tab: `echo x >> /Volumes/T7Tmp/ai-dev/q21/work/edit/empty/notes.txt`. + `cd ../..`. 3.6 `/edit edit/crlf-bom.txt`, change a word, save; `xxd edit/crlf-bom.txt | head -1` starts `efbb bf` + and lines end `0d0a`. `/edit edit/mode640.txt`, save, `ls -l edit/mode640.txt` is `-rw-r-----`. `/edit + edit/link.txt`, save, `ls -l edit/link.txt` still points to `target.txt`. 3.7 `/edit /bin/ls`, `/etc/hosts`, + `edit/big.txt` (2 MB), `edit/readonly.txt`, then `/view` each. 3.8 with `view/README.md` and `edit/target.txt`. + 3.9 `/edit edit/alias/new.txt` (`alias` links to `real`). +4. **Semantic Output.** 4.1 `cd semantic/pass && npm test` (40 passing tests stand in for this repository's suite). + 4.2 `cd ../fail && npm test` (2 failing). 4.3 `cd ../ts && npx --no-install tsc --noEmit bad.ts` (2 errors); `cd + ../c && make` (1 error). 4.4 `cd ../../git && git status`, `ls`, `cat tracked.txt`. 4.5 `cd ../semantic/fail && + npm test`, then as listed, with `pbpaste` after each copy. 4.6 as listed. 4.7 in step 8. `cd ../..` when done. +5. **`/track`.** 5.1 `/track df -h`; `/track --every 5s -- docker system df` is optional (read-only, but it asks your + Docker daemon every 5 s). 5.2 `/track --every 2s -- cat track/f.txt`; second tab: `seq 1 2 > + /Volumes/T7Tmp/ai-dev/q21/work/track/f.txt`, then `seq 1 40 > …`. 5.3–5.7 as listed (5.7's `/tmp/t` commands are + refused and never run; answer No to the ones that ask). 5.8 `/track --every 3s -- sh -c 'sleep 2; echo QA-TRACK'` + (Yes), close the window while it runs, then in a new window `pgrep -fl QA-TRACK` prints nothing. 5.9 and 5.10 as + listed. 5.11 in step 8. +6. **Modules.** 6.1 `cd modules/bun-app`, `/modules`: Bun is recommended; enabled, typing `bun` shows `1.1.30` + requested (Bun is not installed, so nothing active). 6.2 Not run (no Nix). 6.3 on this Mac the container module shows + nothing; the in-container half is Not run. 6.4 and 6.5 as listed. `cd ../..`. +7. **Themes.** 7.1–7.3 as listed. 7.4 in step 8. 7.5 second tab, before and after switching themes: `shasum + "$HOME/Library/Application Support/com.mitchellh.ghostty/config"*`: unchanged. Older configuration: `exit`, then in + a plain window `mkdir -p /Volumes/T7Tmp/ai-dev/q21/config-copy/nmsh && cp "$HOME/Library/Application + Support/notMyShell/config.json" /Volumes/T7Tmp/ai-dev/q21/config-copy/nmsh/` and `/Volumes/T7Tmp/ai-dev/q21/nmsh + copy`: the theme and prompt saved by your v0.20 install load unchanged (only the copy can change). +8. **No color and Safe glyphs** (2.9, 4.7, 5.11, 7.4). `exit`, relaunch as `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q21/nmsh`, + Settings → Appearance → Safe glyphs, then 2.9 on `view/README.md`, 4.7 with `cd semantic/fail && npm test` at 40 + columns (`cd ../..` after), 5.11 with `/track df -h`. Then 7.4's second half: `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color + /Volumes/T7Tmp/ai-dev/q21/nmsh` and switch between two new themes. + +### Sitting B: Terminal.app, Fish (passes 1, 3 and 5, about 20 minutes) + +`/Volumes/T7Tmp/ai-dev/q21/nmsh fish`, then 1.1–1.7 (not 1.5b), 3.1–3.9 and 5.1–5.10 as in sitting A, and 5.11 with +`NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q21/nmsh fish` as in step 8. Terminal.app is a 256-color terminal: note anything that +reads differently from Ghostty. + +### Sitting C: Terminal.app, zsh (passes 2 and 7, about 15 minutes) + +`/Volumes/T7Tmp/ai-dev/q21/nmsh`, then 2.1–2.8 and 7.1–7.5 as in sitting A, and 2.9 and 7.4 as in step 8. + +When every result is recorded: `rm -rf /Volumes/T7Tmp/ai-dev/q21`. + +## 1. Multi-row progress redraws (#388) + +- [ ] 1.1 `node -e 'for (let i=0;i<=10;i++){process.stdout.write((i?"\x1b[1A\x1b[2K":"")+"Downloading "+i*10+"%\n")}'` + leaves one `Downloading 100%` line, at 80 columns and at 40 columns. +- [ ] 1.2 A two-row display (for example `docker pull` of a multi-layer image, or `brew install` of a formula with + concurrent downloads) updates its rows in place; log lines printed between frames all stay. Record the command. +- [ ] 1.3 Resize the window narrower and wider while 1.2 runs: no earlier command, prompt or NMSh notice is erased. +- [ ] 1.4 `/copy` of that block copies the final rows once, not every frame. +- [ ] 1.5 `printf '10%%\r20%%\r100%%\n'` still shows one `100%` line; `clear` still clears. +- [ ] 1.5a At 40 columns, a progress display whose line is wider than the window (for example `pip install` of a + large wheel, `cargo build` of a crate with a long name, or the wrapped-row fixture in `tests/ansiLiveRegion.test.ts`) + redraws in place and every log line printed before it stays. +- [ ] 1.5b On Linux, `sudo apt install <small package>` (fancy progress on): the transcript keeps the dpkg log lines and + contains no `Progress: [ nn%]` fragments. +- [ ] 1.6 `vim`, `less` and `htop` still take the full screen and return to the composer. +- [ ] 1.7 Queue two quick commands (`ls` then `pwd`) with Ctrl+Q while a short command runs: both stored command + lines end with the known-command color (#408). + +## 2. Native viewer (`/view`, View output) + +- [ ] 2.1 `/view README.md` in this repository: Markdown is rendered (headings, lists, code, tables); `m` shows the + source with line numbers; `m` again returns. +- [ ] 2.2 `/view src/viewer/ViewerPanel.ts`: syntax colors, line numbers, `w` turns wrapping off and Left/Right scroll + sideways with `‹`/`›` marks, `l` hides numbers. +- [ ] 2.3 `/` finds text (lowercase ignores case), `n`/`N` step and wrap with a note, `:` + number goes to a line, + `G`/`g` end and start, PageUp/PageDown, mouse wheel scrolls. +- [ ] 2.4 Run `seq 1 5000`, then `/view` (latest output) and Actions → View output on that block; `/view 2` opens the + previous output. The transcript is unchanged after closing. +- [ ] 2.5 `c` copies the whole document; the clipboard has the exact text (tabs kept), and nothing else. +- [ ] 2.6 Hostile content: `printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > /tmp/h.txt && /view /tmp/h.txt` + shows `␛` marks, no color change, no link, no screen clear. +- [ ] 2.7 Refusals say why: a directory, a missing file, `/dev/zero`, a FIFO (`mkfifo /tmp/f && /view /tmp/f` returns + at once), a binary (`/view /bin/ls`), a 20 MB log (shows the first 8 MiB with a note). +- [ ] 2.8 Esc closes, the composer draft and cursor are where they were, and a running command's output kept + arriving underneath. At 32×10 the viewer still fits and Esc is shown. +- [ ] 2.9 `NO_COLOR=1` and Safe glyphs: matches and the current match are still visible (reverse video). + +## 3. Native editor (`/edit`) + +- [ ] 3.1 `/edit notes.txt` in an empty folder: it says *new file*; type two lines with Enter and a Tab, Ctrl+S says + *Saved*, Esc returns to the composer, `cat -A notes.txt` shows the tab and a final newline. +- [ ] 3.2 Shift+arrows, Shift+Alt+arrows and Cmd+Shift+arrows select; typing replaces the selection; Ctrl+C copies + and Ctrl+X cuts it (paste it back with the terminal's paste); Ctrl+Z undoes a typed word in one step, Ctrl+Y redoes. +- [ ] 3.3 Ctrl+F finds (lowercase ignores case), Enter again finds the next match. +- [ ] 3.4 Edit, then Esc: the question offers Save, Discard changes and Keep editing; each does what it says. +- [ ] 3.5 Conflict: `/edit notes.txt`, change it from another terminal (`echo x >> notes.txt`), then Ctrl+S: nothing is + written until you choose; Overwrite writes yours, Reload shows theirs. +- [ ] 3.6 A CRLF file with a BOM keeps both after saving (`xxd file | head -1`); `chmod 640` survives a save; editing + through a symlink keeps the symlink. +- [ ] 3.7 Refusals: a binary (`/edit /bin/ls`), a root-owned file (`/edit /etc/hosts`), a 2 MB file, a read-only file + (`chmod 444`) each explain why, and `/view` still opens them. +- [ ] 3.8 `e` in `/view README.md` opens the editor on the same file; `vim notes.txt` and `nano notes.txt` still run as + usual from the composer. +- [ ] 3.9 A new file in a folder reached through a symlink (macOS `/tmp`, or `alias -> real`): `/edit alias/new.txt`, + type a line, Ctrl+S, type another, Ctrl+S: *Saved* both times and no conflict question (#425); `real/new.txt` has + both lines. + +## 4. Semantic Output + +- [ ] 4.1 `npm test` in this repository (long, passing): one overview row under the command (`✔ node --test · … passed · + 0 failed …`) above the folded output; nothing else changed; the lifecycle row appears once. +- [ ] 4.2 A failing run (break one test): the overview says how many failed and a details row lists the failing tests; + details are open when the output is folded, closed when it is expanded; clicking the row and Actions → Show or hide + details toggle it; Ctrl+O still folds only the raw output. +- [ ] 4.3 `npx tsc --noEmit` with an error, `cargo build` with an error, `make` of a C file with an error: a + diagnostics overview with `file:line:col` items. +- [ ] 4.4 `git status`, `ls`, `cat README.md`: no overview. +- [ ] 4.5 `/copy --semantic` and `/copy --both` on the failing run (paste into an editor); `/copy latest` is the raw + output only; find (Ctrl+F) never matches the overview text. +- [ ] 4.6 Settings → Transcript → Semantic Output Off: the rows disappear from every block; On brings them back. +- [ ] 4.7 `NO_COLOR=1` and Safe glyphs: the overview reads `+`/`x` and stays readable at 40 columns. + +## 5. `/track` + +- [ ] 5.1 `/track df -h` and (where Docker runs) `/track --every 5s -- docker system df`: one block, a status row and the + result; it updates in place every interval while you run other commands; scrolling up and selecting text in other + blocks is never disturbed. +- [ ] 5.2 Make the result shorter and longer (`/track --every 2s -- cat f.txt` while editing `f.txt`): shorter results + do not move what is below; longer ones say how many lines are not shown. +- [ ] 5.3 `/track refresh` twice quickly: the second is refused, never overlapped. `/track pause`, `/track resume`. +- [ ] 5.4 A failing command (`/track --every 2s -- ls missing`): the status shows the exit code, never a healthy result. +- [ ] 5.5 `/track copy` copies only the latest result; find (Ctrl+F) never matches the live rows. +- [ ] 5.6 `/track stop`: the last result appears once in the transcript; `/resume` of the saved session shows it + without any per-refresh copies. +- [ ] 5.7 An unknown command asks for Yes first ("Track …?"); `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are + refused. Also refused: `/track echo x > /tmp/t 2>/dev/null`, `/track git log --output=/tmp/t`, `/track npx jest -u`. + Asks first: `/track env ls`, `/track curl -X POST http://localhost:1/`, `/track npm publish`, `/track ssh localhost + true`. Starts at once: `/track npm test 2>&1`, `/track curl -s http://localhost:1/` (fails, shows the exit code). +- [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` + still opens its manager. +- [ ] 5.9 Four trackers at once (`ls`, `df -h`, `cat f.txt`, a failing one): each block refreshes on its own, the + composer area shows one `4 trackers · 1 failing` row, narrowing the window to 60 columns and back keeps every block + readable, and `/track stop all` leaves each last result once. +- [ ] 5.10 `/track --every 2s -- sh -c 'trap "" TERM; exec >/dev/null 2>&1; exec sleep 300'` (Yes), then `/track stop`: + within 3 seconds `pgrep -f "sleep 300"` finds nothing. +- [ ] 5.11 Safe glyphs (Settings → Appearance) and `NO_COLOR=1`: the status marks are ASCII (`*`, `=`, `#`) and the + status still says live, paused or stopped in words. + +## 6. Native modules and diagnostics + +- [ ] 6.1 In a folder with `bun.lock` and `"packageManager": "bun@1.1.30"`, `/modules` recommends Bun; enabled, typing + `bun` shows its version (requested, and active when installed through Homebrew). +- [ ] 6.2 `nix-shell -p hello` (or `nix develop`) with the Nix shell module enabled: it shows `impure`; it disappears + after `exit`. +- [ ] 6.3 In a Docker or Podman container (or a Codespace), the container module names it; on the host it shows nothing. +- [ ] 6.4 `/modules explain` lists every configured module with its surface and reason; `/modules timings` lists the + slowest collections; neither changes what is collected (the Status Strip CPU stays as it was). +- [ ] 6.5 Route three modules to the Context Rail (Modules → Surface), narrow the window until one disappears, then run + `/modules explain`: that module says "left out for width in the last frame"; widen the window and run it again: the + note is gone. The same with the Status Strip. + +## 7. Theme families + +- [ ] 7.1 `/theme`: the Built-in list shows Ayu, Kanagawa, Everforest, Flexoki and Night Owl; `/` then `kanagawa` leaves + three; `/` then `light` leaves the light variants; Esc clears the filter, Esc again closes. +- [ ] 7.2 Activate each new variant on a dark terminal: prompt fills, branch, status, selection band and transcript + chrome are readable; module text stays readable on every fill. +- [ ] 7.3 Activate the light variants (Ayu Light, Kanagawa Lotus, Everforest Light, Flexoki Light, Light Owl) on a light + and on a dark terminal: accents and rules change, NMSh's text tiers stay readable. +- [ ] 7.4 `NO_COLOR=1` and a 256-color terminal (`TERM=xterm-256color`, no `COLORTERM`): nothing breaks. +- [ ] 7.5 Switching themes never changes Ghostty's or Terminal.app's own colors or configuration files; a config saved + with an older theme id still loads that theme. diff --git a/llms.txt b/llms.txt index 65fb4d87..784db687 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # notMyShell (NMSh) -Latest published stable release: v0.18.0 (see CHANGELOG.md). Homebrew is the recommended macOS install: brew install raiseCatError/tap/nmsh. Source installation remains available. +Latest published stable release: v0.20.0 (see CHANGELOG.md). Homebrew is the recommended macOS install: brew install raiseCatError/tap/nmsh. Source installation remains available. notMyShell is a terminal frontend that runs on top of a real persistent shell instead of replacing the shell. diff --git a/package-lock.json b/package-lock.json index fcf095eb..ebd15aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nmsh", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nmsh", - "version": "0.20.0", + "version": "0.21.0", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 24d47269..7647257c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nmsh", - "version": "0.20.0", + "version": "0.21.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/qa/v021-fixtures.sh b/scripts/qa/v021-fixtures.sh new file mode 100755 index 00000000..be209355 --- /dev/null +++ b/scripts/qa/v021-fixtures.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Fixtures for the v0.21 physical QA walkthrough (docs/testing/v021-physical-qa.md). +# +# scripts/qa/v021-fixtures.sh <new-directory> +# +# Creates, inside <new-directory> (which must not exist yet), a launcher for this checkout's build with an NMSh +# configuration and session service of its own, so the QA build never reads your settings or attaches to your running +# sessions, and the small files the checklist uses. Nothing outside that directory is changed, nothing is downloaded +# or installed, and nothing runs as root. Delete the directory once the results are recorded. +set -euo pipefail + +target=${1:-} +if [ -z "$target" ]; then echo 'usage: scripts/qa/v021-fixtures.sh <new-directory>' >&2; exit 2; fi +case $target in /*) ;; *) target="$PWD/$target" ;; esac +if [ -e "$target" ]; then echo "refusing: $target already exists" >&2; exit 2; fi +repo=$(cd "$(dirname "$0")/../.." && pwd -P) +if [ ! -f "$repo/dist/index.js" ]; then echo "build first: (cd $repo && npm ci && npm run build)" >&2; exit 2; fi +# The session service's socket lives under the directory; Unix socket paths are limited to about 100 bytes. +if [ ${#target} -gt 60 ]; then echo "refusing: $target is longer than 60 characters (session sockets need a short path)" >&2; exit 2; fi + +mkdir -p "$target" +root=$(cd "$target" && pwd -P) +work="$root/work" + +for shell in zsh bash fish; do + mkdir -p "$root/config-$shell/nmsh" "$root/run-$shell" + chmod 700 "$root/run-$shell" + printf '{"onboardingComplete":true,"toolsSetupComplete":true,"shellBackend":"%s"}\n' "$shell" > "$root/config-$shell/nmsh/config.json" +done + +cat > "$root/nmsh" <<EOF +#!/usr/bin/env bash +# The QA build ($repo) with this folder's configuration and session service: ./nmsh [zsh|bash|fish|copy] +# "copy" uses config-copy/nmsh, a copy of an existing configuration you make yourself (see the walkthrough). +profile=\${1:-zsh} +case \$profile in zsh|bash|fish|copy) ;; *) echo 'usage: ./nmsh [zsh|bash|fish|copy]' >&2; exit 2 ;; esac +if [ ! -d "$root/config-\$profile/nmsh" ]; then echo "missing $root/config-\$profile/nmsh" >&2; exit 2; fi +mkdir -p "$root/run-\$profile" && chmod 700 "$root/run-\$profile" +cd "$work" || exit 1 +exec env XDG_CONFIG_HOME="$root/config-\$profile" NMSH_RUNTIME_DIR="$root/run-\$profile" NMSH_DISABLE_UPDATES=1 node "$repo/dist/index.js" +EOF +chmod +x "$root/nmsh" + +# 1. Progress redraws (#388) ------------------------------------------------------------------------------------- +mkdir -p "$work/progress" +cat > "$work/progress/multirow.mjs" <<'EOF' +// Two rows redrawn in place (cursor up, erase line), with log lines printed between frames, as concurrent +// downloads and container pulls draw them. node multirow.mjs [steps] (default 24, a quarter second each). +const steps = Number(process.argv[2] ?? 24); +const sleep = ms => new Promise(done => setTimeout(done, ms)); +const bar = (done, width = 20) => `[${'='.repeat(Math.round(done * width)).padEnd(width)}]`; +const layers = ['layer 1/2 sha256:9f86d081884c', 'layer 2/2 sha256:60303ae22b99']; +process.stdout.write('LOG pulling two layers\n'); +let drawn = 0; +for (let step = 0; step <= steps; step += 1) { + let out = drawn ? `\x1b[${drawn}A` : ''; + if (step > 0 && step % 6 === 0) out += `\x1b[2KLOG checkpoint ${step / 6}\n`; + layers.forEach((name, index) => { + const done = Math.min(1, (step / steps) * (index ? 0.8 : 1.2)); + out += `\x1b[2K${name} ${bar(done)} ${Math.round(done * 100)}%\n`; + }); + process.stdout.write(out); + drawn = layers.length; + await sleep(250); +} +process.stdout.write('LOG done\n'); +EOF +cat > "$work/progress/wrapped.mjs" <<'EOF' +// A progress display that counts the terminal rows it drew (its line is wider than a 40-column window), moves up by +// them, erases below and redraws, as indicatif, pip and cargo do. The LOG lines must all stay. +const sleep = ms => new Promise(done => setTimeout(done, ms)); +const rows = text => Math.max(1, Math.ceil(text.length / (process.stdout.columns || 80))); +process.stdout.write('LOG kept line one\nLOG kept line two\n'); +let drawn = 0; +for (let n = 0; n <= 10; n += 1) { + if (drawn) process.stdout.write(`\r\x1b[${drawn}A\x1b[J`); + const frame = [`Downloading big-archive-name.tar.gz [${'='.repeat(n)}${' '.repeat(10 - n)}] ${n * 10}%`, `eta ${10 - n}s`]; + process.stdout.write(`${frame.join('\n')}\n`); + drawn = frame.reduce((sum, line) => sum + rows(line), 0); + await sleep(300); +} +process.stdout.write('LOG done\n'); +EOF + +# 2. /view -------------------------------------------------------------------------------------------------------- +mkdir -p "$work/view/folder" +cp "$repo/README.md" "$work/view/README.md" +cp "$repo/src/viewer/ViewerPanel.ts" "$work/view/ViewerPanel.ts" +printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > "$work/view/hostile.txt" +printf 'name\tsize\tnote\nalpha\t1\tfirst\nbeta\t22\tsecond\n' > "$work/view/tabs.txt" +mkfifo "$work/view/fifo" +yes 'a log line for the 8 MiB viewer limit check' | head -c 20971520 > "$work/view/big.log" || true + +# 3. /edit -------------------------------------------------------------------------------------------------------- +mkdir -p "$work/edit/empty" "$work/edit/real" +ln -s real "$work/edit/alias" +printf '\357\273\277line one\r\nline two\r\n' > "$work/edit/crlf-bom.txt" +printf 'keep my mode\n' > "$work/edit/mode640.txt" && chmod 640 "$work/edit/mode640.txt" +printf 'the target\n' > "$work/edit/target.txt" && ln -s target.txt "$work/edit/link.txt" +printf 'read only\n' > "$work/edit/readonly.txt" && chmod 444 "$work/edit/readonly.txt" +yes 'a line for the 1 MiB editor limit check' | head -c 2097152 > "$work/edit/big.txt" || true + +# 4. Semantic Output ---------------------------------------------------------------------------------------------- +mkdir -p "$work/semantic/pass/test" "$work/semantic/fail/test" "$work/semantic/ts/node_modules/.bin" "$work/semantic/c" +for kind in pass fail; do + printf '{"name":"qa-%s","private":true,"scripts":{"test":"node --test"}}\n' "$kind" > "$work/semantic/$kind/package.json" + { + echo "import test from 'node:test';" + echo "import assert from 'node:assert/strict';" + for n in $(seq 1 40); do echo "test('adds case $n', () => assert.equal($n + $n, $((n * 2))));"; done + if [ "$kind" = fail ]; then + echo "test('rounds a price', () => assert.equal(Math.round(2.5), 2));" + echo "test('parses a date', () => assert.equal(new Date('2026-10-18').getUTCDate(), 19));" + fi + } > "$work/semantic/$kind/test/math.test.mjs" +done +printf '{"name":"qa-ts","private":true}\n' > "$work/semantic/ts/package.json" +printf 'const count: number = "three";\nexport const total = count + missingValue;\n' > "$work/semantic/ts/bad.ts" +ln -s "$repo/node_modules/typescript/bin/tsc" "$work/semantic/ts/node_modules/.bin/tsc" +printf 'int main(void) {\n return undefined_name;\n}\n' > "$work/semantic/c/bad.c" +printf 'bad.o: bad.c\n\tcc -c bad.c -o bad.o\n' > "$work/semantic/c/Makefile" + +# A small repository for `git status` (no remote, no hooks, a local identity for its one commit). +mkdir -p "$work/git" +printf 'tracked\n' > "$work/git/tracked.txt" +git -C "$work/git" init -q +git -C "$work/git" add tracked.txt +git -C "$work/git" -c user.name=QA -c user.email=qa@example.invalid -c commit.gpgsign=false commit -q -m 'QA fixture' +printf 'untracked\n' > "$work/git/untracked.txt" + +# 5. /track ------------------------------------------------------------------------------------------------------- +mkdir -p "$work/track" +printf 'one\ntwo\nthree\n' > "$work/track/f.txt" + +# 6. Modules ------------------------------------------------------------------------------------------------------ +mkdir -p "$work/modules/bun-app" +printf '{"name":"bun-app","private":true,"packageManager":"bun@1.1.30"}\n' > "$work/modules/bun-app/package.json" +printf '{"lockfileVersion": 1}\n' > "$work/modules/bun-app/bun.lock" + +printf 'QA fixtures ready in %s\nStart the QA build with: %s/nmsh (or: %s/nmsh fish, %s/nmsh bash)\n' "$root" "$root" "$root" "$root" diff --git a/scripts/test-file-teardown.mjs b/scripts/test-file-teardown.mjs new file mode 100644 index 00000000..422ab8a2 --- /dev/null +++ b/scripts/test-file-teardown.mjs @@ -0,0 +1,49 @@ +import {after} from 'node:test'; +import {spawnSync} from 'node:child_process'; + +/** + * Preloaded into every test file's process by scripts/test.mjs. A test that stalls past its timeout is cancelled, but + * whatever it started keeps running: a live shell PTY or a piped child then keeps the file's process alive, the file + * never finishes, its failure is never printed, and a CI job waits for its own time limit (#415's Ubuntu shard). + * Once the file's tests are done, a timer that cannot itself keep the process alive gives the file's own cleanup time + * (NMSH_TEST_TEARDOWN_MS, default 15 s); if the process is still alive then, every process the file left running is + * ended and named, so the stall is reported instead of hanging the run. + */ +if (process.env.NODE_TEST_CONTEXT) { + after(() => { + const delay = Number(process.env.NMSH_TEST_TEARDOWN_MS) || 15_000; + setTimeout(endLeftovers, delay).unref(); + }); +} + +export function endLeftovers() { + const rows = processTable(); + const own = rows.find(row => row.pid === process.pid)?.group; + const left = descendants(rows, process.pid).filter(row => !/(?:^|\/)(?:esbuild|ps)$/u.test(row.command)); + for (const row of left) { + // A leader of its own group (a PTY shell, a detached helper) takes its whole group with it; never our own group. + const target = row.group === row.pid && row.group !== own ? -row.group : row.pid; + try { process.kill(target, 'SIGKILL'); } catch { /* Already gone. */ } + } + if (left.length) process.stderr.write(`test teardown: ended ${left.length} process${left.length === 1 ? '' : 'es'} this file left running: ${left.map(row => `${row.pid} ${row.command}`).join(', ')}\n`); + return left; +} + +function processTable() { + const result = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,pgid=,comm='], {encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024}); + if (result.status !== 0 || !result.stdout) return []; + return result.stdout.trim().split('\n').map(line => { + const [pid, parent, group, ...command] = line.trim().split(/\s+/u); + return {pid: Number(pid), parent: Number(parent), group: Number(group), command: command.join(' ')}; + }); +} + +function descendants(rows, root) { + const found = []; + const queue = [root]; + while (queue.length) { + const parent = queue.shift(); + for (const row of rows) if (row.parent === parent && row.pid !== root) { found.push(row); queue.push(row.pid); } + } + return found; +} diff --git a/scripts/test-shell-home.mjs b/scripts/test-shell-home.mjs new file mode 100644 index 00000000..be75a11c --- /dev/null +++ b/scripts/test-shell-home.mjs @@ -0,0 +1,17 @@ +import {mkdirSync} from 'node:fs'; +import {join} from 'node:path'; + +/** + * Marks Fish's man-page completion generation as already done in a test home (#403). + * + * On a home without generated completions, an interactive Fish starts `fish_update_completions` detached + * (Fish 4 checks `$__fish_cache_dir/generated_completions`, Fish 3 `$__fish_user_data_dir/generated_completions`). + * That orphaned process keeps writing about 1,400 files into the home after the shell exits, so removing the home + * races it. Real installations keep Fish's normal behavior; only test homes are marked, in both places. + * + * @param {string} home the HOME the test's shells run with + * @param {{dataHome?: string, cacheHome?: string}} [dirs] XDG_DATA_HOME / XDG_CACHE_HOME when the test sets them + */ +export function markFishCompletionsGenerated(home, {dataHome = join(home, '.local', 'share'), cacheHome = join(home, '.cache')} = {}) { + for (const base of [dataHome, cacheHome]) mkdirSync(join(base, 'fish', 'generated_completions'), {recursive: true}); +} diff --git a/scripts/test.mjs b/scripts/test.mjs index 482eb2b1..902711d0 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -4,6 +4,7 @@ import {availableParallelism, tmpdir} from 'node:os'; import {join} from 'node:path'; import {pathToFileURL} from 'node:url'; import {discoverTestFiles, parseTestOptions, selectTestGroups, testConcurrency} from './test-selection.mjs'; +import {markFishCompletionsGenerated} from './test-shell-home.mjs'; /** 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)} = {}) { @@ -12,15 +13,23 @@ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio try { const testHome = join(root, 'home'); mkdirSync(testHome, {mode: 0o700}); + markFishCompletionsGenerated(testHome); // 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, 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; + // Ambient XDG data, cache and state directories (a developer's, or a wrapper's shared cache) are not the tests': + // shells and tools fall back to the private HOME, as they do on CI where these are unset. + delete env.XDG_DATA_HOME; + delete env.XDG_CACHE_HOME; + delete env.XDG_STATE_HOME; // PTY workers also own shell/helper processes. Bound fan-out on larger // development hosts; explicit Node --test-concurrency options still win. const concurrency = testConcurrency(process.env.NMSH_TEST_CONCURRENCY, availableParallelism()); - const child = spawn(process.execPath, ['--import=tsx', '--test', `--test-concurrency=${concurrency}`, ...args, ...files], { + // Each file's process ends what it left running once its tests are done (a stalled test cannot hang the run). + const teardown = new URL('./test-file-teardown.mjs', import.meta.url).href; + const child = spawn(process.execPath, ['--import=tsx', `--import=${teardown}`, '--test', `--test-concurrency=${concurrency}`, ...args, ...files], { cwd, stdio, env, }); let interrupted = false; diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e727d766..62148e4a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -5,6 +5,7 @@ import {queuePresentationChanges, queueRowKey, staleQueueSnapshot} from '../stat import {shellSubmission} from '../shell/submission.js'; import {railNeedsPromptConversion, railPreviewConfiguration} from '../prompt/railLayout.js'; import {prepareRail, paintRailComposition, railCompositionPreview, type PreparedRail} from '../prompt/railComposition.js'; +import {fitOutcome, newFitReport, type FitReport} from '../prompt/fitReport.js'; import {routeModule} from '../context/surfaceRouter.js'; import {promptFacts, moduleFactContext, updateFact, factAllowed} from '../context/facts.js'; import {chromaPreviewNote} from '../appearance/chromaNotes.js'; @@ -61,13 +62,17 @@ import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../th 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 {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; -import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection} from '../clipboard/copySelection.js'; +import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection, semanticCopyPayload, type CopyContent} from '../clipboard/copySelection.js'; import {captureOutputAnchor, createOutputSelection, moveOutputSelection, restoreOutputAnchor, revealOutputSelection, validateOutputSelection, type OutputAnchor, type OutputScope, type OutputSelection} from '../output/OutputSelection.js'; import {copyPickerKey, createCopyPicker, renderCopyPicker, type CopyPickerState} from '../ui/CopyPicker.js'; import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, 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 {displayPath, loadFileDocument, outputDocument} from '../viewer/document.js'; +import {createViewer, renderViewer, viewerKey, type ViewerLook, type ViewerState} from '../viewer/ViewerPanel.js'; +import {createEditor, editorKey, renderEditor, type EditorAction, type EditorState} from '../viewer/EditorPanel.js'; +import {openForEdit, saveFile} from '../viewer/fileEdit.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'; @@ -264,8 +269,10 @@ import {openableUrl, projectRunAllowed, readProjectFacts} from '../ask/project.j import {ManagedTasks} from '../tasks/ManagedTasks.js'; import {CursorPresenter} from '../cursor/CursorPresenter.js'; import {runDoctor} from '../doctor/doctor.js'; -import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; -import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; +import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; +import {renderWatchPanel, watchNoticeRows, watchPanelKey, type WatchPanelState} from '../tasks/WatchPanel.js'; +import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.js'; +import {explainModules, moduleTimings} from '../prompt/moduleReport.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js'; import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; @@ -935,6 +942,8 @@ export class TerminalApp { this.output.setTranscriptAppearance(this.promptConfiguration.transcript); this.output.presenter.setTreatment(this.promptConfiguration.presentation); this.output.setOutputFolding(this.promptConfiguration.outputFolding); + this.output.setSemanticOutput(this.promptConfiguration.semanticOutput); + this.output.setLiveRows((id, width) => this.trackRows(id, width)); this.output.presenter.setLayout(this.promptConfiguration.transcriptPresentation); this.output.presenter.setHyperlinks(this.host.capabilities.hyperlinks); this.renderer.setCursorStyle(cursorStyleSequence(this.promptConfiguration.cursor.shape, this.promptConfiguration.cursor.blink)); @@ -1620,6 +1629,26 @@ export class TerminalApp { this.render(); return; } + if (this.textEditor) { + const editor = this.textEditor; + const {columns, rows} = this.dimensions(); + const action = editorKey(editor, key, columns, rows - 4); + if (action) this.runEditorAction(editor, action); + this.render(); + return; + } + if (this.viewer) { + const viewer = this.viewer; + const {columns, rows} = this.dimensions(); + const action = viewerKey(viewer, key, columns, rows - 4, this.viewerLook()); + if (action?.kind === 'close') { this.viewer = undefined; this.returnFromPanel(); } + else if (action?.kind === 'edit') { this.viewer = undefined; this.openTextEditor(action.path, '/edit', viewer); } + else if (action?.kind === 'copy') { + void this.copyText(action.text, 'Copied the document').then(note => { if (note && this.viewer === viewer) { viewer.note = note; this.render(); } }); + } + this.render(); + return; + } if (this.comparePanel) { const {columns, rows} = this.dimensions(); const action = comparePanelKey(this.comparePanel, key, columns, rows - 4); @@ -1903,13 +1932,28 @@ export class TerminalApp { if (action?.kind === 'close') { this.watchPanel = undefined; this.returnFromPanel(); } else if (action?.kind === 'confirm' && panel.confirm) { const pending = panel.confirm; + const size = this.dimensions(); + if (size.columns < 40 || size.rows < 14) { + panel.message = 'Resize to at least 40 columns and 14 rows to review this recurring command.'; + this.render(); + return; + } panel.confirm = undefined; const safety = watchSafety(pending.command); if (safety.kind === 'refused') panel.message = safety.reason; - else { this.watches.start(pending.command, pending.cwd, pending.intervalMs, safety); panel.selected = this.watches.active().length - 1; } + else if (this.startWatch(pending.track ? `/track ${pending.command}` : `/watch ${pending.command}`, pending.command, pending.cwd, pending.intervalMs, safety, Boolean(pending.track))) { + // A confirmed /track shows its block in the transcript; the manager closes. + if (pending.track) { this.watchPanel = undefined; this.returnFromPanel(); } + else panel.selected = this.watches.active().length - 1; + } } else if (action?.kind === 'pause') this.watches.pause(action.id); else if (action?.kind === 'resume') this.watches.resume(action.id); - else if (action?.kind === 'now') this.watches.runNow(action.id); + else if (action?.kind === 'now' && !this.watches.runNow(action.id)) panel.message = 'A result is still being produced; no second one was started.'; + else if (action?.kind === 'copy') { + const latest = this.watches.get(action.id)?.current; + if (!latest) panel.message = 'No completed result to copy yet.'; + else void this.copyText(latest.output, 'Copied the latest result').then(note => { if (note && this.watchPanel === panel) { panel.message = note; this.render(); } }); + } else if (action?.kind === 'stop') this.watches.stop(action.id); this.render(); return; @@ -2160,6 +2204,10 @@ export class TerminalApp { 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.semanticToggle !== undefined) { + this.output.toggleSemanticDetails(row.semanticToggle); + this.render(); + } if (key.kind === 'mouseClick' && row.isFoldHint && row.commandIndex !== undefined) { this.output.toggleExpanded(row.commandIndex); this.render(); @@ -2873,6 +2921,11 @@ export class TerminalApp { else this.configureClipboardChanges(slash.suggestions); } else if (slash.kind === 'pins') { this.panelOrigin = undefined; this.openPins(); } + else if (slash.kind === 'view') this.openViewer(slash.target, command.trim()); + else if (slash.kind === 'editFile') { + if (!slash.target) this.output.addFrontendInteraction(command.trim(), 'Name a file: /edit notes.md. Your own editor and vim, nano or micro still work as usual.', INFO); + else this.openTextEditor(slash.target, command.trim()); + } else if (slash.kind === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -2893,6 +2946,9 @@ export class TerminalApp { if (this.appearanceHub) { this.appearanceHub.view = 'motion'; this.appearanceHub.selected = 0; this.appearanceHub.previewStart = Date.now(); } } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } + else if (slash.kind === 'modules' && slash.report === 'explain') this.output.addFrontendBlock(command.trim(), explainModules(this.promptConfiguration.modules, this.modulesContext(), this.promptConfiguration, Date.now(), + (id, surface) => fitOutcome(surface === 'contextRail' ? this.lastFit.rail : surface === 'statusStrip' ? this.lastFit.strip : undefined, id))); + else if (slash.kind === 'modules' && slash.report === 'timings') this.output.addFrontendBlock(command.trim(), moduleTimings(id => this.contextEngine.status(id))); else if (slash.kind === 'modules') { this.panelOrigin = undefined; this.openModulesManager(); } else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } else if (slash.kind === 'screensaver') { @@ -2979,7 +3035,7 @@ export class TerminalApp { else if (slash.kind === 'providers') this.openProvidersOverview(slash.family); 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 === 'watch') this.handleWatch(command.trim(), slash.op, slash.arguments, slash.track === true); else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); @@ -3008,7 +3064,7 @@ export class TerminalApp { 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) : [])]); + this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record, this.blockOptions(record)) : [])]); } /** Newest-first indexes of completed commands that did not exit 0 (a nonzero exit or an interrupt is authoritative; output text is not). */ @@ -3200,7 +3256,7 @@ export class TerminalApp { if (this.running || this.settingsPanelActive) return; const record = this.output.view().completed.find(item => item.startId === startId); if (record && !record.frontend) { - this.paletteState = createPalette(blockPaletteItems(record)); + this.paletteState = createPalette(blockPaletteItems(record, this.blockOptions(record))); this.blockPaletteContext = {palette: this.paletteState, scope: this.outputScope()}; if (this.outputNavigation?.intent === 'actions' && this.outputNavigation.focusedId === startId) { this.blockPaletteReturn = {selection: this.outputNavigation, palette: this.paletteState}; @@ -3213,7 +3269,7 @@ export class TerminalApp { const records = this.output.view().completed; const index = records.findIndex(item => item.startId === startId); const record = records[index]; - if (!record || !blockPaletteItems(record).some(item => item.action.kind === 'block' && item.action.id === action)) return; + if (!record || !blockPaletteItems(record, this.blockOptions(record)).some(item => item.action.kind === 'block' && item.action.id === action)) return; // Selection itself never runs actions. An explicit choice ends it before any editor/program handoff. this.endOutputNavigation(false); const includeStatus = this.promptConfiguration.copy.includeStatus; @@ -3230,6 +3286,8 @@ export class TerminalApp { 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 === 'view') this.openOutputViewer(record); + else if (action === 'details') this.output.toggleSemanticDetails(record.startId); else if (action === 'pager') await this.openBlockInPager(record); else if (action === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } else if (action === 'edit' || action === 'rerun') { @@ -3553,6 +3611,110 @@ export class TerminalApp { finally { rmSync(directory, {recursive: true, force: true}); this.render(); } } + /** Which conditional block actions this block offers. */ + private blockOptions(record: CompletedCommand): {details: boolean} { + return {details: Boolean(this.output.semanticFor(record)?.items.length)}; + } + + private viewerLook(): ViewerLook { + return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks}; + } + + /** + * /view: a completed output by its /copy number (the latest when no target is given), or a file relative to the + * shell's directory. The file is read asynchronously and read-only; a failure is reported in the transcript. + */ + private openViewer(target: string, command: string): void { + this.viewerLoad?.abort(); + this.viewerLoad = undefined; + if (!target || /^[1-9]\d{0,2}$/u.test(target)) { + const number = target ? Number(target) : 1; + const record = this.output.recentShellCommands()[number - 1]; + if (!record) { + this.output.addFrontendInteraction(command, target ? `No completed command output at ${number}.` : 'No completed output yet. /view <file> opens a file.', target ? ERROR : INFO); + return; + } + this.openOutputViewer(record); + return; + } + const load = new AbortController(); + this.viewerLoad = load; + void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(result => { + if (this.viewerLoad !== load || this.stopped) return; + this.viewerLoad = undefined; + if (!result.ok) { this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; } + // Another panel opened while the file was read: it keeps the keys; the file is not opened over it. + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.viewer = createViewer(result.document); + this.render(); + }); + } + + /** + * /edit: open one small text file in NMSh's editor, or say in the transcript why it cannot be edited (refused files + * stay readable in /view). `returnTo` is the viewer it was opened from; it is reopened if the file cannot be edited. + */ + private openTextEditor(target: string, command: string, returnTo?: ViewerState): void { + void openForEdit(target, this.shellCwd).then(result => { + if (this.stopped) return; + if (!result.ok) { + if (returnTo && !this.hasModalInput()) { returnTo.note = result.reason; this.viewer = returnTo; } + else this.output.addFrontendInteraction(command, result.reason, ERROR); + this.render(); + return; + } + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.textEditor = createEditor(result.file); + this.render(); + }); + } + + /** The editor's requests that touch the disk or the clipboard; the panel shows the outcome. */ + private runEditorAction(editor: EditorState, action: EditorAction): void { + if (action.kind === 'close') { this.textEditor = undefined; this.returnFromPanel(); return; } + if (action.kind === 'copy') { + void this.copyText(action.text, 'Copied').then(note => { if (note && this.textEditor === editor) { editor.note = {text: note, tone: 'success'}; this.render(); } }); + return; + } + editor.busy = true; + if (action.kind === 'reload') { + void openForEdit(editor.file.path, this.shellCwd).then(result => { + editor.busy = false; + if (this.textEditor !== editor) return; + if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; this.render(); return; } + this.textEditor = {...createEditor(result.file), note: {text: 'Reloaded from disk; your changes were discarded.', tone: 'info'}}; + this.render(); + }); + return; + } + const lines = [...editor.buffer.lines]; + void saveFile(editor.file, lines, {...(action.force ? {force: true} : {})}).then(result => { + editor.busy = false; + if (this.textEditor !== editor) return; + if (result.ok) { + editor.file = result.file; + // Only what was written is clean: typing during the save stays unsaved. + if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved(); + if (action.thenClose) { this.textEditor = undefined; this.returnFromPanel(); } + else editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'}; + } else if (result.conflict) { + editor.dialog = {kind: 'conflict', focus: 0, conflict: result.conflict, reason: result.reason, ...(action.thenClose ? {thenClose: true} : {})}; + } else editor.note = {text: `${result.reason} Your text is still here.`, tone: 'error'}; + this.render(); + }); + } + + /** View output: the record's stored output, numbered as /copy numbers it. */ + private openOutputViewer(record: CompletedCommand): void { + const number = this.output.recentShellCommands().indexOf(record) + 1; + this.panelOrigin = undefined; + // The text /copy copies (tabs as the program wrote them), without the optional completion status. + this.viewer = createViewer(outputDocument({command: record.command, output: recordCopyText(record, false), exitCode: record.exitCode}, Math.max(1, number))); + 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; @@ -3575,7 +3737,9 @@ export class TerminalApp { if (!this.outputNavigationAvailable()) return; const parsed = parseCopyArgs(args); if (!parsed.ok) { this.output.addFrontendInteraction(command.trim(), parsed.error, ERROR); return; } - const {selector, status, report} = parsed.request; + const {status, report, content} = parsed.request; + // --semantic and --both copy directly: without a selection they take the latest output, whatever the copy mode. + const selector = content === 'semantic' || content === 'both' ? (parsed.request.selector.kind === 'default' ? {kind: 'latest' as const, count: 1} : parsed.request.selector) : parsed.request.selector; const includeStatus = status ?? this.promptConfiguration.copy.includeStatus; if (selector.kind === 'picker' || (selector.kind === 'default' && this.promptConfiguration.copy.mode === 'picker')) { this.panelOrigin = undefined; @@ -3590,7 +3754,7 @@ export class TerminalApp { 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); + await this.copyRecords(resolved.records, includeStatus, undefined, parsed.request.content); } /** @@ -3598,9 +3762,11 @@ export class TerminalApp { * 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<boolean> { + private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string, content: CopyContent = 'raw'): Promise<boolean> { const scope = this.outputScope(); - const payload = [before ?? '', copySelectionPayload(records, includeStatus)].filter(Boolean).join('\n'); + // --semantic / --both: the reading as text (raw for a record without one, said in the note); raw is the default. + const semantic = content === 'raw' ? undefined : semanticCopyPayload(records, content, includeStatus, record => this.output.semanticFor(record)); + const payload = [before ?? '', semantic ? semantic.payload : 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'); @@ -3614,7 +3780,8 @@ export class TerminalApp { const live = this.output.recentShellCommands(); this.output.expandBlocks(records.filter(record => live.includes(record) && recordCopyText(record, includeStatus)).map(record => record.startId)); } - this.noteClipboard(copiedNote(records.length, copyStats(payload), includeStatus), 'success'); + const fallback = semantic?.unavailable ? ` · ${semantic.unavailable === records.length ? 'semantic unavailable, copied raw' : `${semantic.unavailable} without a semantic reading copied raw`}` : ''; + this.noteClipboard(`${copiedNote(records.length, copyStats(payload), includeStatus)}${content === 'semantic' && !fallback ? ' · semantic' : content === 'both' && !fallback ? ' · raw and semantic' : ''}${fallback}`, 'success'); return true; } @@ -4324,7 +4491,10 @@ export class TerminalApp { if (this.renderRail?.columns === columns) return this.renderRail.prepared; const configuration = this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh' ? {...this.promptConfiguration, contextRail: {...this.promptConfiguration.contextRail, mode: 'off' as const}} : this.promptConfiguration; - return prepareRail(this.promptContext(), columns, configuration); + const report = newFitReport(); + const prepared = prepareRail(this.promptContext(), columns, configuration, 0, report); + this.lastFit.rail = report; + return prepared; } private contextRailRows(columns: number): string[] { @@ -4905,7 +5075,7 @@ export class TerminalApp { private get settingsPanelActive(): boolean { return this.hasModalInput(); } private hasModalInput(ignorePalette = false): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.background.panel || 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 || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.viewer || this.textEditor || this.background.panel || 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 || (!ignorePalette && 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.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } @@ -4947,6 +5117,8 @@ export class TerminalApp { if (this.summaryReview) return renderReportReview(this.summaryReview.document, 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.viewer) return renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook()); + if (this.textEditor) return renderEditor(this.textEditor, columns, this.dimensions().rows - 4); if (this.background.panel) return framePanel(this.background.panelRows(Math.max(1, columns - 4), this.dimensions().rows - 4, {now: Date.now(), color: colorLevel() !== 'none', safe: getCurrentGlyphMode() === 'safe'}), columns); if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.rowPanelPreview(columns)); @@ -5875,6 +6047,12 @@ export class TerminalApp { private reportReview?: ReportReviewState; /** Compare output…: choosing the other block, then the difference. */ private comparePanel?: ComparePanelState; + /** The native read-only viewer (/view, View output); owns keys while open. */ + private viewer?: ViewerState; + /** A file /view is still reading; a newer request or Esc cancels it. */ + private viewerLoad?: AbortController; + /** NMSh's small text editor (/edit, e in /view); owns keys while open. */ + private textEditor?: EditorState; private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number}; private clipboardNoteTimer?: ReturnType<typeof setTimeout>; /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ @@ -7237,6 +7415,7 @@ export class TerminalApp { this.output.setTranscriptAppearance(next.transcript); this.output.presenter.setTreatment(next.presentation); this.output.setOutputFolding(next.outputFolding); + this.output.setSemanticOutput(next.semanticOutput); this.output.presenter.setLayout(next.transcriptPresentation); if (this.settingsPanelState) this.settingsPanelState.glyphStyle = next.glyphStyle; return true; @@ -7482,6 +7661,7 @@ export class TerminalApp { if (next.welcome === 'none') this.welcomeGeneration++; this.output.setTranscriptAppearance(next.transcript); this.output.setOutputFolding(next.outputFolding); + this.output.setSemanticOutput(next.semanticOutput); this.output.presenter.setTreatment(next.presentation); this.output.presenter.setLayout(next.transcriptPresentation); this.transcriptPanelState = undefined; @@ -7925,7 +8105,7 @@ export class TerminalApp { const now = Date.now(); 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)]; + const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, columns)), ...this.pastePreviewRows(columns)]; const background = this.backgroundRows(columns); return shelf ? [...rows, ...background, shelf] : [...rows, ...background]; } @@ -8292,11 +8472,18 @@ export class TerminalApp { const record = this.awakeRecord; const display = configuration.keepAwake.display; // Modules routed to the strip are resolved facts like every other surface; the strip never collects anything itself. - return renderStatusStrip(configuration.statusStrip, stripStatsFromFacts(this.contextEngine.facts()), columns, undefined, + // The live row records how it fitted routed modules, for /modules explain; previews of a draft do not. + const report = configuration === this.promptConfiguration ? newFitReport() : undefined; + const row = renderStatusStrip(configuration.statusStrip, stripStatsFromFacts(this.contextEngine.facts()), columns, undefined, record ? {full: awakeLabel(record, display, 'full'), short: awakeLabel(record, display, 'short'), glyph: awakeLabel(record, display, 'glyph')} : undefined, - this.statusStripModuleItems(configuration)); + this.statusStripModuleItems(configuration), report); + if (report) this.lastFit.strip = report; + return row; } + /** How the last live frame fitted routed modules on the Rail and the Strip (#365). */ + private readonly lastFit: {rail?: FitReport; strip?: FitReport} = {}; + /** Strip-routed modules with their group and, for the path, a shorter form the fitter may use first. */ private statusStripModuleItems(configuration = this.promptConfiguration): StripModuleItem[] { const context = this.promptContext(); @@ -8507,16 +8694,48 @@ export class TerminalApp { private doctorPanel?: DoctorPanelState; private watchPanel?: WatchPanelState; /** Watches NMSh schedules itself; they live (and stop) with this window. */ - private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh'); - private readonly watchSubscription = this.watches.onChange((_watch, transition) => { + private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh', Date.now, () => ({...process.env, PATH: this.shellPath()})); + /** Watches started with /track: their live transcript block keeps the height it reached (see trackBlockRows). */ + private readonly trackBlocks = new Map<string, {reserved: number; finalized: boolean}>(); + private readonly watchSubscription = this.watches.onChange((watch, transition) => { if (this.stopped) return; // A watch moving between failing and passing is one meaningful event. if (transition) this.transitions.echo(transition === 'pass' ? 'taskDone' : 'taskFailed', Date.now()); + // A tracker the person stopped leaves its last result in the transcript once (a closing window leaves nothing). + const block = this.trackBlocks.get(watch.id); + if (block && watch.status === 'stopped' && !block.finalized) { + block.finalized = true; + this.output.addFrontendBlock(`/track ${watch.command}`, finalResultRows(watch)); + // The anchored transcript row can render its stopped state directly from the task. + // The mutable per-tracker height bookkeeping is no longer needed after finalization. + this.trackBlocks.delete(watch.id); + } this.render(); }); + /** A /track block's live rows (presentation only). */ + private trackRows(id: string, width: number): string[] { + const block = this.trackBlocks.get(id); + const {rows, reserved} = trackBlockRows(this.watches.get(id), width, Date.now(), block?.reserved ?? 0, this.watches.sampling(id)); + if (block) block.reserved = reserved; + return rows; + } + + /** Start a classified (or confirmed) watch; /track also writes its block, whose live rows follow it in place. */ + private startWatch(command: string, watched: string, cwd: string, intervalMs: number | undefined, safety: Exclude<WatchSafety, {kind: 'refused'}>, track: boolean): boolean { + let watch; + try { watch = this.watches.start(watched, cwd, intervalMs, safety); } + catch (error) { this.output.addFrontendInteraction(command, error instanceof Error ? error.message : 'Could not start tracking.', ERROR); return false; } + if (track) { + this.trackBlocks.set(watch.id, {reserved: 0, finalized: false}); + const place = cwd === this.shellCwd ? 'here' : `in ${displayPath(cwd)}`; + this.output.addTrackBlock(watch.id, command, [`${GLYPHS.info} Tracking ${watch.command} ${place} with sh -c and your shell's PATH (not its aliases or functions). /track stop ends it.`]); + } + return true; + } + /** /watch …: start (classified first), list, pause, resume, run now, stop. */ - private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start', argument: string): void { + private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy', argument: string, track = false): void { const active = this.watches.active(); const pick = () => { const index = /^\d+$/u.test(argument) ? Number(argument) - 1 : active.length - 1; @@ -8524,24 +8743,41 @@ export class TerminalApp { }; if (op === 'list') { this.panelOrigin = undefined; this.watchPanel = {selected: 0, output: false}; return; } if (op === 'start') { - const {command: watched, intervalMs} = parseWatch(argument); + const parsed = parseWatch(argument); + if (parsed.error) { this.output.addFrontendInteraction(command, parsed.error, ERROR); return; } + const {command: watched, intervalMs} = parsed; const safety = watchSafety(watched); if (safety.kind === 'refused') { this.output.addFrontendInteraction(command, safety.reason, ERROR); return; } this.panelOrigin = undefined; if (safety.kind === 'confirm' || safety.remote) { this.watchPanel = {selected: 0, output: false, confirm: {command: watched, cwd: this.shellCwd, ...(intervalMs ? {intervalMs} : {}), - reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no'}}; + reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no', ...(track ? {track: true} : {})}}; return; } - this.watches.start(watched, this.shellCwd, intervalMs, safety); - this.watchPanel = {selected: this.watches.active().length - 1, output: false}; + // /track shows its result in the transcript; /watch opens its manager as before. + if (this.startWatch(command, watched, this.shellCwd, intervalMs, safety, track) && !track) this.watchPanel = {selected: this.watches.active().length - 1, output: false}; + return; + } + const noun = track ? 'tracker' : 'watch'; + if (op === 'stop' && argument === 'all') { + for (const watch of active) this.watches.stop(watch.id); + this.output.addFrontendInteraction(command, active.length ? `Stopped every ${noun}; their processes are being ended.` : `Nothing is being ${track ? 'tracked' : 'watched'}.`, INFO); return; } - if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stopped every watch.', INFO); return; } const watch = pick(); - if (!watch) { this.output.addFrontendInteraction(command, 'Nothing is being watched.', INFO); return; } - const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : (this.watches.runNow(watch.id), true); - this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Running' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the watch on ${watch.command}.` : 'Nothing changed.', INFO); + if (!watch) { this.output.addFrontendInteraction(command, `Nothing is being ${track ? 'tracked' : 'watched'}.`, INFO); return; } + if (op === 'copy') { + if (!watch.current) { this.output.addFrontendInteraction(command, `No completed result of ${watch.command} yet.`, INFO); return; } + // The latest completed result only, as plain text: never earlier refreshes, never the block's own lines. + void this.copyText(watch.current.output, `Copied the latest result of ${watch.command.slice(0, 40)}`); + return; + } + if (op === 'now' && !this.watches.runNow(watch.id)) { + this.output.addFrontendInteraction(command, `A result of ${watch.command} is still being produced; no second one was started.`, INFO); + return; + } + const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : true; + this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Refreshing' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the ${noun} on ${watch.command}.` : 'Nothing changed.', INFO); } /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ @@ -10352,6 +10588,8 @@ export class TerminalApp { this.lastPtyRows = plan.ptyRows; this.lastPtyColumns = columns; this.session.resize(columns, plan.ptyRows); + this.output.setScreenRows(plan.ptyRows); + this.output.setScreenColumns(columns); } const wrapped = this.output.wrapped(columns); diff --git a/src/appearance/ThemeStudio.ts b/src/appearance/ThemeStudio.ts index 679ab433..794b4906 100644 --- a/src/appearance/ThemeStudio.ts +++ b/src/appearance/ThemeStudio.ts @@ -26,7 +26,7 @@ import {categoryOf, findTheme, IMPORTER_VERSION, provenanceLabel, type ThemeAsse import {assetRef, builtinRef, builtinTheme, type ThemeRef} from './themeRefs.js'; import {IMPORT_FORMAT_CHOICES, IMPORT_SIZE_LIMIT, importFormatLabel, importThemeSource, type ImportOutcome, type ImportPreview} from './themeImporters.js'; import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; -import type {CatppuccinAccent} from './themeFamilies.js'; +import {THEME_FAMILIES, themeVariant, type CatppuccinAccent} from './themeFamilies.js'; /** * Theme Studio (/theme): create, edit, import, manage and select NMSh Native @@ -300,6 +300,8 @@ export interface StudioContext { export interface ThemeStudioState { tab: StudioTab; + /** Built-in tab: `/` narrows the list by name, family, or dark/light. */ + builtinFilter?: {text: string; editing: boolean}; focus: 'tabs' | 'list'; /** Selected row per library tab. */ selected: Record<Exclude<StudioTab, 'import'>, number>; @@ -354,8 +356,20 @@ function libraryItems(context: StudioContext, tab: 'imported' | 'custom'): Theme return context.themes.filter(asset => categoryOf(asset) === tab); } +/** The built-in themes the filter keeps (every one without a filter): each word must match the name, family or dark/light. */ +function builtinList(state: ThemeStudioState): Array<Exclude<NativePaletteId, 'custom'>> { + const query = state.builtinFilter?.text.trim().toLowerCase(); + if (!query) return BUILTIN_PALETTES; + return BUILTIN_PALETTES.filter(palette => { + const variant = themeVariant(palette); + const family = THEME_FAMILIES.find(item => item.id === (variant?.family ?? 'nmsh'))?.label ?? 'NMSh'; + const words = `${builtinTheme(palette, 'mauve').name} ${family} ${variant ? (variant.dark ? 'dark' : 'light') : ''}`.toLowerCase(); + return query.split(/\s+/u).every(term => words.includes(term)); + }); +} + function rowCount(state: ThemeStudioState, context: StudioContext): number { - if (state.tab === 'builtin') return BUILTIN_PALETTES.length; + if (state.tab === 'builtin') return builtinList(state).length; if (state.tab === 'imported') return libraryItems(context, 'imported').length; if (state.tab === 'custom') return libraryItems(context, 'custom').length + CUSTOM_ACTIONS; return 0; @@ -369,7 +383,7 @@ export function selectedAsset(state: ThemeStudioState, context: StudioContext): } export function selectedBuiltinRef(state: ThemeStudioState, context: StudioContext): ThemeRef { - return builtinRef(BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender', context.accent); + return builtinRef(builtinList(state)[state.selected.builtin] ?? 'lavender', context.accent); } /** The theme the studio is showing: the editor draft, the import preview, or the selected row. */ @@ -377,7 +391,7 @@ export function previewTheme(state: ThemeStudioState, context: StudioContext): { if (state.editor) return {theme: state.editor.draft}; if (state.tab === 'import') return state.importPreview ? {theme: state.importPreview.theme} : undefined; if (state.tab === 'builtin') { - const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + const palette = builtinList(state)[state.selected.builtin] ?? 'lavender'; return {theme: builtinTheme(palette, context.accent), palette}; } const asset = selectedAsset(state, context); @@ -394,7 +408,7 @@ const CUSTOM_ACTIONS = 2; /** C toggles the local preview Chroma wherever no text is being typed. */ function typing(state: ThemeStudioState): boolean { - return Boolean(state.rename || state.editor?.picker || state.editor?.editingName !== undefined || (state.tab === 'import' && state.importField === 'path' && state.focus === 'list' && !state.importPreview && !state.editor)); + return Boolean(state.rename || (state.tab === 'builtin' && state.builtinFilter?.editing) || state.editor?.picker || state.editor?.editingName !== undefined || (state.tab === 'import' && state.importField === 'path' && state.focus === 'list' && !state.importPreview && !state.editor)); } export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string, context: StudioContext): StudioAction | undefined { @@ -438,6 +452,20 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, const tabStep = tabCycleDelta(key); if (tabStep && !(state.tab === 'import' && state.focus !== 'tabs' && state.importField === 'path')) { switchTab(state, tabStep); return undefined; } if (state.tab === 'import') return importKey(state, key, cwd); + if (state.tab === 'builtin' && state.builtinFilter) { + const filter = state.builtinFilter; + // Esc clears a filter before it closes the studio. + if (key.kind === 'escape') { state.builtinFilter = undefined; state.selected.builtin = 0; return undefined; } + if (filter.editing) { + if (key.kind === 'enter') { filter.editing = false; return undefined; } + const next = editText(filter.text, key); + if (next !== undefined) { filter.text = next.slice(0, 40); state.selected.builtin = 0; return undefined; } + } + } + if (state.tab === 'builtin' && state.focus === 'list' && key.kind === 'text' && key.value === '/') { + state.builtinFilter = {text: state.builtinFilter?.text ?? '', editing: true}; + return undefined; + } if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; if (key.kind === 'left' || key.kind === 'right') { switchTab(state, key.kind === 'left' ? -1 : 1); return undefined; } if (state.focus === 'tabs') { @@ -458,7 +486,7 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, if (letter === 'd') return {kind: 'duplicateBuiltin', ref}; if (letter === 'e') { // Built-ins are immutable: editing starts a new Custom theme from it. - const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + const palette = builtinList(state)[state.selected.builtin] ?? 'lavender'; state.editor = createThemeEditor(undefined, palette); state.editor.draft = {...builtinTheme(palette, context.accent), name: `My ${builtinTheme(palette, context.accent).name}`.slice(0, 48)}; } @@ -565,14 +593,20 @@ export function renderThemeStudio(state: ThemeStudioState, context: StudioContex if (state.tab === 'builtin') { body.push(` ${subtle}NMSh and bundled theme families. Built-ins are immutable; duplicate one to edit it.${RESET}`); - const {start, items} = window(BUILTIN_PALETTES, state.selected.builtin); + const visible = builtinList(state); + if (state.builtinFilter) { + const caret = state.builtinFilter.editing ? `${accent}▏${RESET}` : ''; + body.push(` ${accent}Filter:${RESET} ${state.builtinFilter.text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '')}${caret}${subtle} · ${visible.length} of ${BUILTIN_PALETTES.length}${RESET}`); + if (!visible.length) body.push(` ${subtle}No theme matches; Esc clears the filter.${RESET}`); + } + const {start, items} = window(visible, state.selected.builtin); items.forEach((palette, offset) => { const index = start + offset; const theme = builtinTheme(palette, context.accent); const ref = builtinRef(palette, context.accent); body.push(`${mark(index === state.selected.builtin)} ${focusForeground(index === state.selected.builtin)}${padCells(theme.name, 26)}${RESET}${swatches(Object.values(theme.prompt).slice(0, 6), level)}${active(ref)}${pinned(ref)}`); }); - controls.push(['Enter', 'set active'], ['D', 'duplicate to Custom'], ['E', 'edit a copy']); + controls.push(['Enter', 'set active'], ['/', 'filter'], ['D', 'duplicate to Custom'], ['E', 'edit a copy']); } else if (state.tab === 'imported' || state.tab === 'custom') { const items = libraryItems(context, state.tab); const rows: Array<{label: string; detail: string; ref?: ThemeRef; colors?: string[]}> = state.tab === 'custom' diff --git a/src/appearance/themeFamilies.ts b/src/appearance/themeFamilies.ts index 48cad325..87de31cb 100644 --- a/src/appearance/themeFamilies.ts +++ b/src/appearance/themeFamilies.ts @@ -44,7 +44,8 @@ export interface ThemeVariant { accents?: Record<CatppuccinAccent, string>; } -export type ThemeFamilyId = 'nmsh' | 'catppuccin' | 'dracula' | 'tokyonight' | 'gruvbox' | 'rosepine' | 'nord' | 'solarized' | 'onedark' | 'custom'; +export type ThemeFamilyId = 'nmsh' | 'catppuccin' | 'dracula' | 'tokyonight' | 'gruvbox' | 'rosepine' | 'nord' | 'solarized' | 'onedark' + | 'ayu' | 'kanagawa' | 'everforest' | 'flexoki' | 'nightowl' | 'custom'; export interface ThemeFamily { id: ThemeFamilyId; @@ -65,6 +66,11 @@ export const THEME_FAMILIES: readonly ThemeFamily[] = [ {id: 'nord', label: 'Nord', variantLabel: 'Variant', source: 'https://github.com/nordtheme/nord', license: 'MIT'}, {id: 'solarized', label: 'Solarized', variantLabel: 'Variant', source: 'https://github.com/altercation/solarized', license: 'MIT'}, {id: 'onedark', label: 'One Dark', variantLabel: 'Variant', source: 'https://github.com/atom/one-dark-syntax', license: 'MIT'}, + {id: 'ayu', label: 'Ayu', variantLabel: 'Version', source: 'https://github.com/ayu-theme/ayu-colors', license: 'MIT'}, + {id: 'kanagawa', label: 'Kanagawa', variantLabel: 'Theme', source: 'https://github.com/rebelot/kanagawa.nvim', license: 'MIT'}, + {id: 'everforest', label: 'Everforest', variantLabel: 'Variant', source: 'https://github.com/sainnhe/everforest', license: 'MIT'}, + {id: 'flexoki', label: 'Flexoki', variantLabel: 'Variant', source: 'https://github.com/kepano/flexoki', license: 'MIT'}, + {id: 'nightowl', label: 'Night Owl', variantLabel: 'Variant', source: 'https://github.com/sdras/night-owl-vscode-theme', license: 'MIT'}, {id: 'custom', label: 'Custom', variantLabel: 'Theme'}, ]; @@ -172,6 +178,64 @@ export const THEME_VARIANTS: readonly ThemeVariant[] = [ roles: {project: '#4078f2', cwd: '#d4d4d6', gitBranch: '#a626a4', node: '#50a14f', go: '#0184bc', python: '#c18401', docker: '#986801', kubernetes: '#a0a1a7', success: '#50a14f', failure: '#e45649'}, ui: {accent: '#4078f2', separator: '#a0a1a7', success: '#50a14f', failure: '#e45649'}}, + // ayu-theme/ayu-colors themes/{dark,mirage,light}.yaml (d461f029): the palette's base colors, editor and UI + // foregrounds, panel background and accent tint as written; syntax tones derived from them are not used. + {id: 'ayuDark', family: 'ayu', variant: 'Dark', label: 'Ayu Dark', description: 'Ayu Dark, warm accents on near black', dark: true, + roles: {project: '#e6b450', cwd: '#5a6673', gitBranch: '#d2a6ff', node: '#aad94c', go: '#95e6cb', python: '#e6c08a', docker: '#59c2ff', + kubernetes: '#39bae6', success: '#aad94c', failure: '#f07178'}, + ui: {accent: '#e6b450', separator: '#5a6378', success: '#aad94c', failure: '#d95757', primary: '#bfbdb6', secondary: '#bfbdb6', subtle: '#5a6378', selection: '#141821'}}, + {id: 'ayuMirage', family: 'ayu', variant: 'Mirage', label: 'Ayu Mirage', description: 'Ayu Mirage, softer on blue-gray', dark: true, + roles: {project: '#ffcc66', cwd: '#6e7c8f', gitBranch: '#dfbfff', node: '#d5ff80', go: '#95e6cb', python: '#d9be98', docker: '#73d0ff', + kubernetes: '#5ccfe6', success: '#d5ff80', failure: '#f28779'}, + ui: {accent: '#ffcc66', separator: '#707a8c', success: '#d5ff80', failure: '#ff6666', primary: '#cccac2', secondary: '#cccac2', subtle: '#707a8c', selection: '#282e3b'}}, + {id: 'ayuLight', family: 'ayu', variant: 'Light', label: 'Ayu Light', description: 'Ayu Light, bright and clear', dark: false, + roles: {project: '#f29718', cwd: '#adaeb1', gitBranch: '#a37acc', node: '#86b300', go: '#4cbf99', python: '#eba400', docker: '#22a4e6', + kubernetes: '#55b4d4', success: '#86b300', failure: '#f07171'}, + ui: {accent: '#f29718', separator: '#828e9f', success: '#86b300', failure: '#e65050'}}, + // rebelot/kanagawa.nvim lua/kanagawa/colors.lua and themes.lua (bb85e4bf): each theme's own role colors + // (fun, keyword, string, type, identifier, bg_p2, nontext, comment, bg_visual, ok, error). + {id: 'kanagawaWave', family: 'kanagawa', variant: 'Wave', label: 'Kanagawa Wave', description: 'Kanagawa Wave, ink and wave blue', dark: true, + roles: {project: '#7e9cd8', cwd: '#363646', gitBranch: '#957fb8', node: '#98bb6c', go: '#7aa89f', python: '#e6c384', docker: '#7fb4ca', + kubernetes: '#d27e99', success: '#98bb6c', failure: '#e82424'}, + ui: {accent: '#7e9cd8', separator: '#54546d', success: '#98bb6c', failure: '#e82424', primary: '#dcd7ba', secondary: '#c8c093', subtle: '#727169', selection: '#223249'}}, + {id: 'kanagawaDragon', family: 'kanagawa', variant: 'Dragon', label: 'Kanagawa Dragon', description: 'Kanagawa Dragon, dark and muted', dark: true, + roles: {project: '#8ba4b0', cwd: '#393836', gitBranch: '#8992a7', node: '#8a9a7b', go: '#8ea4a2', python: '#c4b28a', docker: '#658594', + kubernetes: '#a292a3', success: '#98bb6c', failure: '#e82424'}, + ui: {accent: '#8ba4b0', separator: '#625e5a', success: '#98bb6c', failure: '#e82424', primary: '#c5c9c5', secondary: '#c8c093', subtle: '#737c73', selection: '#223249'}}, + {id: 'kanagawaLotus', family: 'kanagawa', variant: 'Lotus', label: 'Kanagawa Lotus', description: 'Kanagawa Lotus, the light theme', dark: false, + roles: {project: '#4d699b', cwd: '#e4d794', gitBranch: '#624c83', node: '#6f894e', go: '#597b75', python: '#77713f', docker: '#6693bf', + kubernetes: '#b35b79', success: '#6f894e', failure: '#e82424'}, + ui: {accent: '#4d699b', separator: '#a09cac', success: '#6f894e', failure: '#e82424'}}, + // sainnhe/everforest palette.md (85a86eb6), medium contrast: backgrounds by position (bg0…bg5), accents, greys. + // Soft and hard change only the editor background, which NMSh does not paint. + {id: 'everforestDark', family: 'everforest', variant: 'Dark', label: 'Everforest Dark', description: 'Everforest Dark, green-based and calm', dark: true, + roles: {project: '#a7c080', cwd: '#475258', gitBranch: '#d699b6', node: '#83c092', go: '#7fbbb3', python: '#dbbc7f', docker: '#7fbbb3', + kubernetes: '#e69875', success: '#a7c080', failure: '#e67e80'}, + ui: {accent: '#a7c080', separator: '#7a8478', success: '#a7c080', failure: '#e67e80', primary: '#d3c6aa', secondary: '#9da9a0', subtle: '#7a8478', selection: '#3d484d'}}, + {id: 'everforestLight', family: 'everforest', variant: 'Light', label: 'Everforest Light', description: 'Everforest Light, warm paper', dark: false, + roles: {project: '#8da101', cwd: '#bdc3af', gitBranch: '#df69ba', node: '#35a77c', go: '#3a94c5', python: '#dfa000', docker: '#3a94c5', + kubernetes: '#f57d26', success: '#8da101', failure: '#f85552'}, + ui: {accent: '#8da101', separator: '#a6b0a0', success: '#8da101', failure: '#f85552'}}, + // kepano/flexoki README (8d723bac): dark uses the 400 accents and base tiers (tx base-200, tx-2 base-500, + // tx-3 base-700, ui-2 base-850, ui-3 base-800); light uses the 600 accents (ui-3 base-200, tx-3 base-300). + {id: 'flexokiDark', family: 'flexoki', variant: 'Dark', label: 'Flexoki Dark', description: 'Flexoki Dark, inky and warm', dark: true, + roles: {project: '#4385be', cwd: '#403e3c', gitBranch: '#8b7ec8', node: '#879a39', go: '#3aa99f', python: '#d0a215', docker: '#da702c', + kubernetes: '#ce5d97', success: '#879a39', failure: '#d14d41'}, + ui: {accent: '#4385be', separator: '#575653', success: '#879a39', failure: '#d14d41', primary: '#cecdc3', secondary: '#878580', subtle: '#575653', selection: '#343331'}}, + {id: 'flexokiLight', family: 'flexoki', variant: 'Light', label: 'Flexoki Light', description: 'Flexoki Light, paper and ink', dark: false, + roles: {project: '#205ea6', cwd: '#cecdc3', gitBranch: '#5e409d', node: '#66800b', go: '#24837b', python: '#ad8301', docker: '#bc5215', + kubernetes: '#a02f6f', success: '#66800b', failure: '#af3029'}, + ui: {accent: '#205ea6', separator: '#b7b5ac', success: '#66800b', failure: '#af3029'}}, + // sdras/night-owl-vscode-theme themes/Night Owl-color-theme.json and Night Owl-Light-color-theme.json (cc291eba): + // editor, selection, line-number and terminal ANSI colors, and the comment token color. + {id: 'nightOwl', family: 'nightowl', variant: 'Night Owl', label: 'Night Owl', description: 'Night Owl, for night owls', dark: true, + roles: {project: '#82aaff', cwd: '#1d3b53', gitBranch: '#c792ea', node: '#22da6e', go: '#7fdbca', python: '#ffeb95', docker: '#21c7a8', + kubernetes: '#f78c6c', success: '#22da6e', failure: '#ef5350'}, + ui: {accent: '#82aaff', separator: '#4b6479', success: '#22da6e', failure: '#ef5350', primary: '#d6deeb', secondary: '#d6deeb', subtle: '#637777', selection: '#1d3b53'}}, + {id: 'nightOwlLight', family: 'nightowl', variant: 'Light Owl', label: 'Light Owl', description: 'Night Owl\'s light theme', dark: false, + roles: {project: '#288ed7', cwd: '#e0e0e0', gitBranch: '#d6438a', node: '#08916a', go: '#2aa298', python: '#e0af02', docker: '#288ed7', + kubernetes: '#d6438a', success: '#08916a', failure: '#de3d3b'}, + ui: {accent: '#288ed7', separator: '#90a7b2', success: '#08916a', failure: '#e64d49'}}, ]; export function themeVariant(id: string): ThemeVariant | undefined { diff --git a/src/appearance/themeSelection.ts b/src/appearance/themeSelection.ts index 4936dea7..33e1e9b2 100644 --- a/src/appearance/themeSelection.ts +++ b/src/appearance/themeSelection.ts @@ -27,7 +27,8 @@ export function variantOptions(family: ThemeFamilyId): VariantOption[] { /** The variant a family opens on: the documented default for each family. */ const DEFAULT_VARIANT: Record<ThemeFamilyId, NativePaletteId> = { nmsh: 'lavender', catppuccin: 'catppuccinMocha', dracula: 'dracula', tokyonight: 'tokyonightNight', gruvbox: 'gruvboxDark', - rosepine: 'rosePine', nord: 'nord', solarized: 'solarizedDark', onedark: 'oneDark', custom: 'custom', + rosepine: 'rosePine', nord: 'nord', solarized: 'solarizedDark', onedark: 'oneDark', + ayu: 'ayuMirage', kanagawa: 'kanagawaWave', everforest: 'everforestDark', flexoki: 'flexokiDark', nightowl: 'nightOwl', custom: 'custom', }; const UI_DEFAULT_HEX = (): Record<UiThemeRole, string> => { diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 9eda8b8a..a4a9c32e 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -158,9 +158,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'doctor', label: 'Doctor', support: 'actionable', open: '/doctor', covers: ['/doctor'], aliases: ['doctor', 'health check', 'diagnostics', 'diagnose', 'check my setup', 'is everything ok', 'whats wrong with my setup', 'check environment'], description: '/doctor checks NMSh, the shell, this project, Git, tools, the local model and the host terminal with local, read-only checks, and offers actions that open the right place; it never fixes anything silently.'}, - {id: 'watch', label: 'Watch', support: 'actionable', open: '/watch', covers: ['/watch'], - aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command'], - description: '/watch runs a command on NMSh\'s own schedule and shows what changed in one live block (pause, run now, stop). Commands that install, modify or destroy are refused; unknown ones need a Yes.'}, + {id: 'watch', label: 'Watch and track', support: 'actionable', open: '/watch', covers: ['/watch', '/track'], + aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command', 'track a command', 'live result', 'refresh a command'], + description: '/track df -h keeps one live result of a finite command in the transcript, refreshed in place every few seconds (/track --every 5s -- cmd; pause, resume, refresh, copy, stop; stopping leaves the last result once). /watch runs the same scheduler with its manager (what changed between runs). Both run sh -c with your shell\'s PATH, not its aliases; commands that install, modify or destroy are refused and unknown ones need a Yes. Trackers stop with the window that started them.'}, {id: 'pastePreview', label: 'Paste preview', support: 'settings', open: '/settings', where: 'Settings → Editor → Paste preview (Smart, Always, Off)', covers: ['settings:Editor'], aliases: ['paste preview', 'paste guard', 'pasting', 'paste safety', 'pasted commands'], description: 'Multiline, chained, mutating or risky pastes are shown (muted, above the composer) before they enter it; Insert keeps the exact text, and nothing runs until you press Enter.'}, @@ -253,6 +253,12 @@ export const CONCEPTS: readonly Concept[] = [ {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: 'view', label: 'View a file or output', support: 'actionable', open: '/view', covers: ['/view'], + aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view log', 'pager'], + description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text. less, bat and Glow still work as usual.'}, + {id: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'], + aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'], + description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR still work as usual.'}, {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', 'run as block'], description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch with choices to run as one block, queue separately, edit or cancel; 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.'}, @@ -364,7 +370,7 @@ 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', 'output-actions', 'compare', 'transcripts', 'pins']}, + {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', 'output-actions', 'view', 'edit-file', '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'], diff --git a/src/backgroundTasks/supervisor.ts b/src/backgroundTasks/supervisor.ts index 4c3e5cb2..583b7b33 100644 --- a/src/backgroundTasks/supervisor.ts +++ b/src/backgroundTasks/supervisor.ts @@ -79,12 +79,16 @@ interface LiveTask { /** The live guard owns signalling from inside its group; no numeric group id is retained. */ group?: TaskGroup; leaderExited?: boolean; + /** Since when the leader's exit waits for the guard's verdict on the rest of the group (performance.now()). */ + verdictSince?: number; pending?: ReturnType<typeof setTimeout>; /** Observes the command leader separately from the guard's eventual exit. */ watch?: ReturnType<typeof setInterval>; } const ORDER: readonly StopLevel[] = ['interrupt', 'terminate', 'kill']; +/** How long a finished leader waits for the guard's verdict when it cannot get one (its `ps` keeps failing). */ +const VERDICT_MS = 3000; export class BackgroundTaskSupervisor { private readonly tasks = new Map<string, LiveTask>(); @@ -124,11 +128,20 @@ export class BackgroundTaskSupervisor { this.tasks.set(id, task); pty.onData(data => { task.output.append(data); this.changed(task); }); const observeLeader = () => { - if (task.leaderExited) return; - const exit = task.group?.leaderExit(); - if (exit) { + if (!task.leaderExited) { + const exit = task.group?.leaderExit(); + if (!exit) return; + task.leaderExited = true; + this.exited(task, exit.exitCode, exit.signal); + } + // The command has exited. The guard decides how it ended: it exits when nothing else of the group runs (onExit + // below), or says that something does. Only if it cannot tell in time is something reported as left. + if (task.verdictSince === undefined) { clearInterval(task.watch); task.watch = undefined; return; } + if (task.group?.leftBehind() || performance.now() - task.verdictSince > VERDICT_MS) { + task.verdictSince = undefined; clearInterval(task.watch); task.watch = undefined; - task.leaderExited = true; this.exited(task, exit.exitCode, exit.signal); + if (task.record.stopRequested) { task.record.leftovers = true; this.changed(task, true); } + else this.finish(task); } }; task.watch = setInterval(observeLeader, 25); @@ -140,7 +153,7 @@ export class BackgroundTaskSupervisor { clearInterval(task.watch); task.watch = undefined; if (!task.leaderExited) this.exited(task, exitCode, signal); - else if (task.record.state === 'stopping') this.finish(task); + else if (task.record.state === 'stopping' || task.verdictSince !== undefined) { task.verdictSince = undefined; this.finish(task); } else { task.record.leftovers = false; this.changed(task, true); } }); this.changed(task, true); @@ -236,6 +249,8 @@ export class BackgroundTaskSupervisor { this.changed(task, true); return; } + // Whether anything else of the group still runs is not known yet: wait for the guard's verdict, never guess. + if (task.group?.alive === true) { task.verdictSince = performance.now(); return; } this.finish(task); } diff --git a/src/backgroundTasks/taskGroup.ts b/src/backgroundTasks/taskGroup.ts index 9c011597..537d3aaa 100644 --- a/src/backgroundTasks/taskGroup.ts +++ b/src/backgroundTasks/taskGroup.ts @@ -53,6 +53,11 @@ export class TaskGroup { return undefined; } + /** The guard found members of the group still running after the command's leader exited. */ + leftBehind(): boolean { + try { return readFileSync(join(this.directory, 'left'), 'utf8') === '1'; } catch { return false; } + } + close(): void { this.alive = false; rmSync(this.directory, {recursive: true, force: true}); diff --git a/src/backgroundTasks/taskGroupGuard.ts b/src/backgroundTasks/taskGroupGuard.ts index 771c0ad6..ec413203 100644 --- a/src/backgroundTasks/taskGroupGuard.ts +++ b/src/backgroundTasks/taskGroupGuard.ts @@ -35,6 +35,7 @@ const saveExit = (exitCode: number, signal?: number) => { child.on('error', () => saveExit(127)); child.on('exit', (code, signal) => saveExit(code ?? 0, signal ? constants.signals[signal] : undefined)); let lastProbe = -Infinity; +let reported = false; const poll = setInterval(() => { // An orphaned guard owns its group, never the former parent's possibly recycled PID. if (process.ppid !== parent) { rmSync(directory, {recursive: true, force: true}); process.kill(0, 'SIGKILL'); } @@ -49,4 +50,9 @@ const poll = setInterval(() => { return Number(pgid) === process.pid && Number(pid) !== process.pid && Number(ppid) !== process.pid && !state?.startsWith('Z'); }); if (!members) { clearInterval(poll); process.exit(0); } + // Say once that members remain, so the supervisor reports them now instead of waiting for this guard to exit. + if (!reported) { + reported = true; + try { writeFileSync(`${directory}/left-next`, '1', {mode: 0o600}); renameSync(`${directory}/left-next`, `${directory}/left`); } catch { /* The supervisor's bound applies. */ } + } }, 25); diff --git a/src/clipboard/copySelection.ts b/src/clipboard/copySelection.ts index 342e0cb5..2b6fcfa2 100644 --- a/src/clipboard/copySelection.ts +++ b/src/clipboard/copySelection.ts @@ -1,5 +1,6 @@ import {serializeCopyPayload, type CompletedCommand} from '../output/OutputBuffer.js'; import {parseReportFlag, type ReportFormat} from './report.js'; +import {semanticText, type SemanticResult} from '../output/semantic.js'; /** * Which completed commands /copy takes, by recency (1 is the latest), and whether NMSh's completion status follows @@ -16,6 +17,8 @@ import {parseReportFlag, type ReportFormat} from './report.js'; * - `--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). + * - `--raw` (the default), `--semantic` or `--both`: the stored output, the Semantic Output reading (raw for a record + * that has none), or both under labelled NMSh separators. One of them at most; never with --report. * `/cp` is the same command: it reaches this parser with the same arguments. */ export const COPY_MAX_INDEX = 999; @@ -34,22 +37,31 @@ export interface CopyRequest { status?: boolean; /** Copy as a report in this format instead of plain output. */ report?: ReportFormat; + /** What of each record is copied; undefined is raw (the stored output). */ + content?: CopyContent; } +export type CopyContent = 'raw' | 'semantic' | 'both'; + 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].'; +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, --report[=plain], or one of --raw, --semantic, --both.'; /** 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; + let content: CopyContent | 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') { + if (token === '--raw' || token === '--semantic' || token === '--both') { + const value = token.slice(2) as CopyContent; + if (content !== undefined && content !== value) return {ok: false, error: 'Use one of --raw, --semantic or --both.'}; + content = value; + } else 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; @@ -58,10 +70,11 @@ export function parseCopyArgs(text: string): CopyArgs { } 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} : {})}}); + if (report && content) return {ok: false, error: 'A report has its own format; leave out --raw, --semantic and --both.'}; + const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {}), ...(content ? {content} : {})}}); const selector = selectors[0]; if (selector === undefined) return withStatus({kind: 'default'}); - if (selector === 'ui') return withStatus({kind: 'picker'}); + if (selector === 'ui') return content && content !== 'raw' ? {ok: false, error: '--semantic and --both copy directly: name the outputs (/copy 2 --semantic).'} : withStatus({kind: 'picker'}); if (selector === 'latest') return withStatus({kind: 'latest', count: 1}); const latest = /^-([1-9]\d*)$/u.exec(selector); if (latest) { @@ -130,6 +143,25 @@ export function copySelectionPayload(records: readonly CompletedCommand[], inclu .reduce((payload, text) => !payload ? text : payload.endsWith('\n') ? payload + text : `${payload}\n${text}`, ''); } +/** + * `/copy --semantic` and `--both`: per record, the Semantic Output reading as text, or (both) the raw output and the + * reading under labelled NMSh separators that cannot be mistaken for output. A record without a reading copies its raw + * output once, and is counted so the note can say so. Records keep their order and separation as in a raw copy. + */ +export function semanticCopyPayload(records: readonly CompletedCommand[], content: 'semantic' | 'both', includeStatus: boolean, + reading: (record: CompletedCommand) => SemanticResult | undefined): {payload: string; unavailable: number} { + let unavailable = 0; + const parts = records.map(record => { + const raw = recordCopyText(record, includeStatus); + const result = reading(record); + if (!result) { unavailable += 1; return raw; } + if (content === 'semantic') return semanticText(result); + return [`── NMSh · raw output ──`, raw.replace(/\r?\n$/u, ''), `── NMSh · Semantic Output ──`, semanticText(result)].join('\n'); + }).filter(Boolean); + const payload = parts.reduce((joined, text) => !joined ? text : joined.endsWith('\n') ? joined + text : `${joined}\n${text}`, ''); + return {payload, unavailable}; +} + /** 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`; diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 331dc1bf..8791c228 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -39,8 +39,10 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/action', insertion: '/action', alias: '/actions', description: 'Same as /actions, including /action N'}, {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti in owned chrome; /effects stop cancels'}, {name: '/copy', insertion: '/copy', description: 'Select completed output: Up/Down, Enter copies, Esc returns (saved Quick Copy preferences still apply)'}, - {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 N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status; --semantic or --both the Semantic Output reading'}, {name: '/copy settings', insertion: '/copy settings', description: 'Output navigation, Quick Copy or Interactive Picker; completion status and auto-expand preferences'}, + {name: '/view', insertion: '/view ', description: 'Read a file or a completed output in a native viewer: /view README.md · /view 2 (output 2, numbered as /copy) · /view (latest output)'}, + {name: '/edit', insertion: '/edit ', description: 'Edit a small text file in NMSh: /edit notes.md (Ctrl+S saves safely, Esc closes; vim, nano and your editor still work as usual)'}, {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'}, @@ -49,7 +51,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {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'}, - {name: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them'}, + {name: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them (/modules explain · /modules timings)'}, {name: '/cursor', insertion: '/cursor', description: 'Text caret shape and blink while NMSh owns the composer'}, {name: '/activity', insertion: '/activity', description: 'Live activity colors for the running-command line'}, {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, @@ -126,7 +128,8 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/qc', insertion: '/qc', alias: '/queue', description: 'Review before clearing queued commands'}, {name: '/pause', insertion: '/pause', alias: '/queue', description: 'Pause the command queue'}, {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings'}, - {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume|refresh)'}, + {name: '/track', insertion: '/track ', description: 'Keep one live result of a finite command in the transcript, refreshed in place (/track df -h · /track --every 5s -- docker system df · pause|resume|refresh|stop|copy)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, @@ -168,10 +171,14 @@ export type ParsedSlashCommand = | {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} + /** /view [path | N]: a file (relative to the shell's directory, ~ for home), output N as /copy numbers it, or the latest output. */ + | {kind: 'view'; target: string} + /** /edit <path>: NMSh's small native editor for one text file (relative to the shell's directory, ~ for home). */ + | {kind: 'editFile'; target: string} | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} - | {kind: 'modules'} + | {kind: 'modules'; report?: 'explain' | 'timings'} | {kind: 'chroma'} | {kind: 'theme'} | {kind: 'themeBridge'} @@ -226,7 +233,7 @@ export type ParsedSlashCommand = | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} | {kind: 'llm'} | {kind: 'doctor'} - | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} + | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy'; arguments: string; /** Typed as /track: keep one live result in the transcript. */ track?: boolean} | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} @@ -263,6 +270,10 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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'}; + const view = /^\/view(?:\s+([\s\S]*))?$/u.exec(input); + if (view) return {kind: 'view', target: (view[1] ?? '').trim()}; + const editFile = /^\/edit(?:\s+([\s\S]*))?$/u.exec(input); + if (editFile) return {kind: 'editFile', target: (editFile[1] ?? '').trim()}; // /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); @@ -270,7 +281,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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'}; - if (/^\/modules\s*$/u.test(input)) return {kind: 'modules'}; + const modules = /^\/modules(?:\s+(explain|timings))?\s*$/u.exec(input); + if (modules) return modules[1] ? {kind: 'modules', report: modules[1] as 'explain' | 'timings'} : {kind: 'modules'}; if (/^\/chroma\s*$/u.test(input)) return {kind: 'chroma'}; if (/^\/theme\s*$/u.test(input)) return {kind: 'theme'}; if (/^\/theme-bridge\s*$/u.test(input)) return {kind: 'themeBridge'}; @@ -377,11 +389,14 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const queue = /^\/(?:queue|q)(?:\s+(pause|resume|clear|settings|history|save-summary))?\s*$/u.exec(input); if (queue) return queue[1] === 'settings' ? {kind: 'settings', view: 'config', destination: 'queue'} : {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'}; - const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); + // /track shares the /watch scheduler, safety gate and manager; it also keeps one live result in the transcript. + const watch = /^\/(watch|track)(?:\s+(.*))?$/u.exec(input); if (watch) { - const rest = (watch[1] ?? '').trim(); - const op = /^(stop|pause|resume|now)(?:\s+(.*))?$/u.exec(rest); - return op ? {kind: 'watch', op: op[1] as 'stop' | 'pause' | 'resume' | 'now', arguments: (op[2] ?? '').trim()} : {kind: 'watch', op: rest ? 'start' : 'list', arguments: rest}; + const track = watch[1] === 'track'; + const rest = (watch[2] ?? '').trim(); + const op = new RegExp(`^(stop|pause|resume|now|refresh${track ? '|copy' : ''})(?:\\s+(.*))?$`, 'u').exec(rest); + const kind = op ? (op[1] === 'refresh' ? 'now' : op[1]) as 'stop' | 'pause' | 'resume' | 'now' | 'copy' : rest && rest !== 'list' ? 'start' : 'list'; + return {kind: 'watch', op: kind, arguments: op ? (op[2] ?? '').trim() : rest === 'list' ? '' : rest, ...(track ? {track: true} : {})}; } const rename = /^\/rename(?:\s+(.*))?$/u.exec(input); if (rename) return {kind: 'rename', name: (rename[1] ?? '').trim()}; diff --git a/src/context/capabilities/environment.ts b/src/context/capabilities/environment.ts index de29ec19..609ae706 100644 --- a/src/context/capabilities/environment.ts +++ b/src/context/capabilities/environment.ts @@ -77,4 +77,23 @@ export const direnv = defineCapability<DirenvFact>({ }, }); -export const ENVIRONMENT_CAPABILITIES = [toolVersions, direnv] as CapabilityDefinition<unknown>[]; +export interface NixShellFact { + /** `pure` / `impure` as nix-shell and nix develop report it; `active` for an older nix that sets only 1. */ + state: 'pure' | 'impure' | 'active'; +} + +export const nixShell = defineCapability<NixShellFact>({ + id: 'env.nixShell', + title: 'Nix shell', + reads: ['IN_NIX_SHELL, set in the shell by nix-shell and nix develop (never runs nix)'], + scope: 'session', family: 'metadata', cost: 'cheap', trust: 'session', sensitivity: 'public', persistence: 'snapshot-safe', + fields: ['state'], env: ['IN_NIX_SHELL'], ttlMs: 30_000, timeoutMs: 500, invalidateOn: ['command'], + preview: {state: 'impure'}, + async resolve(context) { + const value = context.env.values.IN_NIX_SHELL; + if (!value) return undefined; + return {value: {state: value === 'pure' || value === 'impure' ? value : 'active'}, evidence: 'IN_NIX_SHELL'}; + }, +}); + +export const ENVIRONMENT_CAPABILITIES = [toolVersions, direnv, nixShell] as CapabilityDefinition<unknown>[]; diff --git a/src/context/capabilities/runtimes.ts b/src/context/capabilities/runtimes.ts index 7dda17ed..90142ee8 100644 --- a/src/context/capabilities/runtimes.ts +++ b/src/context/capabilities/runtimes.ts @@ -366,6 +366,20 @@ export const zigRuntime = simpleRuntime('runtime.zig', 'Zig toolchain', 'zig', [ export const denoRuntime = simpleRuntime('runtime.deno', 'Deno runtime', 'deno', ['.dvmrc', 'deno.json / deno.jsonc presence', '.tool-versions', 'mise.toml'], {requested: '2.1.0', requestedFrom: '.dvmrc', active: '2.1.4', manager: 'Homebrew'}, async context => versionFile(context, ['.dvmrc']), ['deno.json', 'deno.jsonc', 'deno.lock']); +export const bunRuntime = simpleRuntime('runtime.bun', 'Bun runtime', 'bun', ['.bun-version', 'package.json (packageManager "bun@…", engines.bun)', '.tool-versions', 'mise.toml'], + {requested: '1.1.30', requestedFrom: 'package.json', active: '1.1.34', manager: 'Homebrew'}, async context => { + const file = await versionFile(context, ['.bun-version']); + if (file) return file; + const manifest = await findNearest(context, ['package.json']); + const pkg = manifest ? parseJsonData(await readMetadataText(manifest.path, LARGE_METADATA_BYTES)) : undefined; + if (!record(pkg)) return undefined; + // Corepack's pin names the package manager and its exact version; engines.bun is a range. + const pinned = /^bun@(\d[\w.+-]{0,31})$/u.exec(text(pkg.packageManager, 64) ?? '')?.[1]; + if (pinned) return {version: pinned, from: 'package.json packageManager'}; + const range = record(pkg.engines) ? text(pkg.engines.bun, 64) : undefined; + return range ? {version: range, from: 'package.json engines'} : undefined; + }, ['bun.lockb', 'bun.lock', 'bunfig.toml']); + /** A release version as an SDK's own version file states it (Dart's `version`, Flutter's `version`): one bounded line, nothing else. */ const RELEASE = /^v?(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]{1,32})?)$/u; async function releaseFile(path: string): Promise<string | undefined> { @@ -436,5 +450,5 @@ export const flutterRuntime = runtimeCapability('runtime.flutter', 'Flutter SDK' return value ? {value, evidence: [requested?.from, active.active ? 'flutter on PATH' : undefined].filter(Boolean).join(', ') || 'flutter'} : undefined; }); -export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition<unknown>[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, +export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition<unknown>[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, bunRuntime, elixirRuntime, dartRuntime, flutterRuntime] as CapabilityDefinition<unknown>[]; diff --git a/src/context/capabilities/system.ts b/src/context/capabilities/system.ts index d6530039..3f92ffe6 100644 --- a/src/context/capabilities/system.ts +++ b/src/context/capabilities/system.ts @@ -3,6 +3,7 @@ import {defineCapability, PREVIEW_NOW, type CapabilityDefinition} from '../capab import {parseXmlData, readMetadataText, record} from '../services.js'; import {runExternal} from '../../providers/providers.js'; import {linuxBattery, parseMeminfo, parsePmset, parseVmStat} from '../../status/systemStats.js'; +import {access} from 'node:fs/promises'; /** * Machine and session facts. Readers are OS metadata files and fixed system @@ -189,4 +190,25 @@ export const uptime = defineCapability<UptimeFact>({ async resolve() { return {value: {seconds: Math.floor(osUptime())}, evidence: 'system uptime'}; }, }); -export const SYSTEM_CAPABILITIES = [operatingSystem, sessionUser, sessionJobs, sessionDuration, clock, memory, battery, cpu, uptime] as CapabilityDefinition<unknown>[]; +export interface ContainerFact {kind: 'Codespaces' | 'Dev Container' | 'Podman' | 'Docker'} + +async function present(path: string): Promise<boolean> { + try { await access(path); return true; } catch { return false; } +} + +export const container = defineCapability<ContainerFact>({ + id: 'system.container', title: 'Container', + reads: ['/run/.containerenv and /.dockerenv presence (never their content)', 'CODESPACES and REMOTE_CONTAINERS set by dev container hosts'], + scope: 'machine', family: 'system', cost: 'bounded-async', trust: 'session', sensitivity: 'public', persistence: 'snapshot-safe', + fields: ['kind'], env: ['CODESPACES', 'REMOTE_CONTAINERS'], ttlMs: 3_600_000, timeoutMs: 500, invalidateOn: [], + preview: {kind: 'Docker'}, + async resolve(context) { + if (context.env.values.CODESPACES === 'true') return {value: {kind: 'Codespaces'}, evidence: 'CODESPACES'}; + if (context.env.values.REMOTE_CONTAINERS === 'true') return {value: {kind: 'Dev Container'}, evidence: 'REMOTE_CONTAINERS'}; + if (await present('/run/.containerenv')) return {value: {kind: 'Podman'}, evidence: '/run/.containerenv'}; + if (await present('/.dockerenv')) return {value: {kind: 'Docker'}, evidence: '/.dockerenv'}; + return undefined; + }, +}); + +export const SYSTEM_CAPABILITIES = [operatingSystem, sessionUser, sessionJobs, sessionDuration, clock, memory, battery, cpu, uptime, container] as CapabilityDefinition<unknown>[]; diff --git a/src/context/packs/builtin/nmsh.environment.json b/src/context/packs/builtin/nmsh.environment.json index c9b9257f..ed450ba1 100644 --- a/src/context/packs/builtin/nmsh.environment.json +++ b/src/context/packs/builtin/nmsh.environment.json @@ -7,7 +7,7 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["env.toolVersions", "env.direnv"], + "requires": ["env.toolVersions", "env.direnv", "env.nixShell"], "modules": [ { "id": "tools", "label": "mise / asdf", "description": "Tools the nearest mise.toml or .tool-versions requests (never runs mise or asdf).", @@ -26,10 +26,19 @@ {"text": "direnv "}, {"fact": "env.direnv", "field": "state"} ]}] + }, + { + "id": "nix", "label": "Nix shell", "description": "Whether this shell is inside nix-shell or nix develop, from IN_NIX_SHELL (never runs nix).", + "category": "environment", "priority": 37, "role": "system", "icon": "cubes", "condition": "always", + "surfaces": {"preferred": "rightContext"}, + "segments": [{"label": "nix", "parts": [ + {"fact": "env.nixShell", "field": "state"} + ]}] } ], "recommend": [ {"module": "tools", "evidence": [{"kind": "workspaceFile", "names": ["mise.toml", ".mise.toml", ".tool-versions"]}]}, - {"module": "direnv", "evidence": [{"kind": "workspaceFile", "names": [".envrc"]}]} + {"module": "direnv", "evidence": [{"kind": "workspaceFile", "names": [".envrc"]}]}, + {"module": "nix", "evidence": [{"kind": "workspaceFile", "names": ["flake.nix", "shell.nix"]}]} ] } diff --git a/src/context/packs/builtin/nmsh.project.json b/src/context/packs/builtin/nmsh.project.json index e81db5d4..33b83041 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", "project.build", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.dotnet", "runtime.swift", "runtime.zig", "runtime.deno", "runtime.elixir", "runtime.dart", "runtime.flutter", "runtime.rust", "runtime.java"], + "requires": ["project.package", "project.build", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.dotnet", "runtime.swift", "runtime.zig", "runtime.deno", "runtime.bun", "runtime.elixir", "runtime.dart", "runtime.flutter", "runtime.rust", "runtime.java"], "modules": [ { "id": "package", "label": "Package", "description": "Version and package manager from the nearest declarative manifest.", @@ -137,6 +137,18 @@ {"fact": "runtime.deno", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} ]}] }, + { + "id": "bun", "label": "Bun", "description": "Bun runtime from .bun-version, package.json (packageManager, engines) or the install location; never run.", + "category": "runtime", "priority": 38, "role": "runtime", "icon": "code", "condition": "onCommand", + "triggers": ["bun", "bunx"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "bun", "parts": [ + {"fact": "runtime.bun", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.bun", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.bun", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.bun", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, { "id": "elixir", "label": "Elixir", "description": "Elixir version from mix.exs, .tool-versions or the install location; never run.", "category": "runtime", "priority": 37, "role": "runtime", "icon": "elixir", "condition": "onCommand", @@ -208,6 +220,7 @@ {"module": "swift", "evidence": [{"kind": "workspaceFile", "names": ["Package.swift", ".swift-version"]}]}, {"module": "zig", "evidence": [{"kind": "workspaceFile", "names": ["build.zig", "build.zig.zon", ".zigversion"]}]}, {"module": "deno", "evidence": [{"kind": "workspaceFile", "names": ["deno.json", "deno.jsonc", ".dvmrc"]}]}, + {"module": "bun", "evidence": [{"kind": "workspaceFile", "names": ["bun.lockb", "bun.lock", "bunfig.toml", ".bun-version"]}]}, {"module": "build", "evidence": [{"kind": "workspaceFile", "names": ["MODULE.bazel", "WORKSPACE", "settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml", "CMakeLists.txt", "meson.build"]}]}, {"module": "elixir", "evidence": [{"kind": "workspaceFile", "names": ["mix.exs"]}]}, {"module": "dart", "evidence": [{"kind": "workspaceFile", "names": ["pubspec.yaml"]}]}, diff --git a/src/context/packs/builtin/nmsh.system.json b/src/context/packs/builtin/nmsh.system.json index f7949b44..92ec0a61 100644 --- a/src/context/packs/builtin/nmsh.system.json +++ b/src/context/packs/builtin/nmsh.system.json @@ -7,8 +7,16 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh; shares the Status Strip's OS readers."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["system.os", "session.user", "session.jobs", "session.duration", "system.time", "system.battery", "system.memory"], + "requires": ["system.os", "session.user", "session.jobs", "session.duration", "system.time", "system.battery", "system.memory", "system.container"], "modules": [ + { + "id": "container", "label": "Container", "description": "Whether this shell runs in a container (Docker, Podman, a dev container or Codespaces), from marker files and host variables.", + "category": "system", "priority": 14, "role": "system", "icon": "cubes", "condition": "always", + "surfaces": {"preferred": "rightContext"}, + "segments": [{"label": "container", "parts": [ + {"fact": "system.container", "field": "kind"} + ]}] + }, { "id": "os", "label": "Operating system", "description": "Operating system name and version.", "category": "system", "priority": 12, "role": "system", "condition": "always", diff --git a/src/context/packs/schema.ts b/src/context/packs/schema.ts index 31c43481..7153a62b 100644 --- a/src/context/packs/schema.ts +++ b/src/context/packs/schema.ts @@ -293,7 +293,8 @@ export function parsePack(bytes: string | Uint8Array, capabilities: ReadonlyMap< ...(provenance.notes !== undefined ? {notes: str(provenance.notes, 'pack.provenance.notes', 240)} : {})}, compatibility: {contextApi: bounded(compatibility.contextApi, 'pack.compatibility.contextApi', 1, 99), ...(compatibility.nmsh !== undefined ? {nmsh: str(compatibility.nmsh, 'pack.compatibility.nmsh', 32, NMSH_RANGE)} : {})}, - requires: list(record.requires, 'pack.requires', 16, (entry, at) => str(entry, at, 64, /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*){1,3}$/u)), + // As many capabilities as modules: a pack's modules may each need a different one. + requires: list(record.requires, 'pack.requires', 24, (entry, at) => str(entry, at, 64, /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*){1,3}$/u)), modules: list(record.modules, 'pack.modules', 24, module, 1), ...(record.recommend !== undefined ? {recommend: list(record.recommend, 'pack.recommend', 24, (entry, at) => { const rule = obj(entry, at, ['module', 'evidence']); diff --git a/src/context/shellEnvironment.ts b/src/context/shellEnvironment.ts index c0e07561..115a9001 100644 --- a/src/context/shellEnvironment.ts +++ b/src/context/shellEnvironment.ts @@ -39,6 +39,8 @@ export const CONTEXT_ENV_VALUES: Readonly<Record<string, number>> = { MISE_ENV: 64, MISE_DATA_DIR: 1024, ASDF_DATA_DIR: 1024, // direnv's own bookkeeping says what it loaded; NMSh never runs direnv. DIRENV_DIR: 1024, DIRENV_FILE: 1024, + // nix-shell and nix develop mark their shells; dev container hosts mark theirs. + IN_NIX_SHELL: 16, REMOTE_CONTAINERS: 8, CODESPACES: 8, }; /** Reported as present or absent only; values are never read by the snapshot code. */ diff --git a/src/input/pasteGuard.ts b/src/input/pasteGuard.ts index 7242b3c2..fe23cd4a 100644 --- a/src/input/pasteGuard.ts +++ b/src/input/pasteGuard.ts @@ -53,6 +53,8 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo if (char === '\n' || char === ';') { push(false); continue; } if (char === '&' && next === '&') { push(false); index += 1; continue; } if (char === '|' && next === '|') { push(false); index += 1; continue; } + // `>|` is a redirection (overwrite despite noclobber), not a pipe. + if (char === '|' && current.endsWith('>')) { current += char; continue; } if (char === '|') { push(true); continue; } current += char; } @@ -62,7 +64,31 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo const INTERPRETERS = /^(?:sh|bash|zsh|fish|dash|ksh|python3?|node|perl|ruby|eval|source|\.)$/u; +/** Shell text with quoted strings and escaped characters masked, so operators inside them are not read as operators. */ +export function maskQuoted(text: string): string { + return text.replace(/\\[\s\S]|"(?:\\.|[^"\\])*"|'[^']*'/gu, match => match.startsWith('\\') ? '__' : '""'); +} + +/** + * Whether shell text redirects output into a file. Every redirection is judged on its own target, so `>/dev/null` + * elsewhere in the line never excuses `> ~/.zshrc`. Descriptor duplication (`2>&1`), closing (`>&-`), the null device + * and the standard streams are not files; `>|`, `>&file`, `&>file` and `<>` (opens for writing) are. + */ +export function writesFiles(text: string): boolean { + for (const match of maskQuoted(text).matchAll(/(&>>?|\d*>>|\d*>\||\d*<>|\d*>&|\d*>)\s*([^\s;&|<>()]*)/gu)) { + const operator = match[1] ?? ''; + const target = match[2] ?? ''; + if (operator.endsWith('<>')) return true; + if (operator.endsWith('>&') && /^(?:\d+|-)$/u.test(target)) continue; + if (/^\/dev\/(?:null|stdout|stderr|fd\/[12])$/u.test(target)) continue; + return true; + } + return false; +} + /** Words of one command (quotes removed for classification only). */ +export function commandWords(text: string): string[] { return words(text); } + function words(text: string): string[] { return text.match(/"(?:\\.|[^"])*"|'[^']*'|\S+/gu)?.map(word => word.replace(/^["']|["']$/gu, '')) ?? []; } @@ -196,7 +222,7 @@ function shellKinds(text: string, pipedFrom: boolean): PasteKind[] { if (/^(?:mv|cp|mkdir|touch|ln|tee|sed|perl|install|chmod|chown|patch|unzip|tar)$/u.test(command) && !kinds.has('destructive')) { if (command !== 'sed' || /-i\b|--in-place/u.test(flags)) { kinds.delete('read'); kinds.add('modifies'); } } - if (/(?:^|[^>&])>>?\s*[^\s&|]/u.test(text.replace(/"(?:\\.|[^"])*"|'[^']*'/gu, '""')) && !/>\s*\/dev\/null/u.test(text)) kinds.add('modifies'); + if (writesFiles(text)) kinds.add('modifies'); if (!kinds.size) kinds.add('unknown'); return [...kinds]; } diff --git a/src/markdown/highlight.ts b/src/markdown/highlight.ts index a7cfd176..434a3963 100644 --- a/src/markdown/highlight.ts +++ b/src/markdown/highlight.ts @@ -230,12 +230,12 @@ function tokenizeLine(line: string, grammar: Grammar, id: string, state: LineSta } /** Tokens for each line of a code block; a line's tokens always concatenate back to exactly that line. */ -export function highlightCode(lines: readonly string[], language: string | undefined): CodeToken[][] { +export function highlightCode(lines: readonly string[], language: string | undefined, maxLines = MAX_LINES): CodeToken[][] { const id = languageId(language); if (!id) return lines.map(line => [{text: line, kind: 'plain'}]); const state: LineState = {}; return lines.map((line, number) => { - if (number >= MAX_LINES || line.length > MAX_LINE_LENGTH) return [{text: line, kind: 'plain'}]; + if (number >= maxLines || line.length > MAX_LINE_LENGTH) return [{text: line, kind: 'plain'}]; if (id === 'diff') return diffLine(line); if (id === 'markdown') return markdownLine(line, state); return tokenizeLine(line, GRAMMARS[id]!, id, state); diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 4f3bcdcb..272a91e9 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -209,6 +209,13 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string { } } +/** The style a code token gets in rendered Markdown, for other NMSh surfaces that show code the same way (the viewer). */ +export function codeTokenStyle(kind: CodeTokenKind, options: {syntax?: SyntaxSgr; level?: ColorLevel} = {}): string { + const level = options.level ?? colorLevel(); + return tokenSgr(kind, {level, safe: false, hyperlinks: false, ...(options.syntax ? {syntax: options.syntax} : {}), + fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0}); +} + function codeRows(block: Extract<Block, {kind: 'code'}>, width: number, context: Context): MarkdownRow[] { const index = context.codeIndex++; const gutter = `${context.fg(UI_COLORS.separator)}${glyph(context, '│', '|')}${RESET} `; diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index be7d481e..45916ba7 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -54,9 +54,51 @@ export type SerializedLine = SerializedCell[]; */ export const MAX_CONTROL_SEQUENCE = 8192; +/** The screen height assumed until the owner reports the PTY's rows (a terminal's classic default). */ +const DEFAULT_SCREEN_ROWS = 24; + +/** + * The live region (#388). A program may redraw its own recent rows in place, as on a terminal: cursor up/down + * (`CSI A/B/E/F`, `ESC M/D/E`), save/restore (`ESC 7/8`, `CSI s/u`) and erasing (`CSI J` 0/1, `CSI K` 0/1/2) act on + * the row the cursor is on. The cursor never moves above the fence (the line after the newest one NMSh wrote: a + * command header, a notice, an earlier command) nor more than a screen height above the newest line, because a + * terminal cannot address its scrollback either. So a multi-row progress display updates its rows instead of adding + * a copy of them per frame, and nothing outside the running program's own rows can be changed. + * + * Rows are the terminal's rows. Stored lines are logical (a long line is one line), but once the owner reports the + * PTY's width a line wider than it occupies several rows, as on the screen: moving up three rows from below a + * two-row line lands on that line's first row, not on the line above it, so a program that counts the rows it drew + * redraws exactly them (moving by lines, it erased its own earlier output at narrow widths). Carriage return, + * backspace, `CSI G/C/D` and `CSI K` act on the row the cursor is on within its line. A resize re-wraps stored lines + * at the new width, as a reflowing terminal does. + * + * Deliberately not emulated: absolute row positioning (`CSI H/f/d` rows) is not mapped, because an inline program + * cannot know where its rows are (cursor position reports are not answered); the cursor stays where it is. Scroll + * regions (`CSI r`) are not emulated either, except that text a program draws below the region it set (a reserved + * status line, such as an installer's progress row) is not stored: it would otherwise be written into the log rows. + * The stored lines are what a terminal would show at the end; intermediate frames are not kept. + */ export class AnsiOutputParser { readonly lines: StyledLine[] = []; private current: StyledLine = []; + /** The cursor's row: an index into `lines`, or `lines.length` for the current (newest) line. */ + private row = 0; + /** The lowest row the program may address (see the live region). */ + private fence = 0; + private screenRows = DEFAULT_SCREEN_ROWS; + /** The PTY's columns, once reported: rows wrap at this width. Unknown (Infinity), each line is one row. */ + private screenColumns = Number.POSITIVE_INFINITY; + /** The bottom row of a scroll region the program set above the last row, if any (1-based). */ + private regionBottom?: number; + /** The cursor was moved below that region (a status row): what is drawn there is not stored. */ + private statusRow = false; + /** + * The column at which the last character filled its row exactly: the terminal keeps the cursor on that row (a + * pending wrap) until the next character. Any move to another column ends it. + */ + private pendingWrap?: number; + /** DECSC / SCOSC saved cursor (row, column, and for ESC 7 the style). */ + private saved?: {row: number; column: number; style?: string}; private column = 0; private style = ''; private pending = ''; @@ -86,6 +128,141 @@ export class AnsiOutputParser { constructor(private readonly onClear?: () => void) {} + /** The PTY's rows: how far above the newest line a program's cursor can reach. */ + setScreenRows(rows: number): void { + this.screenRows = Math.max(1, Math.min(1000, Math.floor(rows) || DEFAULT_SCREEN_ROWS)); + } + + /** The PTY's columns: the width at which a long line occupies more than one row. */ + setScreenColumns(columns: number): void { + this.screenColumns = Number.isFinite(columns) && columns >= 1 ? Math.min(10_000, Math.floor(columns)) : Number.POSITIVE_INFINITY; + } + + /** + * Program output from here on may redraw only rows from `index` (clamped to the stored lines); NMSh-written rows + * and earlier commands stay out of reach. Used when a running command is resumed from a journal. + */ + fenceAt(index: number): void { + this.fence = Math.max(0, Math.min(index, this.lines.length)); + this.row = this.lines.length; + this.saved = undefined; + } + + /** The line the cursor is on. */ + private get target(): StyledLine { + return this.row < this.lines.length ? this.lines[this.row]! : this.current; + } + + /** The highest line the cursor may move to: the fence, or the line on the row a screen height above the newest. */ + private get topRow(): number { + if (!this.wraps) return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + let row = this.lines.length; + let used = this.rowsOf(row); + while (row > this.fence && used < this.screenRows) { row -= 1; used += this.rowsOf(row); } + return row; + } + + private get wraps(): boolean { return Number.isFinite(this.screenColumns); } + + private lineAt(row: number): StyledLine { return row < this.lines.length ? this.lines[row]! : this.current; } + + /** The rows a line occupies at the PTY's width (the cursor's line also counts the cursor). */ + private rowsOf(row: number): number { + const length = row === this.row ? Math.max(this.lineAt(row).length, this.column) : this.lineAt(row).length; + return Math.max(1, Math.ceil(length / this.screenColumns)); + } + + /** + * The row of its line the cursor is on. A row exactly filled leaves the cursor on it (the terminal's pending + * wrap) until the next character. + */ + private get subRow(): number { + if (!this.wraps) return 0; + const columns = this.screenColumns; + return this.column > 0 && this.column === this.pendingWrap ? this.column / columns - 1 : Math.floor(this.column / columns); + } + + /** Where the cursor's row starts, as a column of its line. */ + private get rowStart(): number { return this.wraps ? this.subRow * this.screenColumns : 0; } + + /** The cursor's column on its row (the last column while a wrap is pending). */ + private get screenColumn(): number { + return this.wraps ? Math.min(this.column - this.rowStart, this.screenColumns - 1) : this.column; + } + + private moveRow(delta: number): void { + this.row = Math.max(this.topRow, Math.min(this.lines.length, this.row + delta)); + } + + /** Cursor up (negative) or down by terminal rows, keeping its column on the row (or going to the row's start). */ + private moveRows(delta: number, toRowStart = false): void { + if (!this.wraps) { + this.moveRow(delta); + if (toRowStart) this.column = 0; + return; + } + const columns = this.screenColumns; + const x = toRowStart ? 0 : this.screenColumn; + let row = this.row; + let sub = this.subRow; + if (delta < 0) { + // No higher than a screen above the newest row: count the rows from the cursor down to it. + let below = this.rowsOf(row) - 1 - sub; + for (let next = row + 1; next <= this.lines.length && below < this.screenRows; next += 1) below += this.rowsOf(next); + let steps = Math.min(-delta, Math.max(0, this.screenRows - 1 - below)); + while (steps > 0) { + if (sub > 0) sub -= 1; + else if (row > this.fence) { row -= 1; sub = this.rowsOf(row) - 1; } + else break; + steps -= 1; + } + } else { + let steps = delta; + while (steps > 0) { + if (sub < this.rowsOf(row) - 1) sub += 1; + else if (row < this.lines.length) { row += 1; sub = 0; } + else break; + steps -= 1; + } + } + this.row = row; + this.column = sub * columns + x; + } + + /** Line feed: down a row (the next row of a wrapped line, or the next line), or a new line at the bottom. */ + private lineFeed(carriageReturn: boolean): void { + this.statusRow = false; + const x = this.screenColumn; + if (this.wraps && this.subRow < this.rowsOf(this.row) - 1) { + this.column = (this.subRow + 1) * this.screenColumns + (carriageReturn ? 0 : x); + return; + } + if (this.row < this.lines.length) this.row += 1; + else { + this.lines.push(this.current); + this.current = []; + this.row = this.lines.length; + } + this.column = carriageReturn ? 0 : x; + } + + /** + * Shorten a line to `length`, but never past the start of the cursor's row: that row stays (blank) on the screen, + * so it stays in the row count. + */ + private truncate(line: StyledLine, length: number): void { + line.splice(length); + const start = this.rowStart; + if (start > 0 && line.length <= start) { line.length = start + 1; line[start] = undefined; } + } + + /** NMSh wrote a line: the cursor returns below it and nothing at or above it can be redrawn. */ + private settle(): void { + this.row = this.lines.length; + this.fence = this.lines.length; + this.saved = undefined; + } + write(chunk: string): void { const input = this.pending + chunk; this.pending = ''; @@ -128,28 +305,26 @@ export class AnsiOutputParser { continue; } if (character === '\n') { - this.lines.push(this.current); - this.current = []; - this.column = 0; + this.lineFeed(true); this.ending = true; index += 1; continue; } if (character === '\r') { - this.column = 0; + this.column = this.rowStart; index += 1; continue; } if (character === '\b') { - this.column = Math.max(0, this.column - 1); + this.column = Math.max(this.rowStart, this.column - 1); index += 1; continue; } if (character === '\t') { - const spaces = 8 - (this.column % 8); + const spaces = 8 - (this.screenColumn % 8); for (let count = 0; count < spaces; count += 1) { this.put(' ', 1); - const cell = this.current[this.column - 1]!; + const cell = this.target[this.column - 1]!; cell.tab = true; if (count === 0) cell.tabSpan = spaces; } @@ -179,14 +354,19 @@ export class AnsiOutputParser { addLine(text: string, style = ''): void { this.ensureLineBoundary(); + // NMSh's own line is never a program's status row. + this.statusRow = false; this.hyperlink = undefined; this.style = style; this.write(text); + // Text with its own line breaks or row movement still ends on the newest line. + this.row = this.lines.length; this.lines.push(this.current); this.current = []; this.column = 0; this.style = ''; this.hyperlink = undefined; + this.settle(); } ensureLineBoundary(): void { @@ -195,6 +375,7 @@ export class AnsiOutputParser { this.current = []; this.column = 0; } + this.settle(); } replaceLine(index: number, text: string): void { @@ -205,6 +386,12 @@ export class AnsiOutputParser { const oldPending = this.pending; const oldHyperlink = this.hyperlink; const oldDiscarding = this.discarding; + const oldRow = this.row; + const oldFence = this.fence; + const oldSaved = this.saved; + const oldStatusRow = this.statusRow; + const oldLength = this.lines.length; + this.statusRow = false; this.current = []; this.column = 0; @@ -212,13 +399,15 @@ export class AnsiOutputParser { this.pending = ''; this.discarding = false; this.hyperlink = undefined; - + // Replacement text is one line of NMSh's own: it is written as the newest line and cannot reach other rows. + this.row = this.lines.length; + this.fence = this.lines.length; + this.saved = undefined; + this.write(text); - if (this.pending.length > 0) { - // Flush any remaining characters if write left pending state (e.g. incomplete escape) - // For simple replacement lines, this shouldn't happen unless malformed, but just in case. - } - this.lines[index] = this.current; + // A line break in replacement text would add lines; only the first one replaces the stored line. + const replaced = this.lines.length > oldLength ? this.lines.splice(oldLength)[0]! : this.current; + this.lines[index] = replaced; this.current = oldCurrent; this.column = oldColumn; @@ -226,6 +415,10 @@ export class AnsiOutputParser { this.pending = oldPending; this.hyperlink = oldHyperlink; this.discarding = oldDiscarding; + this.row = oldRow; + this.fence = oldFence; + this.saved = oldSaved; + this.statusRow = oldStatusRow; } completedCount(): number { @@ -284,12 +477,17 @@ export class AnsiOutputParser { const restored: StyledLine = line.map(cell => cell === null ? null : 'empty' in cell ? undefined : {...cell}); this.lines.push(restored); } + // A restored transcript is history; a resumed command reopens its own rows with fenceAt. + this.settle(); } trim(maxLines: number): number { if (this.lines.length <= maxLines) return 0; const removed = this.lines.length - maxLines; this.lines.splice(0, removed); + this.row = Math.max(0, this.row - removed); + this.fence = Math.max(0, this.fence - removed); + if (this.saved) this.saved = {...this.saved, row: Math.max(0, this.saved.row - removed)}; return removed; } @@ -305,7 +503,7 @@ export class AnsiOutputParser { } const params = match[1] ?? ''; const final = match[3] ?? ''; - this.applyCsi(params, final); + this.applyCsi(params, final, match[2] ?? ''); return {complete: true, next: start + match[0].length}; } if (kind === ']') { @@ -338,9 +536,23 @@ export class AnsiOutputParser { if (start + 1 + intermediates >= input.length || intermediates >= MAX_CONTROL_SEQUENCE - 1) return {complete: false, next: start}; return {complete: true, next: start + 1 + intermediates}; } + // Two-byte escapes that move within the live region; others (keypad modes, RIS…) are dropped as before. + if (kind === '7') this.saved = {row: this.row, column: this.column, style: this.style}; + else if (kind === '8') this.restoreCursor(true); + else if (kind === 'M') this.moveRows(-1); + else if (kind === 'D') { this.lineFeed(false); this.ending = true; } + else if (kind === 'E') { this.lineFeed(true); this.ending = true; } return {complete: true, next: Math.min(input.length, start + 2)}; } + private restoreCursor(withStyle: boolean): void { + this.statusRow = false; + if (!this.saved) return; + this.row = Math.max(this.topRow, Math.min(this.lines.length, this.saved.row)); + this.column = this.saved.column; + if (withStyle && this.saved.style !== undefined) this.style = this.saved.style; + } + /** * SGR in order: 0 resets where it appears, and the arguments of extended * colours (38/48/58 with 5;n or 2;r;g;b) are never read as attributes, so a @@ -368,28 +580,58 @@ export class AnsiOutputParser { if (kept.length > 0) this.style += `\u001B[${kept.join(';')}m`; } - private applyCsi(params: string, final: string): void { + private applyCsi(params: string, final: string, intermediates = ''): void { this.touch(); const values = params.replace(/^\?/u, '').split(';').map(value => Number(value || '0')); const amount = values[0] || 1; + // Row movement, save/restore and partial erases: plain forms only (a private marker or intermediate is another request). + const plain = !intermediates && !/^[<=>?]/u.test(params); if (final === 'm') { // A private marker makes it another request (`CSI > 4 ; 2 m` is xterm's modifyOtherKeys), not SGR. if (/^[<=>?]/u.test(params)) return; this.applySgr(params); } else if (final === 'K') { - if ((values[0] ?? 0) === 2) this.current = []; - else this.current.splice(this.column); + if (this.statusRow) return; + const line = this.target; + const mode = values[0] ?? 0; + // The cursor's row only: a long line's other rows are other rows on the screen. + const start = this.rowStart; + const end = this.wraps ? start + this.screenColumns : Number.POSITIVE_INFINITY; + const from = mode === 0 ? this.column : start; + const to = mode === 1 ? Math.min(this.column + 1, end) : end; + if (to >= line.length && mode !== 1) this.truncate(line, from); + else for (let position = from; position < to && position < line.length; position += 1) line[position] = undefined; + this.touch(line); + } else if (plain && (final === 'A' || final === 'B' || final === 'E' || final === 'F')) { + this.moveRows(final === 'A' || final === 'F' ? -amount : amount, final === 'E' || final === 'F'); + } else if (plain && final === 'r') { + // A scroll region ending above the last row reserves the rows below it (see statusRow); the region is not emulated. + const bottom = values[1] ?? 0; + this.regionBottom = bottom > 0 && bottom < this.screenRows ? bottom : undefined; + if (this.regionBottom === undefined) this.statusRow = false; + } else if (plain && (final === 'H' || final === 'f' || final === 'd')) { + // Absolute rows are not mapped (see the class comment); only a move below the program's scroll region matters. + this.statusRow = this.regionBottom !== undefined && (values[0] || 1) > this.regionBottom; + } else if (plain && params === '' && final === 's') { + this.saved = {row: this.row, column: this.column}; + } else if (plain && params === '' && final === 'u') { + this.restoreCursor(false); + } else if (plain && final === 'J' && (values[0] ?? 0) <= 1) { + this.eraseDisplay(values[0] ?? 0); } else if (final === 'G') { - this.column = Math.max(0, amount - 1); + this.column = this.rowStart + (this.wraps ? Math.min(amount - 1, this.screenColumns - 1) : amount - 1); } else if (final === 'C') { - this.column += amount; + this.column = this.wraps ? this.rowStart + Math.min(this.screenColumns - 1, this.screenColumn + amount) : this.column + amount; } else if (final === 'D') { - this.column = Math.max(0, this.column - amount); + this.column = this.rowStart + Math.max(0, this.screenColumn - amount); } else if (final === 'J') { if (amount === 2 || amount === 3) { this.lines.length = 0; this.current = []; this.column = 0; + this.row = 0; + this.fence = 0; + this.saved = undefined; this.onClear?.(); } } @@ -397,22 +639,48 @@ export class AnsiOutputParser { private put(text: string, width: number): void { this.ending = false; - for (let position = 0; position < width; position += 1) this.current[this.column + position] = undefined; - this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; + if (this.statusRow) return; + const line = this.target; + for (let position = 0; position < width; position += 1) line[this.column + position] = undefined; + line[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; this.touch(); - for (let position = 1; position < width; position += 1) this.current[this.column + position] = null; + for (let position = 1; position < width; position += 1) line[this.column + position] = null; this.column += width; + this.pendingWrap = this.wraps && this.column % this.screenColumns === 0 ? this.column : undefined; } - private touch(): void { revisions.set(this.current, lineRevision(this.current) + 1); } + private touch(line: StyledLine = this.target): void { revisions.set(line, lineRevision(line) + 1); } private findPreviousCell(): StyledCell | undefined { - for (let index = Math.min(this.column - 1, this.current.length - 1); index >= 0; index -= 1) { - const cell = this.current[index]; + const line = this.target; + for (let index = Math.min(this.column - 1, line.length - 1); index >= 0; index -= 1) { + const cell = line[index]; if (cell) return cell; } return undefined; } + + /** Erase in display within the live region: 0 below the cursor, 1 above it. */ + private eraseDisplay(mode: number): void { + if (this.statusRow) return; + const line = this.target; + if (mode === 0) { + this.truncate(line, this.column); + if (this.row < this.lines.length) { + // The cursor's row becomes the newest line; the program's rows below it are gone, as on the screen. + this.current = line; + this.lines.length = this.row; + } else this.touch(line); + this.touch(this.current); + return; + } + for (let row = this.topRow; row < this.row; row += 1) { + this.lines[row]!.length = 0; + this.touch(this.lines[row]!); + } + for (let position = 0; position <= this.column && position < line.length; position += 1) line[position] = undefined; + this.touch(line); + } } /** diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 1f3cff68..4ac1211d 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -13,6 +13,7 @@ import {type PromptSnapshot} from '../prompt/snapshot.js'; import {type TranscriptAppearance} from '../prompt/configuration.js'; import {TranscriptPresenter, type TranscriptView} from './TranscriptPresenter.js'; import {queueSummaryFrom, SUMMARY_LIMIT, type QueueSummary} from '../session/QueueSummary.js'; +import {recognizeOutput, type SemanticResult} from './semantic.js'; export {renderHistoricalContext} from './TranscriptPresenter.js'; @@ -155,6 +156,13 @@ export class OutputBuffer { /** How rows look; presentation state here is never serialized into transcripts. */ readonly presenter = new TranscriptPresenter(); private outputFolding: OutputFoldingMode = 'smart'; + /** Semantic Output (#386): readings of completed output, derived and never stored; details open/closed per block. */ + private semanticOutput = true; + private readonly semanticReadings = new WeakMap<CompletedCommand, SemanticResult | null>(); + private readonly semanticDetails = new Map<number, boolean>(); + /** /track: the stored line each live result is drawn under (line id → tracker id), and who draws it. */ + private readonly trackAnchors = new Map<number, string>(); + private liveRowsProvider?: (id: string, width: number) => readonly string[]; private readonly parser: AnsiOutputParser; private readonly completed: CompletedCommand[] = []; private readonly visualGaps = new Set<number>(); @@ -196,6 +204,8 @@ export class OutputBuffer { private retireClearedBlocks(): void { this.completed.length = 0; this.userToggled.clear(); + this.semanticDetails.clear(); + this.trackAnchors.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -271,6 +281,8 @@ export class OutputBuffer { this.firstRun = undefined; this.parser.restore([]); this.completed.length = 0; + this.semanticDetails.clear(); + this.trackAnchors.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -352,9 +364,23 @@ export class OutputBuffer { const placed = startId >= after && outputStartId <= this.parser.completedCount() && (header || (startId === 0 && outputStartId === 0)); if (!placed) this.retireClearedBlocks(); else if (!header) this.active.cleared = true; + // The resumed command may go on redrawing its own rows (not NMSh's lines written into its block meanwhile). + let ownRows = placed ? outputStartId : 0; + for (let line = ownRows; line < this.parser.completedCount(); line += 1) if (this.lineTypes.has(line)) ownRows = line + 1; + this.parser.fenceAt(ownRows); return {cleared: Boolean(this.active.cleared)}; } + /** The PTY's rows, which bound how far a program's output can redraw rows above its newest line. */ + setScreenRows(rows: number): void { + this.parser.setScreenRows(rows); + } + + /** The PTY's columns: a program's long lines occupy rows at this width when it moves its cursor over them. */ + setScreenColumns(columns: number): void { + this.parser.setScreenColumns(columns); + } + updateCommandHighlight(startId: number, formattedLines: string[]): void { for (let i = 0; i < formattedLines.length; i++) { this.parser.replaceLine(startId + i, formattedLines[i] ?? ''); @@ -608,9 +634,65 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + // Only a transcript with /track blocks pays for the per-line lookup. + ...(this.trackAnchors.size ? {liveRows: (line: number, width: number) => { + const id = this.trackAnchors.get(line); + return id === undefined ? undefined : this.liveRowsProvider?.(id, width); + }} : {}), + semantic: record => { + const result = this.semanticFor(record); + return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; + }, }; } + setSemanticOutput(on: boolean): void { this.semanticOutput = on; } + + /** Who draws each /track block's live rows (presentation only; nothing it returns is stored). */ + setLiveRows(provider: (id: string, width: number) => readonly string[]): void { this.liveRowsProvider = provider; } + + /** + * A /track block: NMSh's lines for the command, stored once like any frontend block; the tracker's live result is + * drawn under its last line and replaced in place on every refresh. Returns that line's id. + */ + addTrackBlock(id: string, command: string, rows: readonly string[]): number { + this.addFrontendBlock(command, rows); + const anchor = this.parser.completedCount() - 1; + this.trackAnchors.set(anchor, id); + return anchor; + } + + + /** + * The block's Semantic Output reading, computed once per record from its stored output (never stored, so a resumed + * transcript reads it again lazily). Only shell commands with output are read; Off reads nothing. + */ + semanticFor(record: CompletedCommand): SemanticResult | undefined { + if (!this.semanticOutput || record.frontend || record.activities?.length || !record.output) return undefined; + let reading = this.semanticReadings.get(record); + if (reading === undefined) { + reading = recognizeOutput(record.command, record.output, record.exitCode) ?? null; + this.semanticReadings.set(record, reading); + } + // The usefulness gate: a short, passing output already reads well; a listing of failures is always worth it. + if (!reading || (reading.outcome === 'passed' && !reading.items.length && record.output.split('\n').length < 10)) return undefined; + return reading; + } + + /** Details start open when the raw output is folded (they are what it hides), closed when it is expanded. */ + semanticDetailsOpen(record: CompletedCommand, result = this.semanticFor(record)): boolean { + if (!result?.items.length) return false; + return this.semanticDetails.get(record.startId) ?? !record.expanded; + } + + toggleSemanticDetails(startId: number): boolean { + const record = this.completed.find(item => item.startId === startId); + const result = record && this.semanticFor(record); + if (!record || !result?.items.length) return false; + this.semanticDetails.set(startId, !this.semanticDetailsOpen(record, result)); + return true; + } + /** Important hidden lines of a collapsed block, by line id; computed on first draw and kept per block identity. */ private readonly highlightCache = new Map<string, readonly number[]>(); private foldHighlightsFor(record: CompletedCommand): readonly number[] { diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index da5f02f1..c120f96b 100644 --- a/src/output/OutputSelection.ts +++ b/src/output/OutputSelection.ts @@ -42,7 +42,7 @@ export interface OutputAnchor { offset: number; } -const rowKind = (row: WrappedRow) => row.isFoldHint ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; +const rowKind = (row: WrappedRow) => row.isFoldHint || row.derived ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; const stableRow = (row: WrappedRow | undefined) => row !== undefined && ((row.lineIndex ?? -1) >= 0 || row.blockStartId !== undefined || row.activityId !== undefined); const matchesAnchor = (row: WrappedRow, anchor: OutputAnchor) => row.lineIndex === anchor.lineIndex && row.blockStartId === anchor.blockStartId && row.activityId === anchor.activityId && rowKind(row) === anchor.kind; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index b4d4b6f2..e607b181 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -21,6 +21,8 @@ import {archiveColor, grayscaleArchiveColor} from '../prompt/snapshot.js'; import {isPromptRole, promptRoleColors} from '../prompt/prompt.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE, normalizeConnectorFade, type GitColorMode, type TranscriptAppearance} from '../prompt/configuration.js'; import type {CompletedCommand, HistoricalContextSnapshot, SecondaryActivity} from './OutputBuffer.js'; +import {semanticRows} from './semanticRows.js'; +import type {SemanticResult} from './semantic.js'; /** * History rules are UI chrome, resolved at use so themes apply. The shipped @@ -77,6 +79,10 @@ export interface TranscriptView { 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[]; + /** A /track block's live rows under this stored line, replaced on every refresh; undefined for other lines. */ + liveRows?: (lineIndex: number, width: number) => readonly string[] | undefined; + /** The block's Semantic Output reading and whether its details are open (#386); undefined: raw only. */ + semantic?: (record: CompletedCommand) => {result: SemanticResult; open: boolean} | undefined; } /** Transient interaction state the app tracks; the presenter decides how it looks. */ @@ -138,7 +144,7 @@ export class TranscriptPresenter { } rows(view: TranscriptView, width: number): WrappedRow[] { - const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf} = view; + const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf, liveRows} = view; const column = this.layout === 'chat' ? chatColumn(width) : undefined; // A chat command block is right-aligned as a whole: its widest wrapped row sets the left edge. const blockWidths = new Map<number, number>(); @@ -181,6 +187,9 @@ export class TranscriptPresenter { } const cmd = completed.find(c => c.outputStartId === i); + // Semantic Output: the reading's rows come first in the block's output area; the output below is unchanged. + const reading = cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId ? view.semantic?.(cmd) : undefined; + if (cmd && reading) result.push(...semanticRows(reading.result, reading.open, width, {lineIndex: cmd.outputStartId, commandIndex: completed.indexOf(cmd), startId: cmd.startId})); if (cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId) { const hiddenLines = cmd.endId - cmd.outputStartId; // Activity-bearing parents use their lifecycle row as the disclosure control below. @@ -278,6 +287,8 @@ export class TranscriptPresenter { else if (cmdIndex !== -1) row.commandIndex = cmdIndex; result.push(row); } + // A /track block's live result, under its stored line (derived: never stored, searched, selected or copied). + if (liveRows) for (const ansi of liveRows(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); } if (active) { for (const activity of active.activities) { diff --git a/src/output/TranscriptSearch.ts b/src/output/TranscriptSearch.ts index 58356794..b249a9a1 100644 --- a/src/output/TranscriptSearch.ts +++ b/src/output/TranscriptSearch.ts @@ -78,7 +78,7 @@ export function logicalLines(rows: readonly WrappedRow[], include: (row: Wrapped let current: LogicalLine | undefined; let currentIndex: number | undefined; rows.forEach((row, index) => { - if (row.isFilterHint || !include(row)) { current = undefined; currentIndex = undefined; return; } + if (row.isFilterHint || row.derived || !include(row)) { current = undefined; currentIndex = undefined; return; } if (current && row.lineIndex !== undefined && row.lineIndex === currentIndex) { current.offsets.push(current.text.length); current.rows.push(index); diff --git a/src/output/TranscriptSelection.ts b/src/output/TranscriptSelection.ts index c6fc39fe..2d20f914 100644 --- a/src/output/TranscriptSelection.ts +++ b/src/output/TranscriptSelection.ts @@ -52,7 +52,7 @@ export function selectedText(rows: readonly WrappedRow[], selection: TranscriptS let previousLine: number | undefined; for (let index = start; index <= end && index < rows.length; index += 1) { const row = rows[index]!; - if (row.isFilterHint) continue; + if (row.isFilterHint || row.derived) continue; const text = row.plain.slice(row.indent ?? 0); // Continuation rows of the same logical line are joined exactly; padding is trimmed once the line ends. if (row.lineIndex !== undefined && row.lineIndex === previousLine && lines.length) lines[lines.length - 1] += text; diff --git a/src/output/semantic.ts b/src/output/semantic.ts new file mode 100644 index 00000000..6d707f91 --- /dev/null +++ b/src/output/semantic.ts @@ -0,0 +1,293 @@ +/** + * Semantic Output (#386, first phase): a deterministic reading of a completed command's stored output, never a + * replacement for it. Each recognizer is a pure function of (command, output, exit code) that accepts only its own + * unmistakable evidence (a runner's own summary line, a compiler's own diagnostic format) and fails closed otherwise. + * Counts are the ones the tool printed, items are lines taken verbatim from the output, and nothing is inferred + * about state the output does not show. The raw output stays authoritative: this is presentation and an optional copy. + */ + +export interface SemanticItem { + /** The line as the tool printed it (trimmed), possibly joined with its location line. */ + text: string; + tone: 'failure' | 'warning'; +} + +export interface SemanticResult { + kind: 'tests' | 'diagnostics'; + /** Who produced the facts ("node --test", "pytest", "TypeScript"). */ + source: string; + outcome: 'passed' | 'failed'; + /** One line of facts, as printed ("1,797 passed · 2 failed · 25 skipped"). */ + overview: string; + /** Failing tests or diagnostics, at most MAX_ITEMS. */ + items: SemanticItem[]; + /** Items found beyond MAX_ITEMS. */ + omitted: number; + /** False when only part of a very large output was read. */ + complete: boolean; +} + +export const MAX_ITEMS = 20; +/** Output read for recognition: the end of the output, where runners and compilers summarize. */ +export const MAX_SEMANTIC_BYTES = 2 * 1024 * 1024; + +/** The last line that matches (runners print their summary at the end). */ +function findLast(lines: readonly string[], test: (line: string) => boolean): string | undefined { + for (let index = lines.length - 1; index >= 0; index -= 1) if (test(lines[index]!)) return lines[index]; + return undefined; +} + +const number = (value: string | undefined) => value === undefined ? undefined : Number(value.replace(/,/gu, '')); +const count = (value: number) => value.toLocaleString('en-US'); +const plural = (value: number, word: string) => `${count(value)} ${word}${value === 1 ? '' : 's'}`; + +function facts(parts: ReadonlyArray<readonly [label: string, value: number | undefined]>): string { + return parts.filter(([, value]) => value !== undefined).map(([label, value]) => `${count(value!)} ${label}`).join(' · '); +} + +/** + * Lines longer than this are not read: runner summaries and diagnostics are short, and every pattern below then runs + * in time linear in what it reads, whatever a hostile output contains. + */ +export const MAX_LINE = 2048; + +/** Items from lines `parse` accepts (it returns the item text), deduplicated and bounded. */ +function collect(lines: readonly string[], parse: (line: string) => string | undefined, tone: SemanticItem['tone'] = 'failure'): {items: SemanticItem[]; omitted: number} { + const seen = new Set<string>(); + const items: SemanticItem[] = []; + let omitted = 0; + for (const line of lines) { + const text = parse(line)?.trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + if (items.length < MAX_ITEMS) items.push({text, tone}); else omitted += 1; + } + return {items, omitted}; +} + +/** `N word` counts in one summary line (only ever applied to the one line a recognizer picked). */ +const countIn = (line: string, word: string) => number(new RegExp(`(?:^|[^\\d,])(\\d{1,3}(?:,\\d{3})*|\\d{1,12}) ${word}\\b`, 'u').exec(line)?.[1]); + +/** `path:line:col` with a path that has no spaces; end-anchored with bounded digits, so linear. */ +const LOCATION_TAIL = /:\d{1,7}:\d{1,7}$/u; +const isLocation = (text: string) => text.length > 0 && !/\s/u.test(text) && LOCATION_TAIL.test(text); + +type Recognizer = (command: string, lines: readonly string[], exitCode: number) => Omit<SemanticResult, 'complete'> | undefined; + +/** Node's test runner: the spec reporter's `ℹ` summary, or TAP's `#` summary. */ +const nodeTest: Recognizer = (_command, lines) => { + for (const marker of ['ℹ', '#'] as const) { + const value = (name: string) => { + const prefix = `${marker} ${name} `; + const rest = findLast(lines, line => line.startsWith(prefix))?.slice(prefix.length).trim(); + return rest !== undefined && /^\d{1,12}$/u.test(rest) ? Number(rest) : undefined; + }; + const tests = value('tests'); + const pass = value('pass'); + const fail = value('fail'); + if (tests === undefined || pass === undefined || fail === undefined) continue; + const skipped = value('skipped'); + const todo = value('todo'); + const cancelled = value('cancelled'); + const section = lines.indexOf('✖ failing tests:'); + const failing = marker === 'ℹ' + ? collect(section >= 0 ? lines.slice(section + 1) : lines, line => line.startsWith('✖ ') && line !== '✖ failing tests:' ? line.slice(2).replace(/ \([\d.]{1,20}m?s\)$/u, '') : undefined) + : collect(lines, line => { + const head = /^not ok \d{1,9} - /u.exec(line); + if (!head) return undefined; + const text = line.slice(head[0].length); + const hash = text.indexOf(' #'); + // A TODO or SKIP directive is not a failure. + if (hash >= 0 && /^\s*(?:todo|skip)\b/iu.test(text.slice(hash + 2))) return undefined; + return hash >= 0 ? text.slice(0, hash) : text; + }); + return {kind: 'tests', source: 'node --test', outcome: fail > 0 || (cancelled ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', pass], ['failed', fail], ['skipped', skipped || undefined], ['todo', todo || undefined], ['cancelled', cancelled || undefined]]), ...failing}; + } + return undefined; +}; + +/** Jest's `Tests: … N total` line (and its `●` failure headings). */ +const jest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith('Tests:') && line.endsWith(' total') && /\d$/u.test(line.slice(0, -6))); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('● ') && !text.startsWith('● Console') ? text.slice(2) : undefined; + }); + return {kind: 'tests', source: 'Jest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** Vitest's `Tests 2 failed | 40 passed (42)` line and its FAIL lines. */ +const vitest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith(' ') && line.trimStart().startsWith('Tests ') && /\(\d{1,9}\)$/u.test(line)); + if (!summary || !lines.some(line => line.trimStart().startsWith('Test Files '))) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('FAIL ') ? text.slice(5).trimStart() : undefined; + }); + return {kind: 'tests', source: 'Vitest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** pytest's closing `=== 1 failed, 3 passed in 0.12s ===` line and its short summary. */ +const pytest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => { + if (!line.startsWith('=') || !line.endsWith('=')) return false; + const inner = line.replace(/^=+ /u, '').replace(/ =+$/u, ''); + const at = inner.lastIndexOf(' in '); + if (at < 0 || !/^[\d.]{1,12}s(?: \([\d:]{1,12}\))?$/u.test(inner.slice(at + 4))) return false; + return inner.slice(0, at).split(', ').every(part => /^\d{1,9} \w{1,20}$/u.test(part)); + }); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const errors = countIn(summary, 'errors?'); + const failing = collect(lines, line => { + const head = line.startsWith('FAILED ') ? 7 : line.startsWith('ERROR ') ? 6 : 0; + return head ? line.slice(head) : undefined; + }); + return {kind: 'tests', source: 'pytest', outcome: (failed ?? 0) + (errors ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['errors', errors], ['skipped', countIn(summary, 'skipped')], ['xfailed', countIn(summary, 'xfailed')]]), ...failing}; +}; + +/** cargo test's `test result:` lines (one per test binary; summed). */ +const cargoTest: Recognizer = (_command, lines) => { + const results = lines.map(line => /^test result: (?:ok|FAILED)\. (\d{1,9}) passed; (\d{1,9}) failed; (\d{1,9}) ignored;/u.exec(line)).filter(match => match !== null); + if (!results.length) return undefined; + const sum = (index: number) => results.reduce((total, match) => total + Number(match[index]), 0); + const failing = collect(lines, line => line.startsWith('test ') && line.endsWith(' ... FAILED') ? line.slice(5, -11) : undefined); + return {kind: 'tests', source: 'cargo test', outcome: sum(2) > 0 ? 'failed' : 'passed', + overview: facts([['passed', sum(1)], ['failed', sum(2)], ['ignored', sum(3) || undefined]]), ...failing}; +}; + +/** go test's per-package `ok` / `FAIL` lines and `--- FAIL:` tests. */ +const goTest: Recognizer = (command, lines) => { + if (!/^\s*(?:\S+\/)?go\s+test\b/u.test(command)) return undefined; + const ok = lines.filter(line => /^ok\s+\S+\s+(?:[\d.]+s|\(cached\))/u.test(line)).length; + const failedPackages = lines.filter(line => /^FAIL\s+\S+\s+(?:[\d.]+s|\[build failed\]|\[setup failed\])/u.test(line)).length; + if (!ok && !failedPackages) return undefined; + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('--- FAIL: ') ? text.slice(10).split(' ')[0] : undefined; + }); + return {kind: 'tests', source: 'go test', outcome: failedPackages > 0 ? 'failed' : 'passed', + overview: `${plural(ok, 'package')} ok · ${plural(failedPackages, 'package')} failed${failing.items.length ? ` · ${plural(failing.items.length + failing.omitted, 'failing test')}` : ''}`, ...failing}; +}; + +/** Commands that compile or check code: their diagnostics are the point even when they exit 0. */ +const BUILD = /^\s*(?:\S+\/)?(?:tsc|npx\s+tsc|vue-tsc|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|make|ninja|cmake|cargo\s+(?:build|check|clippy|run|test)|rustc|swift\s+build|xcodebuild|go\s+(?:build|vet)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck|check|lint|compile|tsc))\b/u; + +/** One TypeScript diagnostic, pretty (`file:1:2 - error TS…: …`) or plain (`file(1,2): error TS…: …`). */ +function typescriptDiagnostic(line: string): string | undefined { + for (const severity of ['error', 'warning'] as const) { + const pretty = line.indexOf(` - ${severity} TS`); + if (pretty > 0 && isLocation(line.slice(0, pretty))) { + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(pretty + severity.length + 4)); + if (rest) return `${line.slice(0, pretty)} ${rest[1]}: ${rest[2]}`; + } + const plain = line.indexOf(`): ${severity} TS`); + if (plain > 0) { + const head = /^(\S+)\((\d{1,7}),(\d{1,7})$/u.exec(line.slice(0, plain)); + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(plain + severity.length + 4)); + if (head && rest) return `${head[1]}:${head[2]}:${head[3]} ${rest[1]}: ${rest[2]}`; + } + } + return undefined; +} + +/** TypeScript diagnostics, and tsc's `Found N errors` line. */ +const typescript: Recognizer = (command, lines, exitCode) => { + const found = findLast(lines, line => /^Found \d{1,9} errors?(?: in .+)?\.?$/u.test(line)); + if (!found && !lines.some(line => typescriptDiagnostic(line) !== undefined)) return undefined; + if (!found && exitCode === 0 && !BUILD.test(command)) return undefined; + const items = collect(lines, typescriptDiagnostic); + const errors = number(found ? /^Found (\d{1,9}) errors?/u.exec(found)?.[1] : undefined) ?? items.items.length + items.omitted; + const files = found ? /in (\d{1,9}) files/u.exec(found)?.[1] : undefined; + return {kind: 'diagnostics', source: 'TypeScript', outcome: errors > 0 ? 'failed' : 'passed', + overview: `${plural(errors, 'error')}${files ? ` in ${files} files` : ''}`, ...items}; +}; + +/** The location of a Rust ` --> file:line:col` line, or undefined. */ +function rustLocation(line: string | undefined): string | undefined { + const text = line?.trim(); + return text?.startsWith('--> ') && isLocation(text.slice(4)) ? text.slice(4) : undefined; +} + +/** Rust: `error[E0308]: …` / `warning: …` headings with their ` --> file:line:col` location. */ +const rust: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => rustLocation(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + const items: SemanticItem[] = []; + let omitted = 0; + let errors = 0; + let warnings = 0; + for (let index = 0; index < lines.length; index += 1) { + const heading = /^(error|warning)(\[E\d{4}\])?: (.+)$/u.exec(lines[index]!); + if (!heading || /^(?:could not compile|aborting due to|`.+` \(.+\) generated \d+ warnings?)/u.test(heading[3]!)) continue; + const location = rustLocation(lines[index + 1]); + if (!location) continue; + if (heading[1] === 'error') errors += 1; else warnings += 1; + const text = `${location} ${heading[1]}${heading[2] ?? ''}: ${heading[3]}`; + if (items.length < MAX_ITEMS) items.push({text, tone: heading[1] === 'error' ? 'failure' : 'warning'}); else omitted += 1; + } + if (!errors && !warnings) return undefined; + return {kind: 'diagnostics', source: 'Rust', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), items, omitted}; +} + +/** One GCC/Clang diagnostic `file:line:col: error: …` as [location, severity, message]. */ +function cDiagnostic(line: string): [string, string, string] | undefined { + for (const severity of ['fatal error', 'error', 'warning'] as const) { + const at = line.indexOf(`: ${severity}: `); + if (at > 0 && isLocation(line.slice(0, at))) return [line.slice(0, at), severity, line.slice(at + severity.length + 4)]; + } + return undefined; +} + +/** GCC and Clang diagnostics. */ +const cCompilers: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => cDiagnostic(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + let errors = 0; + let warnings = 0; + for (const line of lines) { + const diagnostic = cDiagnostic(line); + if (diagnostic) { if (diagnostic[1] === 'warning') warnings += 1; else errors += 1; } + } + const failures = collect(lines, line => { + const diagnostic = cDiagnostic(line); + return diagnostic && `${diagnostic[0]} ${diagnostic[1]}: ${diagnostic[2]}`; + }); + for (const item of failures.items) if (/ warning: /u.test(item.text)) item.tone = 'warning'; + return {kind: 'diagnostics', source: 'GCC/Clang', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), ...failures}; +}; + +/** Most specific first: a runner's summary wins over diagnostics that happen to appear in its output. */ +const RECOGNIZERS: readonly Recognizer[] = [nodeTest, jest, vitest, pytest, cargoTest, goTest, typescript, rust, cCompilers]; + +/** The semantic reading of a completed output, or undefined when no recognizer is sure. */ +export function recognizeOutput(command: string, output: string, exitCode: number): SemanticResult | undefined { + if (!output) return undefined; + const complete = output.length <= MAX_SEMANTIC_BYTES; + const text = complete ? output : output.slice(-MAX_SEMANTIC_BYTES); + // trimEnd is linear (a `\s+$` pattern is not), and over-long lines are not read at all. + const lines = text.split('\n').map(line => line.length > MAX_LINE ? '' : line.trimEnd()); + const shown = command.length > 1024 ? command.slice(0, 1024) : command; + for (const recognizer of RECOGNIZERS) { + const result = recognizer(shown, lines, exitCode); + if (result) return {...result, complete}; + } + return undefined; +} + +/** Plain text of a reading, for `/copy --semantic` and `--both`: everything, whatever is folded on screen. */ +export function semanticText(result: SemanticResult): string { + const lines = [`${result.source}: ${result.overview}`, ...result.items.map(item => item.text)]; + if (result.omitted) lines.push(`… ${count(result.omitted)} more in the raw output`); + if (!result.complete) lines.push('(read from the last 2 MiB of a longer output)'); + return lines.join('\n'); +} diff --git a/src/output/semanticRows.ts b/src/output/semanticRows.ts new file mode 100644 index 00000000..4f17a87a --- /dev/null +++ b/src/output/semanticRows.ts @@ -0,0 +1,47 @@ +import {colorLevel} from '../presentation/capabilities.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateText} from '../util/text.js'; +import type {SemanticResult} from './semantic.js'; +import type {WrappedRow} from './viewport.js'; + +/** + * How a Semantic Output reading is drawn inside its block (#386): one overview row, then, when the reading lists + * failures or diagnostics, a disclosure row and (open) the items. The rows are derived presentation: never stored + * transcript lines, never in raw search, selection or /copy (which copy the output as it was). + */ + +const RESET = '\u001b[0m'; +const inert = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, '·'); + +function itemLabel(result: SemanticResult): string { + const total = result.items.length + result.omitted; + if (result.kind === 'tests') return `${total.toLocaleString('en-US')} failing test${total === 1 ? '' : 's'}`; + return `${total.toLocaleString('en-US')} diagnostic${total === 1 ? '' : 's'}`; +} + +export function semanticRows(result: SemanticResult, open: boolean, width: number, owner: {lineIndex: number; commandIndex: number; startId: number}): WrappedRow[] { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const passed = result.outcome === 'passed'; + const icon = passed ? GLYPHS.success : GLYPHS.failure; + const base = {lineIndex: owner.lineIndex, commandIndex: owner.commandIndex, derived: true as const}; + const overviewPlain = truncateText(` ${icon} ${result.source} · ${result.overview}${result.complete ? '' : ' · from the end of the output'}`, width); + const rows: WrappedRow[] = [{...base, plain: overviewPlain, + ansi: `${paint(passed ? 'success' : 'failure')}${overviewPlain.slice(0, 2 + icon.length + 1)}${RESET}${paint('secondary')}${overviewPlain.slice(2 + icon.length + 1)}${RESET}`}]; + if (!result.items.length) return rows; + const marker = open ? (getCurrentGlyphMode() === 'nerd' ? '⌄' : 'v') : GLYPHS.selection; + const disclosure = truncateText(` ${marker} ${itemLabel(result)}${open ? '' : ' · click or Actions to show'}`, width); + rows.push({...base, plain: disclosure, ansi: `${paint('subtle')}${disclosure}${RESET}`, semanticToggle: owner.startId}); + if (!open) return rows; + for (const item of result.items) { + const mark = item.tone === 'failure' ? GLYPHS.failure : '!'; + const plain = truncateText(` ${mark} ${inert(item.text)}`, width); + rows.push({...base, plain, ansi: `${paint(item.tone === 'failure' ? 'failure' : 'warning')}${plain.slice(0, 4 + mark.length)}${RESET}${plain.slice(4 + mark.length)}`}); + } + if (result.omitted) { + const plain = truncateText(` … ${result.omitted.toLocaleString('en-US')} more in the output below`, width); + rows.push({...base, plain, ansi: `${paint('subtle')}${plain}${RESET}`}); + } + return rows; +} diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 1dabbbab..89e4e46c 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,6 +20,11 @@ export interface WrappedRow { isFilterHint?: boolean; /** A row of the first-run welcome (0 is Vespyr's block above it), so the ending can reveal it line by line. */ firstRunLine?: number; + /** Derived presentation (a Semantic Output reading, a /track live result): never transcript content, so search, + * selection and copy skip it. */ + derived?: boolean; + /** Clicking this row opens or closes the Semantic Output details of the block with this startId. */ + semanticToggle?: number; } export interface StickyHeader { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7c174942..7fef4d49 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -144,10 +144,14 @@ export type NativePaletteId = 'lavender' | 'brand' | 'cool' | 'warm' | 'grayscal /** Bundled third-party variants (see appearance/themeFamilies). */ export type ThirdPartyPaletteId = 'catppuccinLatte' | 'catppuccinFrappe' | 'catppuccinMacchiato' | 'catppuccinMocha' | 'dracula' | 'tokyonightNight' | 'tokyonightStorm' | 'tokyonightMoon' | 'tokyonightDay' | 'gruvboxDark' | 'gruvboxLight' - | 'rosePine' | 'rosePineMoon' | 'rosePineDawn' | 'nord' | 'solarizedDark' | 'solarizedLight' | 'oneDark' | 'oneLight'; + | 'rosePine' | 'rosePineMoon' | 'rosePineDawn' | 'nord' | 'solarizedDark' | 'solarizedLight' | 'oneDark' | 'oneLight' + | 'ayuDark' | 'ayuMirage' | 'ayuLight' | 'kanagawaWave' | 'kanagawaDragon' | 'kanagawaLotus' | 'everforestDark' | 'everforestLight' + | 'flexokiDark' | 'flexokiLight' | 'nightOwl' | 'nightOwlLight'; export const THIRD_PARTY_PALETTE_IDS: readonly ThirdPartyPaletteId[] = ['catppuccinLatte', 'catppuccinFrappe', 'catppuccinMacchiato', 'catppuccinMocha', 'dracula', 'tokyonightNight', 'tokyonightStorm', 'tokyonightMoon', 'tokyonightDay', 'gruvboxDark', 'gruvboxLight', - 'rosePine', 'rosePineMoon', 'rosePineDawn', 'nord', 'solarizedDark', 'solarizedLight', 'oneDark', 'oneLight']; + 'rosePine', 'rosePineMoon', 'rosePineDawn', 'nord', 'solarizedDark', 'solarizedLight', 'oneDark', 'oneLight', + 'ayuDark', 'ayuMirage', 'ayuLight', 'kanagawaWave', 'kanagawaDragon', 'kanagawaLotus', 'everforestDark', 'everforestLight', + 'flexokiDark', 'flexokiLight', 'nightOwl', 'nightOwlLight']; export type NativeGapChoice = 'off' | 'compact' | 'normal' | 'wide'; /** * Rich Git state colors: `semantic` keeps meaningful Git colors under any @@ -619,6 +623,8 @@ export interface PromptConfiguration { liveSessionMultiple: LiveSessionMultiple; /** Whether long, boring finished output starts collapsed. Presentation only. */ outputFolding: OutputFoldingMode; + /** Semantic Output: a test or build summary drawn in recognized blocks (#386). Presentation only; Off draws raw only. */ + semanticOutput: boolean; /** What new presentation sessions show at the top; archived sessions keep theirs. */ welcome: WelcomeProviderId; /** Ghost-text suggestion provider; external providers fall back to Native. */ @@ -865,6 +871,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { liveSessionMultiple: 'ask', notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', + semanticOutput: true, welcome: 'vespyr', suggestions: 'nmsh', history: 'native', @@ -986,6 +993,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.liveSessionMultiple as LiveSessionMultiple : 'ask'; // Off persists as `never`, so v0.4 configs load unchanged. const outputFolding: OutputFoldingMode = value.outputFolding === 'never' || value.outputFolding === 'always' ? value.outputFolding : 'smart'; + const semanticOutput = value.semanticOutput !== false; const welcome: WelcomeProviderId = WELCOME_PROVIDER_IDS.includes(value.welcome as WelcomeProviderId) ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) @@ -1069,7 +1077,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, ohMyPosh, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } @@ -1129,7 +1137,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, + glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } diff --git a/src/prompt/fitReport.ts b/src/prompt/fitReport.ts new file mode 100644 index 00000000..6304236b --- /dev/null +++ b/src/prompt/fitReport.ts @@ -0,0 +1,13 @@ +/** + * What a surface's width fitting did to routed modules in one live render (#365): which it left out and which it + * shortened. `/modules explain` reads the last frame's report; nothing is measured or collected to produce it. + */ +export interface FitReport {dropped: Set<string>; compacted: Set<string>} + +export const newFitReport = (): FitReport => ({dropped: new Set(), compacted: new Set()}); + +/** How the last frame fitted one module on its surface, if it had to. */ +export function fitOutcome(report: FitReport | undefined, id: string): 'dropped' | 'compacted' | undefined { + if (!report) return undefined; + return report.dropped.has(id) ? 'dropped' : report.compacted.has(id) ? 'compacted' : undefined; +} diff --git a/src/prompt/moduleReport.ts b/src/prompt/moduleReport.ts new file mode 100644 index 00000000..35663f30 --- /dev/null +++ b/src/prompt/moduleReport.ts @@ -0,0 +1,58 @@ +import {moduleDefinition} from '../context/modules.js'; +import {CORE_CAPABILITIES} from '../context/registry.js'; +import {routeModule, surfaceAvailability} from '../context/surfaceRouter.js'; +import {MODULE_SURFACE_LABELS, type ContextModuleConfig, type ModuleSurface, type PromptConfiguration} from './configuration.js'; +import {whyText, type ModulesContext} from './ModulesPanel.js'; + +/** + * Plain-text module diagnostics for the transcript (#365), in the spirit of `starship explain` and `starship timings`: + * `/modules explain` says, per configured module, where it is routed, whether it is on screen and why, and the state of + * the capabilities it reads; `/modules timings` lists the slowest last collections. Both read the engine's snapshots + * and the settings only: nothing is collected, run or read from disk to produce them, and no module value is shown + * (only states, ages, durations and the capabilities' own evidence summaries). + */ + +type Status = NonNullable<ReturnType<NonNullable<ModulesContext['status']>>>; + +const inert = (text: string, limit: number) => text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, ' ').slice(0, limit); +const age = (status: Status, now: number) => status.collectedAt === undefined ? '' : ` ${Math.max(0, Math.round((now - status.collectedAt) / 1000))}s ago`; +const took = (status: Status) => status.durationMs === undefined ? '' : `, took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; +const STATE: Record<string, string> = {fresh: 'fresh', stale: 'stale', pending: 'collecting', absent: 'nothing to show', timeout: 'timed out', failed: 'failed', idle: 'not collected'}; + +export function explainModules(modules: readonly ContextModuleConfig[], context: ModulesContext, configuration: PromptConfiguration, now = Date.now(), + fit?: (id: string, surface: string) => 'dropped' | 'compacted' | undefined): string[] { + const rows: string[] = []; + for (const module of modules) { + const definition = moduleDefinition(module.id); + if (!definition) { rows.push(`${inert(module.id, 64)} · its Context Pack is not installed or enabled; nothing is collected`); continue; } + const surface = routeModule(module); + const availability = surface === 'hidden' ? undefined : surfaceAvailability(surface, configuration); + const sourced = [...definition.facts.keys()].map(id => ({id, status: context.status?.(id)})).filter((entry): entry is {id: string; status: Status} => Boolean(entry.status)); + const statuses = sourced.map(entry => entry.status); + const why = !module.visible ? 'turned off in Modules' + : availability && !availability.available ? availability.reason + : context.conditionMet?.(module) === false ? 'hidden: its condition does not match here' + : whyText(module, surface, statuses, definition.triggers); + const where = surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]; + // Width: what the last frame's fitting did on its surface (higher priorities are kept, then shortened, longest). + const fitted = module.visible && surface !== 'hidden' && (!availability || availability.available) ? fit?.(module.id, surface) : undefined; + const width = fitted === 'dropped' ? ' · left out for width in the last frame (higher-priority modules kept)' + : fitted === 'compacted' ? ' · shortened to fit the width in the last frame' : ''; + rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}${width}`); + const sources = sourced.map(({id, status}) => `${id} ${STATE[status.state] ?? status.state}${age(status, now)}${took(status)}${status.evidence ? ` (${inert(status.evidence, 80)})` : ''}`); + if (sources.length) rows.push(` reads ${sources.join('; ')}`); + } + if (!rows.length) rows.push('No modules are configured.'); + rows.push('States come from this window\'s Context Engine; producing this report collected nothing.'); + return rows; +} + +export function moduleTimings(status: (id: string) => Status | undefined, now = Date.now(), limit = 15): string[] { + const measured = CORE_CAPABILITIES.map(capability => ({id: capability.id, ...status(capability.id)})).filter((entry): entry is Status & {id: string} => entry.durationMs !== undefined) + .sort((a, b) => b.durationMs! - a.durationMs!); + if (!measured.length) return ['No module data has been collected in this window yet.']; + const rows = measured.slice(0, limit).map(entry => `${String(entry.durationMs! < 1 ? '<1' : Math.round(entry.durationMs!)).padStart(6)} ms ${entry.id} · ${STATE[entry.state] ?? entry.state}${age(entry, now)}`); + if (measured.length > limit) rows.push(`… ${measured.length - limit} faster ones not listed`); + rows.push('Wall time of each capability\'s last collection, excluding time queued; nothing was collected to show this.'); + return rows; +} diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index 906bc8d9..69e0c6fa 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -1,4 +1,5 @@ import {colorLevel} from '../presentation/capabilities.js'; +import type {FitReport} from './fitReport.js'; import {promptFacts, moduleFactContext, safeContextText, factAllowed} from '../context/facts.js'; import {routeModule} from '../context/surfaceRouter.js'; import {moduleDefinition} from '../context/modules.js'; @@ -644,7 +645,7 @@ export function buildContextLine( } /** Resolve appearance through the existing semantic theme context, restoring it after a chosen-theme render. */ -export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): string[] { +export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0, report?: FitReport): string[] { const rail = configuration.contextRail; if (configuration.provider !== 'nmsh' || rail.mode === 'off') return []; const saved = themeContext(); @@ -682,10 +683,15 @@ export function buildContextRail(context: PromptContext, width: number, configur compact[0] = true; } } + if (report) for (const group of groups) { + const index = rows.findIndex(row => row.includes(group[0]!)); + if (index === -1) report.dropped.add(group[0]!.id); else if (compact[index]) report.compacted.add(group[0]!.id); + } const paintedRows = rows.map((blocks, index) => { if (!blocks.length || width <= 0) return ''; const painted = paint(blocks, compact[index]!); if (displayWidth(painted) <= width) return painted; + for (const block of blocks) report?.compacted.add(block.id); if (mirrored) return fitRightPowerlineBlocks([...blocks].reverse(), width, fitted => paintPhysical(fitted, true)); return fitPowerlineBlocks(blocks, 0, 0, width, nmsh.endStyle, false, nmsh.startStyle, nmsh.connector, resolveConnectorFade('off', nmsh.connector), nmsh.connectorFadeColors, promptRenderExtras(draft, time)); diff --git a/src/prompt/railComposition.ts b/src/prompt/railComposition.ts index 0225491e..0eb5026e 100644 --- a/src/prompt/railComposition.ts +++ b/src/prompt/railComposition.ts @@ -1,6 +1,7 @@ import {colorLevel} from '../presentation/capabilities.js'; import type {PromptContext} from '../shell/ShellContext.js'; import type {PromptConfiguration} from './configuration.js'; +import type {FitReport} from './fitReport.js'; import {buildContextParts, buildContextRail, buildInlineContextPrefix, buildRightContext} from './prompt.js'; import {railPresentation, type RailPresentation} from './railLayout.js'; import {planScreen, regionOf, type ScreenPlan} from '../app/screenPlan.js'; @@ -12,11 +13,11 @@ const RESET = '\u001b[0m'; export interface PreparedRail {presentation: RailPresentation; content: string[]; time: number} /** Facts arrive resolved. This projection never requests or collects a value. */ -export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): PreparedRail { +export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0, report?: FitReport): PreparedRail { const enabled = configuration.provider === 'nmsh' && configuration.contextRail.mode !== 'off'; const parts = buildContextParts(context, columns, configuration, time); const budget = railPresentation(configuration, enabled ? configuration.contextRail.rows : 0, columns, parts); - const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time) : []; + const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time, report) : []; return {content, time, presentation: railPresentation(configuration, content.length, columns, parts)}; } diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 348a2e88..6b876774 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -369,6 +369,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('dividerDensity')}, {row: configRow('dividerColors'), note: () => 'Live composer dividers follow Divider lines (Look, under Chroma); past dividers never move'}, {row: configRow('outputFolding')}, + {row: configRow('semanticOutput')}, ]}, {id: 'history', title: 'History & navigation', intro: ['NMSh Native covers history, directory jumps and picking.', 'External providers are optional alternatives.'], rows: [ providerRow('setupHistory', 'History', 'Command history source', 'History', HISTORY_PROVIDERS, diff --git a/src/shell/ConfiguredCompletion.ts b/src/shell/ConfiguredCompletion.ts index 582f5ea7..ecedeef1 100644 --- a/src/shell/ConfiguredCompletion.ts +++ b/src/shell/ConfiguredCompletion.ts @@ -4,6 +4,7 @@ import {mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, statSync} import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {removeDirectoryNow} from '../util/removeDirectory.js'; import {commandIdentity, completionLabel, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js'; const MAX_BYTES = 1024 * 1024; @@ -124,7 +125,8 @@ export class ConfiguredCompletionSource implements CompletionSource { } this.wait?.resolve(false); this.wait = undefined; - if (this.root) rmSync(this.root, {recursive: true, force: true}); + // The helper's own cleanup may be removing it, and a shell may still write its pid file, at this moment. + if (this.root) removeDirectoryNow(this.root); this.root = undefined; this.output = ''; } diff --git a/src/shell/SemanticService.ts b/src/shell/SemanticService.ts index e094f215..5ee2b819 100644 --- a/src/shell/SemanticService.ts +++ b/src/shell/SemanticService.ts @@ -46,6 +46,13 @@ export function parseCompletionFacts(line: string): CompletionFacts | undefined return {zshCompletions: zc === '1', fzfTab: ft === '1', completionSystem: cs === '1'}; } +/** How many times a lookup superseded by newer shell knowledge is asked again before it settles as unknown. */ +const REASKS = 3; +/** How long the helper may take to source the user's configuration before it is given up on. */ +const STARTUP_MS = 20_000; +/** How long one lookup may take once the helper reads requests. */ +const LOOKUP_MS = 1500; + export class SemanticService { private child: ReturnType<typeof spawn>; private pending = new Map<number, (result: CommandType) => void>(); @@ -65,8 +72,10 @@ export class SemanticService { this.shellNamesComplete = text.split('\n').includes('complete'); this.cache.clear(); for (const [name, type] of this.shellNames) this.cache.set(name, type); - for (const resolve of this.pending.values()) resolve('unknown'); + // Lookups in flight were asked against the old knowledge; each one asks again against this one (see lookup). + const superseded = [...this.pending.values()]; this.pending.clear(); + for (const resolve of superseded) resolve('unknown'); for (const resolve of this.rawPending.values()) resolve(''); this.rawPending.clear(); } @@ -130,7 +139,12 @@ PS1="" this.child.stdin!.on('error', () => { /* ignore EPIPE */ }); + // Lookups are timed from when the helper reads them: sourcing a slow configuration (nvm, a large framework) took + // longer than one lookup's limit, and the first lookup's timeout then ended the helper for the whole session. + this.startupTimer = setTimeout(() => { if (!this.ready) this.kill(); }, STARTUP_MS); + this.startupTimer.unref?.(); this.child.stdin!.write(` +builtin printf 'ready\\n' while builtin read -r id mode cmd; do if [[ $mode == source ]]; then nmsh_kind=$(builtin whence -w -- "$cmd" 2>/dev/null) @@ -178,6 +192,7 @@ done\n`); this.buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; + if (line === 'ready') { this.markReady(); continue; } const space = line.indexOf(' '); if (space === -1) continue; const idStr = line.substring(0, space); @@ -205,8 +220,32 @@ done\n`); } private isDead = false; + private ready = false; + private startupTimer?: NodeJS.Timeout; + /** Time limits of lookups sent before the helper was ready, started once it is. */ + private readonly whenReady: Array<() => void> = []; + + private markReady(): void { + if (this.ready) return; + this.ready = true; + clearTimeout(this.startupTimer); + for (const arm of this.whenReady.splice(0)) arm(); + } + + private armWhenReady(arm: () => void): void { + if (this.ready) arm(); else this.whenReady.push(arm); + } async classifyCommand(cmd: string): Promise<CommandType> { + return this.lookup(cmd, 0); + } + + /** + * A lookup that newer shell knowledge superseded while it was in flight never caches its answer (it may be stale), + * and is asked again against the new knowledge, at most REASKS times. Without that, a stored command classified + * just as the next prompt arrived (a fast queue) would keep the unclassified color for good (#408). + */ + private async lookup(cmd: string, reasked: number): Promise<CommandType> { if (!cmd || cmd.trim().length === 0) return 'unknown'; // Only classify the first word if it has spaces cmd = cmd.split(' ')[0]; @@ -220,18 +259,22 @@ done\n`); const id = this.nextId++; const generation = this.generation; return new Promise((resolve) => { - const timer = setTimeout(() => { - this.pending.delete(id); - this.kill(); - resolve('unknown'); - }, 1500); + let timer: NodeJS.Timeout | undefined; this.pending.set(id, (res) => { clearTimeout(timer); - if (generation !== this.generation) { resolve('unknown'); return; } + if (generation !== this.generation) { resolve(reasked < REASKS ? this.lookup(cmd, reasked + 1) : 'unknown'); return; } if (this.cache.size >= 8192) this.cache.delete(this.cache.keys().next().value!); this.cache.set(cmd, res); resolve(res); }); + this.armWhenReady(() => { + if (!this.pending.has(id)) return; + timer = setTimeout(() => { + this.pending.delete(id); + this.kill(); + resolve('unknown'); + }, LOOKUP_MS); + }); // Safety: cmd should not contain newlines or null bytes const safeCmd = cmd.replace(/[\r\n\0]/g, ''); try { @@ -250,9 +293,13 @@ done\n`); if (this.isDead || !this.child.stdin?.writable || this.rawPending.size >= 32) return Promise.resolve(undefined); const id = this.nextId++; return new Promise(resolve => { - const timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, 1500); - timer.unref?.(); + let timer: NodeJS.Timeout | undefined; this.rawPending.set(id, line => { clearTimeout(timer); resolve(line || undefined); }); + this.armWhenReady(() => { + if (!this.rawPending.has(id)) return; + timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, LOOKUP_MS); + timer.unref?.(); + }); try { this.child.stdin!.write(`${id} ${mode} ${word.replace(/[\r\n\0\s]/gu, '')}\n`); } catch { clearTimeout(timer); this.rawPending.delete(id); resolve(undefined); } }); @@ -279,6 +326,8 @@ done\n`); kill() { this.isDead = true; + clearTimeout(this.startupTimer); + this.whenReady.length = 0; try { this.child.stdin?.end(); } catch (e) { /* ignore */ } diff --git a/src/status/StatusStrip.ts b/src/status/StatusStrip.ts index 5297df13..40a48d6e 100644 --- a/src/status/StatusStrip.ts +++ b/src/status/StatusStrip.ts @@ -1,4 +1,5 @@ import {linuxBattery, parseMeminfo, parsePmset, parseVmStat, type SystemStats} from './systemStats.js'; +import type {FitReport} from '../prompt/fitReport.js'; import type {StatusStripSettings, StripZone} from '../prompt/configuration.js'; import type {ContextFacts} from '../context/facts.js'; import {safeContextText} from '../context/facts.js'; @@ -97,6 +98,8 @@ interface Entry { tone: 'secondary' | 'failure' | 'accent'; /** Keep Awake narrows only after everything else has gone (it outranks every other item). */ last?: boolean; + /** The routed module this entry shows, for the fit report. */ + id?: string; } /** Hostile module text loses whole control sequences, then is bounded like every other display string. */ @@ -105,7 +108,7 @@ const clean = (text: string) => safeContextText(stripTerminalControls(text), 120 function entries(settings: StatusStripSettings, stats: SystemStats, now: Date | undefined, awake: StripAwake | undefined, modules: readonly StripModuleItem[]): Entry[] { // Routed modules outlast the strip's own items; among them, higher module priority stays longest. const routed: Entry[] = modules.map(module => ({zone: module.zone ?? 'right', forms: [...new Set([clean(module.text), ...(module.compact ?? []).map(clean)])].filter(Boolean), - form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const})).filter(entry => entry.forms.length); + form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const, ...(module.id ? {id: module.id} : {})})).filter(entry => entry.forms.length); const native: Entry[] = stripItems(settings, stats, now).map(item => { const icon = item.icon ? semanticIcon(item.icon) : ''; return {zone: settings.nativeZone, forms: item.compact ? [item.text, item.compact] : [item.text], form: 0, ...(icon ? {icon} : {}), rank: item.priority, tone: 'secondary' as const}; @@ -191,10 +194,17 @@ function fit(all: Entry[], settings: StatusStripSettings, columns: number): Entr * room and otherwise pushed between its neighbours. */ export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats & {now?: number}, columns: number, now?: Date, awake?: StripAwake, - modules: readonly StripModuleItem[] = []): string { - if (!settings.enabled || columns < STRIP_MIN_COLUMNS) return ''; + modules: readonly StripModuleItem[] = [], report?: FitReport): string { + if (!settings.enabled) return ''; + if (columns < STRIP_MIN_COLUMNS) { for (const module of modules) if (module.id) report?.dropped.add(module.id); return ''; } const time = now ?? (stats.now !== undefined ? new Date(stats.now) : undefined); - const list = fit(entries(settings, stats, time, awake, modules), settings, columns); + const candidates = entries(settings, stats, time, awake, modules); + const list = fit(candidates, settings, columns); + if (report) for (const entry of candidates) { + if (!entry.id) continue; + const kept = list.find(item => item.id === entry.id); + if (!kept) report.dropped.add(entry.id); else if (kept.form > 0) report.compacted.add(entry.id); + } if (!list.length) return ''; const group = (zone: StripZone) => list.filter(entry => entry.zone === zone); const [left, center, right] = ZONES.map(zone => ({zone, painted: paintZone(zone, group(zone), settings), width: zoneWidth(zone, group(zone), settings)})); diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..fa7b0d15 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -3,22 +3,24 @@ import {renderControls} from '../ui/controls.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; +import {trackMark} from './trackBlock.js'; import type {WatchTask} from './WatchTasks.js'; -/** /watch: one live block per watch (current, previous, what changed), never a pile of repeated output. */ +/** /track and /watch share one live snapshot view: no duplicated transcript output. */ export interface WatchPanelState { selected: number; /** Showing the selected watch's current output. */ output: boolean; /** A watch waiting for the person's explicit Yes (unknown command, or a network target). */ - confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'}; + confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'; /** Started as /track: it gets a live transcript block. */ track?: boolean}; message?: string; } -export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now'; id: string} | {kind: 'confirm'}; +export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now' | 'copy'; id: string} | {kind: 'confirm'}; const formatEvery = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; const ago = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +const runStatus = (run: WatchTask['current']): string => !run ? 'waiting for first result' : run.timedOut ? 'timed out' : run.error ? 'process error' : run.signal ? `signal ${run.signal}` : `exit ${run.exitCode}`; export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonly WatchTask[]): WatchPanelAction | undefined { if (state.confirm) { @@ -37,6 +39,7 @@ export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonl const letter = key.value.toLowerCase(); if (letter === 'p') return {kind: watch.status === 'paused' ? 'resume' : 'pause', id: watch.id}; if (letter === 'r') return {kind: 'now', id: watch.id}; + if (letter === 'c') return {kind: 'copy', id: watch.id}; if (letter === 's') return {kind: 'stop', id: watch.id}; } return undefined; @@ -47,10 +50,20 @@ export function watchRow(watch: WatchTask, now: number): string { const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); const reset = '\u001b[0m'; - const state = watch.current ? (watch.current.exitCode === 0 ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; - const mark = watch.status === 'paused' ? '‖' : '◉'; - return `${state}${mark}${reset} ${secondary}Watching · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` - + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : ` · ${ago(now - watch.startedAt)}`}${reset}`; + const state = watch.current ? (watch.current.exitCode === 0 && !watch.current.timedOut && !watch.current.error ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; + const mark = trackMark(watch.status === 'paused' ? 'paused' : 'live'); + const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago · ${runStatus(watch.current)}` : ' · first run pending'; + return `${state}${mark}${reset} ${secondary}Track · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` + + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : last}${reset}`; +} + +/** The rows above the composer for active watches: one each for up to two, else one summary (each /track block and the manager show the rest). */ +export function watchNoticeRows(watches: readonly WatchTask[], now: number): string[] { + if (watches.length <= 2) return watches.map(watch => watchRow(watch, now)); + const failing = watches.filter(watch => watch.current && (watch.current.exitCode !== 0 || watch.current.timedOut || watch.current.error)).length; + const paused = watches.filter(watch => watch.status === 'paused').length; + const parts = [`${watches.length} trackers`, ...(failing ? [`${failing} failing`] : []), ...(paused ? [`${paused} paused`] : []), '/track opens the manager']; + return [`${foreground(failing ? UI_COLORS.failure : UI_COLORS.success)}${trackMark('live')}\u001b[0m ${foreground(UI_COLORS.secondary)}${parts.join(' · ')}\u001b[0m`]; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,17 +72,21 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const reset = '\u001b[0m'; - const rows = [`${primary} Watch${reset}`]; + const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { + if (columns < 40 || rowsAvailable < 10) { + return [`${primary} Resize to review tracked command (40 columns, 14 rows)${reset}`, + `${secondary} Confirmation is disabled at this size. Esc cancels.${reset}`].map(row => truncateAnsi(row, columns)); + } const yes = state.confirm.choice === 'yes'; - rows.push('', ` ${primary}Watch ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, + rows.push('', ` ${primary}${state.confirm.track ? 'Track' : 'Watch'} ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, ` ${subtle}It runs in ${state.confirm.cwd}, every ${formatEvery(state.confirm.intervalMs ?? 5000)}, until you stop it.${reset}`, '', ` ${!yes ? `${accent}${GLYPHS.selection} No${reset}` : `${subtle} No${reset}`} ${yes ? `${accent}${GLYPHS.selection} Yes${reset}` : `${subtle} Yes${reset}`}`, '', renderControls([['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']])); return rows.map(row => truncateAnsi(row, columns)); } if (!watches.length) { - rows.push('', ` ${secondary}Nothing is being watched.${reset}`, ` ${subtle}/watch git status · /watch --every 10s npm test · stop with /watch stop${reset}`); + rows.push('', ` ${secondary}No active command trackers.${reset}`, ` ${subtle}/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df${reset}`); if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); rows.push('', renderControls([['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); @@ -80,14 +97,14 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch if (state.output) { const lines = (watch.current?.output ?? '').split('\n'); const room = Math.max(4, rowsAvailable - rows.length - 3); - rows.push(` ${subtle}Current output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); + rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''} · ${runStatus(watch.current)}${watch.current?.truncated ? ' · output truncated' : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); } else { - rows.push(` ${subtle}Current${reset} ${secondary}${watch.summary ?? (watch.current ? `exit ${watch.current.exitCode}` : 'running…')}${reset}`); + rows.push(` ${subtle}Current${reset} ${secondary}${watch.current?.timedOut || watch.current?.error || watch.current?.signal ? runStatus(watch.current) : watch.summary ?? (watch.current ? runStatus(watch.current) : 'running…')}${watch.current?.truncated ? ' · truncated' : ''}${reset}`); if (watch.previous) rows.push(` ${subtle}Previous${reset} ${secondary}${watch.previousSummary ?? `exit ${watch.previous.exitCode}`}${reset}`); rows.push(` ${subtle}Changed${reset} ${watch.changes.length ? '' : `${secondary}nothing since the last run${reset}`}`, ...watch.changes.map(line => ` ${line.startsWith('✗') || line.startsWith('-') ? foreground(UI_COLORS.failure) : line.startsWith('✓') || line.startsWith('+') ? foreground(UI_COLORS.success) : secondary}${line}${reset}`)); rows.push(` ${subtle}Runs ${watch.runs} · ${ago(now - watch.startedAt)}${watch.network ? ' · repeated network access' : ''}${reset}`); } if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['S', 'stop'], ['Esc', 'close']])); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['C', 'copy latest'], ['S', 'stop'], ['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index d051bab1..ca021c43 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,9 @@ import {spawn, type ChildProcess} from 'node:child_process'; -import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {statSync} from 'node:fs'; +import {StringDecoder} from 'node:string_decoder'; +import {classifyCommand, maskQuoted, splitCommands, writesFiles, type PasteKind} from '../input/pasteGuard.js'; +import {reachesRemote, repeatRisk} from './repeatSafety.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -19,39 +23,75 @@ const REFUSED: ReadonlySet<PasteKind> = new Set(['install', 'modifies', 'destruc export const DEFAULT_INTERVAL_MS = 5000; export const MIN_LOCAL_MS = 2000; export const MIN_REMOTE_MS = 15000; +export const MAX_INTERVAL_MS = 3_600_000; +export const MAX_ACTIVE_WATCHES = 8; +const MAX_COMMAND = 4096; const MAX_OUTPUT = 200_000; +const STOP_GRACE_MS = 1500; +const isDirectory = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } }; export function watchSafety(command: string): WatchSafety { + if (!command.trim() || command.length > MAX_COMMAND || /[\u0000-\u001f\u007f]/u.test(command)) { + return {kind: 'refused', reason: 'A tracked command must be nonempty, single-line and at most 4096 characters.'}; + } + // A harmless-looking first word is not evidence that embedded shell execution is safe. + // Fail closed rather than scheduling substitutions, backticks or process substitutions. + if (/\$\(|`|[<>]\(/u.test(command)) { + return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; + } + // Detached shell jobs would outlive the observed sample and multiply on every tick; heredocs also need a different + // stdin/input-ownership contract. `&&` and descriptor duplication (`2>&1`, `>&2`) are not background operators; `&>` + // is one for the POSIX sh that runs samples (dash reads `cmd &>file` as `cmd &` then `>file`), so it stays refused. + if (/(?:^|[^&<>])&(?!&)|<</u.test(maskQuoted(command))) { + return {kind: 'refused', reason: 'Background operators and heredocs cannot be tracked safely.'}; + } + if (writesFiles(command)) return {kind: 'refused', reason: 'NMSh won\'t repeatedly run a command whose output is redirected into a file.'}; const parts = splitCommands(command); if (!parts.length) return {kind: 'refused', reason: 'There is no command to watch.'}; let unknown = false; + let reason: string | undefined; let network = false; let remote = false; for (const part of parts) { const kinds = classifyCommand(part.text, part.pipedFrom); const bad = kinds.find(kind => REFUSED.has(kind)); - if (bad) return {kind: 'refused', reason: `/watch won't repeatedly run a command that ${bad === 'navigation' ? 'changes directory' : bad === 'install' ? 'installs packages' : bad === 'privilege' ? 'runs as root' : bad === 'pipeline' ? 'runs downloaded code' : bad === 'destructive' ? 'deletes or destroys things' : 'modifies files'} (${part.text}).`}; + if (bad) return {kind: 'refused', reason: `NMSh won't repeatedly run a command that ${bad === 'navigation' ? 'changes directory' : bad === 'install' ? 'installs packages' : bad === 'privilege' ? 'runs as root' : bad === 'pipeline' ? 'runs downloaded code' : bad === 'destructive' ? 'deletes or destroys things' : 'modifies files'} (${part.text}).`}; + // Repetition has its own, stricter gate (repeatSafety.ts): a refusal there ends it; a doubt asks first. + const risk = repeatRisk(part.text, kinds); + if (risk?.kind === 'refused') return {kind: 'refused', reason: `NMSh won't repeatedly run a command that ${risk.reason}.`}; + if (risk) { unknown = true; reason ??= risk.reason; } if (kinds.includes('network')) { network = true; - const target = /https?:\/\/([^/:\s]+)/u.exec(part.text)?.[1] ?? /\s([\w.-]+\.[a-z]{2,})(?:\s|$)/iu.exec(part.text)?.[1]; - if (target && !/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/u.test(target)) remote = true; + if (reachesRemote(part.text, kinds)) remote = true; } - if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; + // Allow only Docker's explicit read-only disk report. Other Docker verbs (including + // 'system prune') still require confirmation, or are refused by the classifier. + const dockerDiskReport = /^docker\s+system\s+df(?:\s+(?:-v|--verbose))?\s*$/u.test(part.text); + if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !dockerDiskReport && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; } - return unknown ? {kind: 'confirm', reason: 'NMSh can\'t tell whether this command changes anything; it will run every interval.', network, remote} : {kind: 'allowed', network, remote}; + return unknown ? {kind: 'confirm', reason: `${reason ?? 'NMSh can\'t tell whether this command changes anything.'} It will run every interval.`, network, remote} : {kind: 'allowed', network, remote}; } -/** "--every 5s", "every 10 seconds", "-n 2" → milliseconds (clamped to the minimum for the command). */ -export function parseWatch(text: string): {command: string; intervalMs?: number} { - const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|minutes?|ms)?\s+/u.exec(text.trim()); - if (!every) return {command: text.trim()}; +/** Parse a finite interval strictly. Invalid cadence never falls through as shell text. */ +export function parseWatch(text: string): {command: string; intervalMs?: number; error?: string} { + const input = text.trim(); + const marker = /^(?:--every|-n|every)(?:\s+|$)/u; + if (!marker.test(input)) return {command: input.replace(/^--(?:\s+|$)/u, '').trim()}; + const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(ms|seconds?|secs?|s|minutes?|mins?|m)?(?:\s+|$)/u.exec(input); + if (!every) return {command: '', error: 'Invalid interval. Use /track --every 5s -- <command>.'}; const value = Number(every[1]); const unit = every[2] ?? 's'; - const intervalMs = unit === 'ms' ? value : /^m/u.test(unit) && unit !== 'ms' ? value * 60_000 : value * 1000; - return {command: text.trim().slice(every[0].length).trim(), intervalMs}; + const multiplier = unit === 'ms' ? 1 : /^(?:m|min|minute)/u.test(unit) ? 60_000 : 1000; + const intervalMs = value * multiplier; + if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS) { + return {command: '', error: 'Interval must be positive and at most 60 minutes.'}; + } + const command = input.slice(every[0].length).replace(/^--(?:\s+|$)/u, '').trim(); + if (!command) return {command: '', error: 'Provide a finite command after the interval.'}; + return {command, intervalMs}; } -export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number} +export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number; truncated?: boolean; timedOut?: boolean; error?: string; signal?: string | null} export interface WatchTask { id: string; @@ -109,9 +149,19 @@ export class WatchTasks { readonly watches: WatchTask[] = []; private readonly timers = new Map<string, NodeJS.Timeout>(); private readonly children = new Map<string, ChildProcess>(); + private readonly killTimers = new Map<string, NodeJS.Timeout>(); + /** KILL escalations for leftovers of finished samples (see endStragglers); never cleared early. */ + private readonly stragglers = new Set<NodeJS.Timeout>(); private readonly listeners = new Set<(watch: WatchTask, transition?: 'pass' | 'fail') => void>(); - constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv = process.env) {} + /** + * `env` may be a function, read at every sample: the window passes its launch environment with the live shell's + * PATH, so a tracked command finds what the shell finds (never its aliases, functions or unexported variables). + */ + constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv | (() => NodeJS.ProcessEnv) = process.env) {} + + /** Whether a sample of this watch is running now. */ + sampling(id: string): boolean { return this.children.has(id); } onChange(listener: (watch: WatchTask, transition?: 'pass' | 'fail') => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private emit(watch: WatchTask, transition?: 'pass' | 'fail'): void { for (const listener of this.listeners) listener(watch, transition); } @@ -121,6 +171,17 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude<WatchSafety, {kind: 'refused'}>): WatchTask { + if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); + // Stopped rows do not appear in the manager; do not retain unlimited stale records. + if (this.watches.length >= 128) { + for (let index = 0; index < this.watches.length && this.watches.length > 64;) { + if (this.watches[index]?.status === 'stopped') this.watches.splice(index, 1); + else index += 1; + } + } + if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { + throw new Error('Invalid tracking interval.'); + } counter += 1; const minimum = safety.remote ? MIN_REMOTE_MS : MIN_LOCAL_MS; const watch: WatchTask = {id: `watch-${counter}`, command, cwd, intervalMs: Math.max(minimum, intervalMs ?? DEFAULT_INTERVAL_MS), startedAt: this.now(), runs: 0, @@ -131,30 +192,93 @@ export class WatchTasks { } /** Run once now (skipped while a run is still in flight: runs never overlap). */ - runNow(id: string): void { + runNow(id: string): boolean { const watch = this.get(id); - if (!watch || watch.status === 'stopped' || this.children.has(id)) return; + if (!watch || watch.status === 'stopped' || this.children.has(id)) return false; + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); const started = this.now(); let output = ''; + let receivedBytes = 0; + let truncated = false; + let timedOut = false; + let spawnError: string | undefined; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); let child: ChildProcess; try { - child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...this.env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); - } catch (error) { this.record(watch, {output: String(error), exitCode: -1, at: started, durationMs: 0}); return; } + const env = typeof this.env === 'function' ? this.env() : this.env; + child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); + } catch (error) { + this.record(watch, {output: stripTerminalControls(String(error), MAX_OUTPUT), exitCode: -1, at: started, durationMs: 0, error: 'spawn failed'}); + return true; + } this.children.set(id, child); - const take = (chunk: Buffer) => { if (output.length < MAX_OUTPUT) output += chunk.toString().slice(0, MAX_OUTPUT - output.length); }; - child.stdout?.on('data', take); - child.stderr?.on('data', take); - // A run never outlives a few intervals (bounded at a minute). - const limit = setTimeout(() => { try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, Math.min(60_000, watch.intervalMs * 3)); + const take = (decoder: StringDecoder, chunk: Buffer) => { + const kept = Math.min(chunk.length, Math.max(0, MAX_OUTPUT - receivedBytes)); + if (kept) { receivedBytes += kept; output += decoder.write(chunk.subarray(0, kept)); } + if (kept < chunk.length) truncated = true; + }; + child.stdout?.on('data', (chunk: Buffer) => take(stdoutDecoder, chunk)); + child.stderr?.on('data', (chunk: Buffer) => take(stderrDecoder, chunk)); + // Timeout is not a successful completion, even when the shell exits on a signal. + const limit = setTimeout(() => { + timedOut = true; + this.signalGroup(child, 'SIGKILL'); + // A detached descendant may retain pipe FDs after the tracked shell dies. + // Stop consuming it; the sample must not wait on an unrelated pipe holder. + child.stdout?.destroy(); + child.stderr?.destroy(); + }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); - const done = (code: number | null) => { + child.on('error', error => { + // Node names the shell when the working directory is what is missing; say which one it is. + spawnError = (error as NodeJS.ErrnoException).code === 'ENOENT' && !isDirectory(watch.cwd) ? `the directory ${watch.cwd} is no longer available` : String(error); + }); + // Only 'close' guarantees the captured stdio has finished, unlike 'exit'. + child.on('close', (code, signal) => { clearTimeout(limit); + const killTimer = this.killTimers.get(id); + if (killTimer) clearTimeout(killTimer); + this.killTimers.delete(id); + this.endStragglers(child); if (this.children.get(id) !== child) return; this.children.delete(id); - this.record(watch, {output: output.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, ''), exitCode: code, at: started, durationMs: this.now() - started}); - }; - child.on('error', () => done(-1)); - child.on('exit', code => done(code)); + output += stdoutDecoder.end() + stderrDecoder.end(); + const sanitized = output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'); + this.record(watch, {output: sanitized, exitCode: code ?? -1, at: started, durationMs: this.now() - started, + ...(truncated ? {truncated} : {}), ...(timedOut ? {timedOut} : {}), + ...(spawnError ? {error: stripTerminalControls(spawnError, 300)} : {}), + ...(signal ? {signal} : {})}); + }); + // The presenter shows `refreshing…` over the previous result from now on; told only once the sample is wired, so + // a listener that throws cannot leave a child without its handlers. + this.emit(watch); + return true; + } + + /** + * A finished sample's process group must be empty: anything left (a child that ignored TERM, or one that closed its + * output and kept running) outlived its sample and would pile up on every refresh. End it with TERM, then KILL after + * the grace period. The KILL timer keeps the window alive that long when it is closing, so nothing is left behind. + */ + private endStragglers(child: ChildProcess): void { + const group = child.pid; + if (!group) return; + try { process.kill(-group, 0); } catch { return; } + try { process.kill(-group, 'SIGTERM'); } catch { return; } + const timer = setTimeout(() => { + this.stragglers.delete(timer); + try { process.kill(-group, 'SIGKILL'); } catch { /* already gone */ } + }, STOP_GRACE_MS); + this.stragglers.add(timer); + } + + private signalGroup(child: ChildProcess, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch { try { child.kill(signal); } catch { /* already exited */ } } } private record(watch: WatchTask, run: WatchRun): void { @@ -200,15 +324,28 @@ export class WatchTasks { return true; } - /** Stop: no more runs, the in-flight run's process group is ended, nothing is left behind. */ + /** Stop scheduling, signal the owned process group, and bound graceful termination. */ stop(id: string): boolean { const watch = this.get(id); if (!watch || watch.status === 'stopped') return false; watch.status = 'stopped'; this.schedule(watch); const child = this.children.get(id); - this.children.delete(id); - if (child?.pid) { try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } } + if (child) { + this.signalGroup(child, 'SIGTERM'); + const pending = this.killTimers.get(id); + if (pending) clearTimeout(pending); + const killTimer = setTimeout(() => { + this.killTimers.delete(id); + if (this.children.get(id) === child) { + this.signalGroup(child, 'SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + } + }, STOP_GRACE_MS); + killTimer.unref(); + this.killTimers.set(id, killTimer); + } this.emit(watch); return true; } diff --git a/src/tasks/repeatSafety.ts b/src/tasks/repeatSafety.ts new file mode 100644 index 00000000..865818c6 --- /dev/null +++ b/src/tasks/repeatSafety.ts @@ -0,0 +1,189 @@ +import {commandWords, type PasteKind} from '../input/pasteGuard.js'; + +/** + * /track and /watch run a command again and again with nobody reading each run. The shared classifier says what a + * command is; this is the stricter gate for repeating it: whether it may start at once, needs an explicit Yes, or is + * never repeated. When it cannot tell, it asks rather than allows. Network commands start at once only when they are + * read-only probes (ping, dig, `nc -z`, a curl GET or a wget to standard output); everything that sends data, saves + * files, signs in, publishes or runs another command asks first. + */ +export type RepeatRisk = {kind: 'refused' | 'confirm'; reason: string}; + +const LOOPBACK = /^(?:localhost|localhost\.localdomain|ip6-localhost|127(?:\.\d{1,3}){3}|::1|0\.0\.0\.0)$/u; +const PROBES = new Set(['ping', 'ping6', 'dig', 'nslookup', 'host', 'traceroute', 'traceroute6', 'mtr']); +const NETCAT = new Set(['nc', 'ncat', 'netcat']); +const REMOTE_SESSIONS = new Set(['ssh', 'sftp', 'telnet', 'ftp', 'mosh']); +const HTTP_CLIENTS = new Set(['http', 'https', 'xh', 'httpie']); +const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']); +const PACKAGE_QUERIES = new Set(['view', 'info', 'show', 'search', 'outdated', 'fund', 'audit']); +const POWER = /^(?:shutdown|reboot|halt|poweroff)$/u; +const POWER_VERBS = /^(?:reboot|poweroff|halt|suspend|hibernate|hybrid-sleep|kexec|soft-reboot)$/u; +/** curl short options that take a value (the rest of the cluster, or the next word). */ +const CURL_VALUE = new Set([...'oDcKXdFTHwAmueEbrxYyzCPUQt']); +/** wget short options that take a value. */ +const WGET_VALUE = new Set([...'OoaPtTweiBUQlARDIX']); +const ASSIGNMENT = /^[A-Za-z_]\w*=/u; + +const refused = (reason: string): RepeatRisk => ({kind: 'refused', reason}); +const confirm = (reason: string): RepeatRisk => ({kind: 'confirm', reason}); +const standardStream = (value: string | undefined) => value === '-' || value === '/dev/null' || value === '/dev/stdout'; +const cluster = (arg: string) => /^-[A-Za-z0-9]+$/u.test(arg) ? arg.slice(1) : ''; + +/** The command's name and arguments, after any leading `NAME=value` assignments. */ +function parts(text: string): {assignments: string[]; name: string; args: string[]} { + const all = commandWords(text); + let index = 0; + while (ASSIGNMENT.test(all[index] ?? '')) index += 1; + const [command = '', ...args] = all.slice(index); + return {assignments: all.slice(0, index), name: command.split('/').pop() ?? command, args}; +} + +/** What repeating one command (one part of a command line) would risk beyond what the classifier already refuses. */ +export function repeatRisk(text: string, kinds: readonly PasteKind[]): RepeatRisk | undefined { + const {assignments, name, args} = parts(text); + if (POWER.test(name) || (/^(?:systemctl|loginctl|launchctl)$/u.test(name) && args.some(arg => POWER_VERBS.test(arg))) || (name === 'init' && /^[06]$/u.test(args[0] ?? ''))) { + return refused(`shuts down or restarts the computer (${text})`); + } + const writes = writesThroughOptions(name, args, text); + if (writes) return refused(writes); + if (args.some(arg => /^--watch(?:All)?(?:=true)?$/u.test(arg))) return refused(`runs until it is stopped (${text}); /track repeats a command that finishes`); + if (assignments.length) return confirm(`It sets ${assignments.map(word => `${word.split('=')[0]}=…`).join(' ')} for the command, which can change what it runs.`); + if (name === 'env') { + const inner = envCommand(args); + if (inner) return confirm(`It runs another command (env … ${inner}).`); + } + if (name === 'git') return gitRisk(args, kinds); + if (PROBES.has(name)) return undefined; + if (NETCAT.has(name)) return args.some(arg => cluster(arg).includes('z')) ? undefined : confirm('It opens a network connection every interval.'); + if (name === 'curl') return curlRisk(args); + if (name === 'wget') return wgetRisk(args); + if (REMOTE_SESSIONS.has(name)) return confirm('It connects to another computer every interval.'); + if (HTTP_CLIENTS.has(name)) return confirm('It sends HTTP requests that may change data.'); + if (PACKAGE_MANAGERS.has(name) && kinds.includes('network')) { + const sub = args.find(arg => !arg.startsWith('-')) ?? ''; + if (PACKAGE_QUERIES.has(sub) && !(sub === 'audit' && args.some(arg => arg === 'fix' || arg === '--fix'))) return undefined; + return confirm(`It publishes, signs in, changes packages or downloads and runs one (${name} ${sub}).`); + } + if (kinds.includes('network') && !/^(?:npx|pnpx|bunx)$/u.test(name)) return confirm('It contacts the network every interval and NMSh can\'t tell what it sends.'); + return undefined; +} + +/** Options that make an otherwise read-only command write files, delete them or update what it checks. */ +function writesThroughOptions(name: string, args: readonly string[], text: string): string | undefined { + const has = (pattern: RegExp) => args.some(arg => pattern.test(arg)); + if (name === 'git' && has(/^--output(?:=|$)/u)) return `writes files (${text})`; + if (name === 'sort' && (has(/^--output(?:=|$)/u) || args.some(arg => cluster(arg).includes('o')))) return `writes files (${text})`; + if (name === 'find' && has(/^-(?:fprint0?|fprintf|fls|delete|exec|execdir|ok|okdir)$/u)) return `writes, deletes or runs things (${text})`; + if (name === 'tree' && has(/^-o$/u)) return `writes files (${text})`; + if (name === 'yq' && has(/^(?:-i|--inplace)$/u)) return `modifies files (${text})`; + if (name === 'scp') return `copies files (${text})`; + if (name === 'rsync' && !(has(/^--(?:dry-run|list-only)$/u) || args.some(arg => cluster(arg).includes('n')))) return `copies files (${text})`; + if (has(/^(?:--fix|--write|--update|--update-?snapshots?|--updateSnapshot|--snapshot-update)(?:=.*)?$/u)) return `rewrites files (${text})`; + if (args.includes('-u') && /\b(?:jest|vitest)\b|^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/u.test(text)) return `updates snapshots (${text})`; + return undefined; +} + +/** The command `env` would run, if any (options and assignments alone only print the environment). */ +function envCommand(args: readonly string[]): string | undefined { + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (arg === '--') return args[at + 1]; + if (/^-[uC]$/u.test(arg)) { at += 1; continue; } + if (/^-\w*S|^--split-string/u.test(arg)) return arg; + if (arg.startsWith('-') || ASSIGNMENT.test(arg)) continue; + return arg; + } + return undefined; +} + +function gitRisk(args: readonly string[], kinds: readonly PasteKind[]): RepeatRisk | undefined { + // Global options come before the subcommand; `-c` there sets configuration (which can name programs to run). + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (!arg.startsWith('-')) break; + if (arg === '-c' || /^--(?:config-env|exec-path)(?:=|$)/u.test(arg)) return confirm('It sets git options that can run other programs.'); + if (arg === '-C') at += 1; + } + if (args.includes('--ext-diff')) return confirm('It runs an external diff program.'); + if (kinds.includes('network') && args.find(arg => !arg.startsWith('-')) === 'fetch') return confirm('It fetches from a remote and updates its branches every interval.'); + return undefined; +} + +function curlRisk(args: readonly string[]): RepeatRisk | undefined { + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + const scheme = /^([a-z][a-z0-9+.-]*):\/\//iu.exec(arg)?.[1]?.toLowerCase(); + if (scheme && scheme !== 'http' && scheme !== 'https') return confirm(`It uses ${scheme}://, which can send commands, not only read.`); + if (arg.startsWith('--')) { + const equals = arg.indexOf('='); + const option = equals === -1 ? arg : arg.slice(0, equals); + const value = equals === -1 ? args[at + 1] : arg.slice(equals + 1); + if (/^--(?:data|form|upload-file|json|remote-name|remote-header-name|output-dir|create-dirs|cookie-jar|config|trace|libcurl|etag-save|hsts|alt-svc|mail-|quote|post30)/u.test(option)) { + return confirm(`It sends data or saves files (${option}).`); + } + if (option === '--request' && !/^(?:GET|HEAD)$/iu.test(value ?? '')) return confirm(`It sends ${value ?? 'other'} requests.`); + if ((option === '--output' || option === '--dump-header') && !standardStream(value)) return confirm(`It saves files (${option}).`); + continue; + } + if (!/^-[^-]/u.test(arg)) continue; + for (let index = 1; index < arg.length; index += 1) { + const letter = arg[index]!; + if ('dFTJOKQ'.includes(letter)) return confirm(`It sends data or saves files (-${letter}).`); + if (!CURL_VALUE.has(letter)) continue; + const value = index + 1 < arg.length ? arg.slice(index + 1) : args[at + 1]; + if (letter === 'X' && !/^(?:GET|HEAD)$/iu.test(value ?? '')) return confirm(`It sends ${value ?? 'other'} requests.`); + if ('oDc'.includes(letter) && !standardStream(value)) return confirm(`It saves files (-${letter}).`); + if (index + 1 >= arg.length) at += 1; + break; + } + } + return undefined; +} + +function wgetRisk(args: readonly string[]): RepeatRisk | undefined { + let toOutput = false; + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (arg.startsWith('--')) { + const equals = arg.indexOf('='); + const option = equals === -1 ? arg : arg.slice(0, equals); + const value = equals === -1 ? args[at + 1] : arg.slice(equals + 1); + if (option === '--spider') toOutput = true; + else if (option === '--output-document') toOutput = standardStream(value); + else if (/^--(?:post-|body-|method|recursive|mirror|page-requisites|convert-links|timestamping|continue|force-directories|save-cookies|warc-|backups)/u.test(option)) return confirm(`It sends data or saves files (${option}).`); + else if (/^--(?:output-file|append-output)$/u.test(option) && !standardStream(value)) return confirm(`It writes a log file (${option}).`); + continue; + } + if (!/^-[^-]/u.test(arg)) continue; + for (let index = 1; index < arg.length; index += 1) { + const letter = arg[index]!; + if ('rmpkNxc'.includes(letter)) return confirm(`It saves files (-${letter}).`); + if (!WGET_VALUE.has(letter)) continue; + const value = index + 1 < arg.length ? arg.slice(index + 1) : args[at + 1]; + if (letter === 'O') toOutput = standardStream(value); + if ('oa'.includes(letter) && !standardStream(value)) return confirm(`It writes a log file (-${letter}).`); + if (index + 1 >= arg.length) at += 1; + break; + } + } + return toOutput ? undefined : confirm('It saves what it downloads to a file every interval.'); +} + +/** + * Whether a network command reaches beyond this computer: only loopback targets are local. Registries and git + * remotes are remote; a command whose target NMSh cannot find is treated as remote. + */ +export function reachesRemote(text: string, kinds: readonly PasteKind[]): boolean { + const {name, args} = parts(text); + if (/^(?:npx|pnpx|bunx)$/u.test(name)) return false; + if (PACKAGE_MANAGERS.has(name) || name === 'git' || name === 'gh') return kinds.includes('network'); + const hosts: string[] = []; + for (const arg of args) { + if (arg.startsWith('-')) continue; + const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]*\]|[^:/?#]*)/iu.exec(arg); + if (url) { hosts.push(url[1] ?? ''); continue; } + const host = /^@?(?:[^@\s]+@)?(\[[0-9a-fA-F:]+\]|[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)(?::\S*)?$/u.exec(arg)?.[1]; + if (host && !/^\d+(?:\.\d+)?$/u.test(host)) hosts.push(host); + } + return !hosts.length || hosts.some(host => !LOOPBACK.test(host.replace(/^\[|\]$/gu, '').toLowerCase())); +} diff --git a/src/tasks/trackBlock.ts b/src/tasks/trackBlock.ts new file mode 100644 index 00000000..74bdf048 --- /dev/null +++ b/src/tasks/trackBlock.ts @@ -0,0 +1,97 @@ +import {displaySafe} from '../input/PasteReview.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi, truncateText} from '../util/text.js'; +import type {WatchRun, WatchTask} from './WatchTasks.js'; + +/** + * A /track block's live rows (#393), drawn under its stored lines and replaced on every refresh: a status row that + * says only what is known (first run pending, refreshing, when the last result completed and how, paused, stale, + * stopped), then the latest completed result. The result keeps a stable height (the largest seen, at most + * MAX_TRACK_ROWS) so a shorter refresh never moves what is below it; a longer one says how much is not shown. + */ + +export const MAX_TRACK_ROWS = 12; +const RESET = '\u001b[0m'; + +/** A tracker's state mark; Safe glyphs use ASCII. */ +const MARKS = {live: ['◉', '*'], stopped: ['■', '#'], paused: ['‖', '='], pending: ['◌', 'o']} as const; +export const trackMark = (state: keyof typeof MARKS): string => MARKS[state][getCurrentGlyphMode() === 'nerd' ? 0 : 1]; + +const clock = (at: number) => new Date(at).toLocaleTimeString('en-GB', {hour12: false}); +const every = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; +const span = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; + +/** How a completed sample ended, in words: never a healthy result when it was not one. */ +export function runOutcome(run: WatchRun): {text: string; ok: boolean} { + if (run.timedOut) return {text: `timed out after ${span(run.durationMs)}`, ok: false}; + if (run.error) return {text: `could not run: ${run.error}`, ok: false}; + if (run.signal) return {text: `ended by ${run.signal}`, ok: false}; + return run.exitCode === 0 ? {text: 'exit 0', ok: true} : {text: `exit ${run.exitCode}`, ok: false}; +} + +export function trackStatus(watch: WatchTask, now: number, sampling: boolean): string { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const run = watch.current; + const outcome = run && runOutcome(run); + const finished = run ? run.at + run.durationMs : undefined; + const parts: string[] = []; + let mark: string; + let role: keyof typeof UI_COLORS = 'subtle'; + if (watch.status === 'stopped') { mark = trackMark('stopped'); parts.push('stopped'); } + else if (watch.status === 'paused') { mark = trackMark('paused'); parts.push('paused'); } + else if (!run) { mark = trackMark('pending'); parts.push(sampling ? 'first run…' : 'first run pending'); } + else { mark = trackMark('live'); role = outcome!.ok ? 'success' : 'failure'; parts.push('live'); } + parts.push(`every ${every(watch.intervalMs)}`); + if (run && outcome) parts.push(`updated ${clock(finished!)}`, outcome.text); + if (run?.truncated) parts.push('output cut at 200 kB'); + if (watch.status === 'running' && run && now - finished! > watch.intervalMs * 2 + 5000) parts.push(`no new result for ${span(now - finished!)}`); + if (watch.status === 'running' && run && sampling) parts.push('refreshing…'); + parts.push(`${watch.runs} run${watch.runs === 1 ? '' : 's'}`); + return `${paint(role)}${mark}${RESET} ${paint('secondary')}${parts.join(' · ')}${RESET}`; +} + +/** A result's first rows (inert, cut far past any terminal width) and its line count: once per result, not per frame. */ +const resultCache = new WeakMap<WatchRun, {head: string[]; total: number}>(); +function resultLines(run: WatchRun): {head: string[]; total: number} { + let cached = resultCache.get(run); + if (!cached) { + const lines = run.output.replace(/\n$/u, '').split('\n'); + cached = {head: lines.slice(0, MAX_TRACK_ROWS).map(line => displaySafe(line.slice(0, 4096))), total: lines.length}; + resultCache.set(run, cached); + } + return cached; +} + +export function trackBlockRows(watch: WatchTask | undefined, width: number, now: number, reserved: number, sampling = false): {rows: string[]; reserved: number} { + const color = colorLevel() !== 'none'; + const subtle = color ? foreground(UI_COLORS.subtle) : ''; + const fit = (text: string) => truncateText(text, Math.max(1, width - 4)); + if (!watch) return {rows: [` ${subtle}${trackMark('stopped')} not running: trackers stop with the window that started them${RESET}`], reserved: 0}; + const status = truncateAnsi(` ${trackStatus(watch, now, sampling)}`, width); + // Stopped by the person: the final result was written into the transcript; the live rows end here. + if (watch.status === 'stopped') return {rows: [status], reserved: 0}; + const {head, total} = watch.current ? resultLines(watch.current) : {head: [], total: 0}; + const height = Math.max(reserved, Math.min(MAX_TRACK_ROWS, Math.max(1, total))); + const body: string[] = []; + if (!watch.current) body.push(` ${subtle}waiting for the first result…${RESET}`); + else { + const take = total > height ? height - 1 : total; + for (const line of head.slice(0, take)) body.push(` ${fit(line)}`); + if (take < total) body.push(` ${subtle}${fit(`… ${total - take} more lines · /track copy copies them all`)}${RESET}`); + } + while (body.length < height) body.push(''); + return {rows: [status, ...body], reserved: height}; +} + +/** The final result written into the transcript when the person stops a tracker: bounded, plain, said once. */ +export function finalResultRows(watch: WatchTask, maxLines = 200): string[] { + const run = watch.current; + if (!run) return [`Stopped before the first result · ${watch.runs} runs`]; + const lines = run.output.replace(/\n$/u, '').split('\n'); + const kept = lines.slice(0, maxLines).map(line => displaySafe(line)); + return [`Stopped · last result ${clock(run.at + run.durationMs)} · ${runOutcome(run).text} · ${watch.runs} run${watch.runs === 1 ? '' : 's'}`, ...kept, + ...(lines.length > maxLines ? [`… ${lines.length - maxLines} more lines were not kept`] : [])]; +} diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index c10baa2f..a1a73f08 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -8,7 +8,9 @@ export type Key = | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} | {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}; + | 'queueCommand' | 'sendToProgram' + /** Ctrl+Y: redo in NMSh's text editor (the composer ignores it, as it ignored the unmapped byte before). */ + | 'redo'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ @@ -106,6 +108,7 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001B[112;5u', 'suggestPrevious'], // Kitty Ctrl+P ['\u001B[113;5u', 'queueCommand'], // Kitty Ctrl+Q ['\u001B[115;5u', 'sendToProgram'], // Kitty Ctrl+S + ['\u001B[121;5u', 'redo'], // Kitty Ctrl+Y ['\u001B[111;5u', 'toggleDetails'], // Kitty Ctrl+O (lowercase o) ['\u001B[79;5u', 'toggleDetails'], // Kitty Ctrl+O (uppercase O) ]; @@ -180,6 +183,7 @@ export function decodeKeys(input: string): Key[] { 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 === '\u0019') keys.push({kind: 'redo'} as Key); // Ctrl+Y: redo in the NMSh editor 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 ab7ec107..a6e7bba4 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -5,7 +5,7 @@ import {displayWidth} from '../util/text.js'; import {recordCopyText} from '../clipboard/copySelection.js'; import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'view' | 'details' | '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'}, @@ -13,6 +13,8 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {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: 'details', label: 'Show or hide details', detail: 'The failing tests or diagnostics Semantic Output read from this output; the output is unchanged'}, + {id: 'view', label: 'View output', detail: "Full stored output in NMSh's viewer: find, line numbers, wrap; read-only"}, {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'}, @@ -24,10 +26,11 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {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, and references only when the output has some. +export function blockPaletteItems(record: CompletedCommand, options: {details?: boolean} = {}): PaletteItem[] { + // Explain failure is offered only on failed blocks, references only when the output has some, and details only when + // Semantic Output listed failures or diagnostics for this block. 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, + return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences) && (action.id !== 'details' || options.details)).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}})); } diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 035aac0a..4a551602 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -411,6 +411,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ 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})}), + {id: 'semanticOutput', label: 'Semantic Output', description: 'A one-line test or build summary (counts as the tool printed them) and the failures or diagnostics it listed, in recognized blocks. The output itself is unchanged; Off shows raw output only', + category: 'Transcript', control: 'boolean', get: config => config.semanticOutput, set: (config, semanticOutput) => ({...config, semanticOutput})}, enumRow({id: 'updateMode', label: 'Automatic updates', description: 'Automatic prepares verified releases for the next launch (official source installs only); Notify only announces them; Off never checks. /update works on request', category: 'Updates', values: UPDATE_MODES, labels: ['Automatic', 'Notify only', 'Off'], get: config => config.updateMode, set: (config, updateMode) => ({...config, updateMode})}), @@ -679,7 +681,7 @@ export const CONFIG_GROUP_BY_ID: Readonly<Record<string, ConfigGroup>> = { reducedMotion: 'Cursor & Motion', effectsOff: 'Cursor & Motion', shellBackend: 'Shell & Providers', suggestions: 'Shell & Providers', localUnderstanding: 'Local Understanding', - askRecord: 'Transcript & Privacy', askPresentation: 'Transcript & Privacy', outputFolding: 'Transcript & Privacy', + askRecord: 'Transcript & Privacy', askPresentation: 'Transcript & Privacy', outputFolding: 'Transcript & Privacy', semanticOutput: 'Transcript & Privacy', }; export const CONFIG_GROUP_BY_CATEGORY: Readonly<Record<string, ConfigGroup>> = { General: 'General', Updates: 'General', @@ -716,7 +718,7 @@ const CONFIG_ORDER: readonly string[] = [ 'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', 'localUnderstanding', - 'askRecord', 'askPresentation', 'outputFolding', + 'askRecord', 'askPresentation', 'outputFolding', 'semanticOutput', ]; const groupRank = (row: SettingsRow) => CONFIG_GROUPS.indexOf(configGroup(row)); /** Sort key: group, the root's place in CONFIG_ORDER, the root's own position, then the row's (so a child stays under its parent). */ diff --git a/src/util/removeDirectory.ts b/src/util/removeDirectory.ts new file mode 100644 index 00000000..f49454fb --- /dev/null +++ b/src/util/removeDirectory.ts @@ -0,0 +1,50 @@ +import {mkdtempSync, readdirSync, renameSync, rmdirSync, rmSync} from 'node:fs'; +import {basename, dirname, join} from 'node:path'; + +/** + * Remove a private directory that helper processes may still be writing into or removing themselves. `rmSync` alone is + * not enough then: a file created during its walk made it throw ENOTEMPTY, and on Linux a concurrent `rm -rf` made it + * return with the directory still there. Renaming the directory away first (atomic, same filesystem) leaves no path to + * it for anyone to write into, so the path is gone when this returns; the renamed copy is then removed. Never throws. + */ +export function removeDirectoryNow(path: string): void { + let grave: string | undefined; + try { + grave = mkdtempSync(join(dirname(path), `${basename(path)}-gone-`)); + renameSync(path, join(grave, 'directory')); + } catch { /* Already gone, or not movable: removed where it is below. */ } + for (const target of grave ? [grave, path] : [path]) { + if (!removeTree(target)) { + setTimeout(() => { try { rmSync(target, {recursive: true, force: true}); } catch { /* Best effort. */ } }, 100).unref(); + } + } +} + +/** + * Remove a directory tree while another remover may be deleting inside it. A remover that already holds the directory + * open (`rm -rf` works through directory descriptors, so it follows a rename) makes entries vanish mid-walk, and one + * rmSync then stops early without an error. Each pass removes what is listed, tolerating what vanished, and retries + * the directory itself while it is not empty; nothing can add entries (its path is gone), so every pass shrinks it. + */ +function removeTree(target: string): boolean { + for (let pass = 0; pass < 50; pass += 1) { + let names: string[]; + try { names = readdirSync(target); } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return true; + if (code !== 'ENOTDIR') return false; + try { rmSync(target, {force: true}); return true; } catch { return false; } + } + for (const name of names) { + try { rmSync(join(target, name), {recursive: true, force: true}); } catch { /* Vanished or retried next pass. */ } + } + try { rmdirSync(target); return true; } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return true; + if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EBUSY') return false; + } + } + return false; +} diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts new file mode 100644 index 00000000..40b51920 --- /dev/null +++ b/src/viewer/EditorPanel.ts @@ -0,0 +1,288 @@ +import type {Key} from '../terminal/keys.js'; +import {graphemes} from '../input/inputLayout.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {renderActionRow, type ActionControl} from '../ui/actionControls.js'; +import {renderControlRows} from '../ui/controls.js'; +import {editText} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {inert} from './document.js'; +import type {EditableFile, SaveConflict} from './fileEdit.js'; +import {displayColumn, TAB_WIDTH, TextBuffer, type Position} from './textBuffer.js'; + +/** + * The native editor (#402): one small text file in a full-height panel. It owns keys only while open; the composer, + * its draft and the shell are untouched. Saving and reloading are the app's work (they touch the disk); the panel says + * what the person asked for and shows the result. Unsaved changes are never dropped without a choice. + */ + +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const WHEEL_ROWS = 3; + +export interface EditorDialog { + kind: 'unsaved' | 'conflict'; + focus: number; + conflict?: SaveConflict; + reason?: string; + /** Close once the save this dialog leads to succeeds. */ + thenClose?: boolean; +} + +export interface EditorState { + file: EditableFile; + buffer: TextBuffer; + top: number; + left: number; + input?: {kind: 'find'; value: string}; + query?: string; + dialog?: EditorDialog; + note?: {text: string; tone: 'info' | 'error' | 'success'}; + /** A save or reload is in progress; keys wait. */ + busy?: boolean; +} + +export type EditorAction = + | {kind: 'close'} + | {kind: 'save'; force?: boolean; thenClose?: boolean} + | {kind: 'reload'} + | {kind: 'copy'; text: string}; + +export function createEditor(file: EditableFile): EditorState { + return {file, buffer: new TextBuffer(file.lines), top: 0, left: 0, ...(file.identity ? {} : {note: {text: 'New file: it is created when you save.', tone: 'info' as const}})}; +} + +function dialogControls(dialog: EditorDialog): ActionControl[] { + const focused = (index: number, destructive = false) => dialog.focus === index ? (destructive ? 'destructiveFocused' : 'focused') : destructive ? 'destructive' : 'default'; + if (dialog.kind === 'unsaved') return [ + {id: 'Action.Save', label: 'Save', state: focused(0), key: 's', essential: true}, + {id: 'Action.Discard', label: 'Discard changes', state: focused(1, true), key: 'd', essential: true}, + {id: 'Action.Keep', label: 'Keep editing', state: focused(2), key: 'Esc', essential: true}, + ]; + return [ + {id: 'Action.Keep', label: 'Keep editing', state: focused(0), key: 'Esc', essential: true}, + {id: 'Action.Overwrite', label: dialog.conflict === 'deleted' ? 'Save anyway' : 'Overwrite', state: focused(1, true), key: 'o', essential: true}, + ...(dialog.conflict === 'deleted' || dialog.conflict === 'exists' ? [] : [{id: 'Action.Reload', label: 'Reload, discard mine', state: focused(2, true), key: 'r', essential: true} as ActionControl]), + ]; +} + +function dialogKey(state: EditorState, dialog: EditorDialog, key: Key): EditorAction | undefined { + const controls = dialogControls(dialog); + const text = key.kind === 'text' ? key.value.toLowerCase() : undefined; + if (key.kind === 'left' || key.kind === 'focusPrevious') { dialog.focus = (dialog.focus + controls.length - 1) % controls.length; return undefined; } + if (key.kind === 'right' || key.kind === 'complete') { dialog.focus = (dialog.focus + 1) % controls.length; return undefined; } + const chosen = key.kind === 'enter' ? controls[dialog.focus]!.id + : key.kind === 'escape' || key.kind === 'interrupt' ? 'Action.Keep' + : controls.find(control => control.key && control.key.length === 1 && control.key === text)?.id; + if (!chosen) return undefined; + state.dialog = undefined; + if (chosen === 'Action.Keep') return undefined; + if (chosen === 'Action.Save') return {kind: 'save', thenClose: true}; + if (chosen === 'Action.Discard') return {kind: 'close'}; + if (chosen === 'Action.Overwrite') return {kind: 'save', force: true, ...(dialog.thenClose ? {thenClose: true} : {})}; + return {kind: 'reload'}; +} + +/** The rows the text area has (the frame, header, status and footer take the rest). */ +function textRows(state: EditorState, columns: number, height: number): number { + return Math.max(1, height - 2 - 1 - 1 - footerRows(state, columns).length); +} + +function gutter(state: EditorState): number { + return Math.max(3, String(state.buffer.lines.length).length) + 1; +} + +function textWidth(state: EditorState, columns: number): number { + return Math.max(4, columns - 4 - gutter(state)); +} + +/** Keep the caret on screen, with a little room around it. */ +function reveal(state: EditorState, columns: number, height: number): void { + const rows = textRows(state, columns, height); + const width = textWidth(state, columns); + const caret = state.buffer.caret; + if (caret.line < state.top) state.top = caret.line; + else if (caret.line >= state.top + rows) state.top = caret.line - rows + 1; + const column = displayColumn(state.buffer.lines[caret.line]!, caret.column); + const margin = Math.min(8, Math.floor(width / 4)); + if (column < state.left) state.left = Math.max(0, column - margin); + else if (column >= state.left + width) state.left = column - width + 1 + margin; +} + +export function editorKey(state: EditorState, key: Key, columns: number, height: number): EditorAction | undefined { + if (state.busy) return undefined; + if (state.dialog) return dialogKey(state, state.dialog, key); + const buffer = state.buffer; + if (state.input) { + const input = state.input; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.input = undefined; return undefined; } + if (key.kind === 'enter') { + state.input = undefined; + state.query = input.value; + const found = buffer.find(input.value); + state.note = !input.value ? undefined : found ? (found.wrapped ? {text: 'Search wrapped to the top.', tone: 'info'} : undefined) : {text: `No match for “${input.value}”.`, tone: 'info'}; + reveal(state, columns, height); + return undefined; + } + const next = editText(input.value, key); + if (next !== undefined) input.value = next.slice(0, 200); + return undefined; + } + state.note = undefined; + const rows = textRows(state, columns, height); + switch (key.kind) { + case 'escape': + if (buffer.dirty) { state.dialog = {kind: 'unsaved', focus: 0}; return undefined; } + return {kind: 'close'}; + case 'sendToProgram': return {kind: 'save'}; + case 'suspend': if (!buffer.undo()) state.note = {text: 'Nothing to undo.', tone: 'info'}; break; + case 'redo': if (!buffer.redo()) state.note = {text: 'Nothing to redo.', tone: 'info'}; break; + case 'find': state.input = {kind: 'find', value: state.query ?? ''}; return undefined; + case 'interrupt': { + const selected = buffer.selectedText(); + if (selected) return {kind: 'copy', text: selected}; + state.note = {text: 'Esc closes · Ctrl+S saves · select text to copy it.', tone: 'info'}; + return undefined; + } + case 'historyDelete': { + const selected = buffer.selectedText(); + if (!selected) { state.note = {text: 'Select text to cut it.', tone: 'info'}; return undefined; } + buffer.insert(''); + reveal(state, columns, height); + return {kind: 'copy', text: selected}; + } + case 'text': buffer.insert(key.value); break; + case 'paste': buffer.insert(key.value); break; + case 'enter': case 'newline': buffer.insert('\n'); break; + case 'complete': buffer.insert('\t'); break; + case 'backspace': buffer.backspace(); break; + case 'delete': buffer.delete(); break; + case 'deleteWord': buffer.deleteWord(); break; + case 'deleteLineBefore': buffer.deleteLineBefore(); break; + case 'deleteLineAfter': buffer.deleteLineAfter(); break; + case 'left': buffer.left(); break; + case 'right': buffer.right(); break; + case 'up': buffer.vertical(-1); break; + case 'down': buffer.vertical(1); break; + case 'selectLeft': buffer.left(true); break; + case 'selectRight': buffer.right(true); break; + case 'selectUp': buffer.vertical(-1, true); break; + case 'selectDown': buffer.vertical(1, true); break; + case 'wordLeft': buffer.wordLeft(); break; + case 'wordRight': buffer.wordRight(); break; + case 'selectWordLeft': buffer.wordLeft(true); break; + case 'selectWordRight': buffer.wordRight(true); break; + case 'lineHome': buffer.home(); break; + case 'lineEnd': buffer.end(); break; + case 'selectLineHome': buffer.home(true); break; + case 'selectLineEnd': buffer.end(true); break; + case 'bufferHome': buffer.bufferHome(); break; + case 'bufferEnd': case 'latest': buffer.bufferEnd(); break; + case 'selectBufferHome': buffer.bufferHome(true); break; + case 'selectBufferEnd': buffer.bufferEnd(true); break; + case 'selectAll': buffer.selectAll(); break; + case 'pageUp': buffer.vertical(-Math.max(1, rows - 1)); break; + case 'pageDown': buffer.vertical(Math.max(1, rows - 1)); break; + case 'wheelUp': state.top = Math.max(0, state.top - WHEEL_ROWS); return undefined; + case 'wheelDown': state.top = Math.max(0, Math.min(buffer.lines.length - rows, state.top + WHEEL_ROWS)); return undefined; + default: return undefined; + } + reveal(state, columns, height); + return undefined; +} + +// ---- Drawing ------------------------------------------------------------------------------------------------------ + +interface Cell {text: string; width: number; offset: number} + +/** A line's drawn cells from display column 0: tabs as spaces, unsafe characters as marks, each with its offset. */ +function lineCells(line: string, limit: number): Cell[] { + const cells: Cell[] = []; + let column = 0; + let offset = 0; + for (const character of graphemes(line)) { + if (column >= limit) break; + if (character === '\t') { + const spaces = TAB_WIDTH - (column % TAB_WIDTH); + for (let index = 0; index < spaces; index += 1) cells.push({text: ' ', width: 1, offset}); + column += spaces; + } else { + const shown = inert(character); + const width = displayWidth(shown); + cells.push({text: shown, width, offset}); + column += width; + } + offset += character.length; + } + return cells; +} + +const inside = (position: Position, start: Position, end: Position) => + (position.line > start.line || (position.line === start.line && position.column >= start.column)) && (position.line < end.line || (position.line === end.line && position.column < end.column)); + +function drawLine(state: EditorState, index: number, width: number): string { + const line = state.buffer.lines[index]!; + const selection = state.buffer.selection; + const caret = state.buffer.caret; + const cells = lineCells(line, state.left + width); + let out = ''; + let used = 0; + let skipped = 0; + let caretDrawn = caret.line !== index; + for (const cell of cells) { + if (skipped < state.left) { skipped += cell.width; continue; } + if (used + cell.width > width) break; + const position = {line: index, column: cell.offset}; + const atCaret = !caretDrawn && cell.offset === caret.column; + const selected = selection && inside(position, selection.start, selection.end); + out += atCaret || selected ? `${REVERSE}${cell.text}${RESET}` : cell.text; + if (atCaret) caretDrawn = true; + used += cell.width; + } + // The caret at the end of the line (or of the visible part) is a reverse-video space. + if (!caretDrawn && used < width) out += `${REVERSE} ${RESET}`; + return out; +} + +function footerRows(state: EditorState, columns: number): string[] { + const width = Math.max(1, columns - 4); + if (state.dialog) { + const dialog = state.dialog; + const message = dialog.kind === 'unsaved' ? `${state.file.title} has unsaved changes.` : dialog.reason ?? `${state.file.title} changed on disk.`; + const accent = foreground(UI_COLORS.warning); + const actions = renderActionRow(dialogControls(dialog), {columns: width, keys: true, color: colorLevel() !== 'none'}).rows; + return [`${accent}${truncateText(message, width)}${RESET}`, ...actions]; + } + if (state.input) return renderControlRows([['Enter', 'find'], ['Esc', 'cancel']], width); + return renderControlRows([['Ctrl+S', 'save'], ['Esc', 'close'], ['Ctrl+Z', 'undo'], ['Ctrl+Y', 'redo'], ['Ctrl+F', 'find'], ['Ctrl+C', 'copy'], ['Ctrl+X', 'cut']], width); +} + +export function renderEditor(state: EditorState, columns: number, height: number): string[] { + const width = textWidth(state, columns); + const rows = textRows(state, columns, height); + state.top = Math.max(0, Math.min(state.top, Math.max(0, state.buffer.lines.length - 1))); + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const warning = foreground(UI_COLORS.warning); + const dirty = state.buffer.dirty; + const detail = [`${state.buffer.lines.length.toLocaleString('en-US')} lines`, state.file.lineEnding === 'crlf' ? 'CRLF' : '', state.file.bom ? 'BOM' : '', state.file.identity ? '' : 'new file'].filter(Boolean).join(' · '); + const title = truncateText(state.file.title, Math.max(8, Math.floor((columns - 4) * 0.6))); + const out = [` ${primary}${title}${RESET}${dirty ? `${warning} ● edited${RESET}` : ''} ${subtle}${truncateText(detail, Math.max(1, columns - 8 - displayWidth(title) - (dirty ? 9 : 0)))}${RESET}`]; + const number = gutter(state); + for (let offset = 0; offset < rows; offset += 1) { + const index = state.top + offset; + if (index >= state.buffer.lines.length) { out.push(` ${subtle}${'~'.padStart(number - 1)}${RESET}`); continue; } + out.push(` ${subtle}${String(index + 1).padStart(number - 1)}${RESET} ${drawLine(state, index, width)}`); + } + const caret = state.buffer.caret; + const position = `Ln ${caret.line + 1}, Col ${displayColumn(state.buffer.lines[caret.line]!, caret.column) + 1}`; + const selected = state.buffer.selection ? ` · ${state.buffer.selectedText().length.toLocaleString('en-US')} selected` : ''; + const status = state.input + ? `${foreground(UI_COLORS.accent)}Find: ${RESET}${truncateText(inert(state.input.value), Math.max(1, columns - 12))}${foreground(UI_COLORS.accent)}▏${RESET}` + : `${subtle}${truncateText(`${position}${selected}${state.busy ? ' · saving…' : ''}`, Math.max(1, columns - 4))}${RESET}${state.note ? ` ${state.note.tone === 'error' ? foreground(UI_COLORS.failure) : state.note.tone === 'success' ? foreground(UI_COLORS.success) : subtle}${truncateText(inert(state.note.text), Math.max(1, columns - 8 - displayWidth(position + selected)))}${RESET}` : ''}`; + out.push(` ${status}`, ...footerRows(state, columns).map(row => ` ${row}`)); + const rule = framePanel([], columns)[0]!; + if (height <= 1) return [rule].slice(0, Math.max(0, height)); + return [...framePanel(out.slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)), columns), rule]; +} diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts new file mode 100644 index 00000000..5493f428 --- /dev/null +++ b/src/viewer/ViewerPanel.ts @@ -0,0 +1,503 @@ +import type {Key} from '../terminal/keys.js'; +import type {SyntaxSgr} from '../input/syntaxTheme.js'; +import {graphemes} from '../input/inputLayout.js'; +import {codeTokenStyle, renderMarkdown, type MarkdownRow} from '../markdown/render.js'; +import {highlightCode, type CodeToken} from '../markdown/highlight.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {renderControlRows} from '../ui/controls.js'; +import {editText} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {formatBytes, inert, type ViewDocument} from './document.js'; + +/** + * The native read-only viewer (#397): one document in a full-height panel, never the transcript. Drawing is bounded + * to what is visible: only the lines on screen are laid out, one wrapped line takes at most MAX_WRAP_ROWS rows, syntax + * roles cover the first HIGHLIGHT_LINES lines and Markdown renders up to RENDER_MARKDOWN_BYTES. Every character is + * drawn inert: control and format characters (bidi overrides, zero-width marks) are shown as visible marks and tabs + * as spaces, and Markdown links are links only for the renderer's allowed targets. + */ + +const HIGHLIGHT_LINES = 20_000; +const RENDER_MARKDOWN_BYTES = 1024 * 1024; +const MAX_WRAP_ROWS = 200; +const TAB = 4; +const HORIZONTAL_STEP = 8; +const WHEEL_ROWS = 3; +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const BOLD = '\u001b[1m'; + +interface Segment {text: string; style: string} +/** One logical line of what is shown: a source line (segments), or a rendered Markdown row (prestyled). */ +interface Unit {text: string; segments?: Segment[]; prestyled?: string; number?: number; rest?: number} + +export interface ViewerState { + document: ViewDocument; + /** First visible unit, and the wrapped row inside it. */ + top: number; + sub: number; + /** Horizontal offset in cells while not wrapping. */ + left: number; + wrap: boolean; + numbers: boolean; + /** Markdown drawn rendered (true) or as source. */ + rendered: boolean; + input?: {kind: 'search' | 'goto'; value: string}; + query?: string; + match?: {unit: number; start: number; length: number}; + matchCount?: number; + /** One line of feedback (copy result, no match, wrapped search). */ + note?: string; + /** Units the last drawing showed, for the position line. */ + shown?: {first: number; last: number}; + tokens?: CodeToken[][]; + markdown?: {width: number; rows: MarkdownRow[]}; +} + +export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string}; + +export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} + +export function createViewer(document: ViewDocument): ViewerState { + const markdown = document.format === 'markdown' && Buffer.byteLength(document.text) <= RENDER_MARKDOWN_BYTES; + const note = document.format === 'markdown' && !markdown ? 'Too large to render: showing the Markdown source.' : undefined; + return {document, top: 0, sub: 0, left: 0, wrap: true, numbers: !markdown, rendered: markdown, ...(note ? {note} : {})}; +} + +// ---- Text --------------------------------------------------------------------------------------------------------- + +/** Characters drawn as visible marks: controls (not tab) and invisible format, line and paragraph separators. */ +const UNSAFE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu; +const PRINTABLE_ASCII = /^[\t\x20-\x7e]*$/u; +const markOf = (character: string) => character === '\u001b' ? '␛' : '·'; +const cellWidth = (character: string) => character.length === 1 && character >= ' ' && character <= '~' ? 1 : displayWidth(character); + +/** + * A piece of a line as the viewer draws it, from `column`: tabs expanded to the next stop and every unsafe character + * a visible mark. Printable ASCII takes a fast path; other text is measured by grapheme. + */ +function safePiece(text: string, column: number): {text: string; column: number} { + if (PRINTABLE_ASCII.test(text)) { + if (!text.includes('\t')) return {text, column: column + text.length}; + let out = ''; + for (const character of text) { + if (character === '\t') { const spaces = TAB - (column % TAB); out += ' '.repeat(spaces); column += spaces; } + else { out += character; column += 1; } + } + return {text: out, column}; + } + const safe = text.replace(UNSAFE, markOf); + if (!safe.includes('\t')) return {text: safe, column: column + displayWidth(safe)}; + let out = ''; + for (const character of graphemes(safe)) { + if (character === '\t') { const spaces = TAB - (column % TAB); out += ' '.repeat(spaces); column += spaces; } + else { out += character; column += cellWidth(character); } + } + return {text: out, column}; +} + +/** The whole line as drawn: what search matches against, so match offsets are drawing offsets. */ +function safeLine(line: string): string { + return safePiece(line, 0).text; +} + +/** + * Tokens → drawable segments, stopping once `budget` cells are produced: a line is never laid out further than it + * can be shown. `rest` counts the source characters left undrawn. + */ +function safeSegments(tokens: readonly CodeToken[], styleOf: (token: CodeToken) => string, budget: number): {segments: Segment[]; rest: number} { + const segments: Segment[] = []; + let column = 0; + let rest = 0; + for (const token of tokens) { + if (column >= budget) { rest += token.text.length; continue; } + // Enough source for the remaining cells (a cell is at most a few code units; a longer cluster just shows less). + const room = (budget - column) * 4 + 8; + const source = token.text.length > room ? token.text.slice(0, room) : token.text; + rest += token.text.length - source.length; + const piece = safePiece(source, column); + column = piece.column; + if (piece.text) segments.push({text: piece.text, style: styleOf(token)}); + } + return {segments, rest}; +} + +function smartMatch(text: string, query: string): (from: number) => number { + const insensitive = query === query.toLowerCase(); + const haystack = insensitive ? text.toLowerCase() : text; + return from => haystack.indexOf(query, from); +} + +// ---- Units -------------------------------------------------------------------------------------------------------- + +function contentWidth(state: ViewerState, columns: number): number { + return Math.max(4, columns - 4 - gutterWidth(state)); +} + +function gutterWidth(state: ViewerState): number { + return state.numbers && !state.rendered ? Math.max(3, String(state.document.lines.length).length) + 1 : 0; +} + +function markdownRows(state: ViewerState, width: number, look: ViewerLook): MarkdownRow[] { + if (state.markdown?.width !== width) { + state.markdown = {width, rows: renderMarkdown(state.document.text, width, {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)})}; + } + return state.markdown.rows; +} + +function unitCount(state: ViewerState, width: number, look: ViewerLook): number { + return state.rendered ? markdownRows(state, width, look).length : state.document.lines.length; +} + +function lineTokens(state: ViewerState, index: number): CodeToken[] { + const line = state.document.lines[index] ?? ''; + if (!state.document.language) return [{text: line, kind: 'plain'}]; + state.tokens ??= highlightCode(state.document.lines.slice(0, HIGHLIGHT_LINES), state.document.language, HIGHLIGHT_LINES); + return state.tokens[index] ?? [{text: line, kind: 'plain'}]; +} + +/** The cells a unit may need to draw: a bounded wrap, or the visible window when scrolled sideways. */ +function cellBudget(state: ViewerState, width: number): number { + return state.wrap ? MAX_WRAP_ROWS * width : state.left + width + 1; +} + +function unitAt(state: ViewerState, index: number, width: number, look: ViewerLook): Unit { + if (state.rendered) { + const row = markdownRows(state, width, look)[index]; + return {text: row?.plain ?? '', prestyled: row?.ansi ?? ''}; + } + const level = colorLevel(); + const styleOf = (token: CodeToken) => level === 'none' ? '' : codeTokenStyle(token.kind, {...(look.syntax ? {syntax: look.syntax} : {}), level}); + const {segments, rest} = safeSegments(lineTokens(state, index), styleOf, cellBudget(state, width)); + return {text: segments.map(segment => segment.text).join(''), segments, number: index + 1, rest}; +} + +/** The searchable text of a unit (the same characters it is drawn with), without styling work. */ +function unitText(state: ViewerState, index: number, width: number, look: ViewerLook): string { + if (state.rendered) return markdownRows(state, width, look)[index]?.plain ?? ''; + return safeLine(state.document.lines[index] ?? ''); +} + +interface Cell {text: string; width: number; style: string} +type Highlight = {start: number; end: number; current: boolean}; + +function cellsOf(unit: Unit, highlights: readonly Highlight[]): Cell[] { + const cells: Cell[] = []; + let offset = 0; + let range = 0; + for (const segment of unit.segments ?? []) { + for (const character of PRINTABLE_ASCII.test(segment.text) ? segment.text : graphemes(segment.text)) { + while (range < highlights.length && highlights[range]!.end <= offset) range += 1; + const mark = range < highlights.length && offset >= highlights[range]!.start ? highlights[range] : undefined; + cells.push({text: character, width: cellWidth(character), style: mark ? `${segment.style}${REVERSE}${mark.current ? BOLD : ''}` : segment.style}); + offset += character.length; + } + } + return cells; +} + +function paint(cells: readonly Cell[]): string { + let out = ''; + let style: string | undefined; + for (const cell of cells) { + if (cell.style !== style) { out += `${RESET}${cell.style}`; style = cell.style; } + out += cell.text; + } + return `${out}${RESET}`; +} + +/** The rows a unit takes: wrapped (bounded), or one row scrolled by `left`. */ +function unitRows(state: ViewerState, unit: Unit, width: number, highlights: readonly Highlight[]): string[] { + if (unit.prestyled !== undefined) { + return [highlights.length ? `${REVERSE}${truncateAnsi(unit.text, width)}${RESET}` : unit.prestyled]; + } + const cells = cellsOf(unit, highlights); + const subtle = foreground(UI_COLORS.subtle); + const more = Boolean(unit.rest); + if (!state.wrap) { + let skipped = 0; + let index = 0; + while (index < cells.length && skipped + cells[index]!.width <= state.left) { skipped += cells[index]!.width; index += 1; } + const row: Cell[] = []; + let used = 0; + while (index < cells.length && used + cells[index]!.width <= width) { row.push(cells[index]!); used += cells[index]!.width; index += 1; } + if ((index < cells.length || more) && row.length) row[row.length - 1] = {text: '›', width: 1, style: subtle}; + if (state.left > 0 && row.length) row[0] = {text: '‹', width: 1, style: subtle}; + return [paint(row)]; + } + const rows: string[] = []; + let row: Cell[] = []; + let used = 0; + for (let index = 0; index < cells.length; index += 1) { + const cell = cells[index]!; + if (used + cell.width > width && row.length) { + rows.push(paint(row)); + row = []; + used = 0; + if (rows.length === MAX_WRAP_ROWS - 1) { + const rest = cells.length - index + (unit.rest ?? 0); + rows.push(`${subtle}${truncateText(`… ${rest.toLocaleString('en-US')} more characters on this line · w turns wrapping off`, width)}${RESET}`); + return rows; + } + } + row.push(cell); + used += cell.width; + } + rows.push(paint(row)); + return rows; +} + +function rowCount(state: ViewerState, index: number, width: number, look: ViewerLook): number { + if (!state.wrap || state.rendered) return 1; + const line = state.document.lines[index] ?? ''; + if (!line) return 1; + // Printable ASCII wraps by simple division; anything else is laid out, bounded (wide characters move whole to the next row). + if (PRINTABLE_ASCII.test(line)) return Math.min(MAX_WRAP_ROWS, Math.max(1, Math.ceil(safeLine(line).length / width))); + return unitRows(state, unitAt(state, index, width, look), width, []).length; +} + +// ---- Scrolling ---------------------------------------------------------------------------------------------------- + +/** The top position at which the last unit's last row is at the bottom: no scrolling past the end. */ +function lastTop(state: ViewerState, width: number, height: number, look: ViewerLook): {top: number; sub: number} { + let remaining = height; + for (let index = unitCount(state, width, look) - 1; index >= 0; index -= 1) { + const rows = rowCount(state, index, width, look); + if (rows >= remaining) return {top: index, sub: rows - remaining}; + remaining -= rows; + } + return {top: 0, sub: 0}; +} + +function clampTop(state: ViewerState, width: number, height: number, look: ViewerLook): void { + const last = lastTop(state, width, height, look); + if (state.top > last.top || (state.top === last.top && state.sub > last.sub)) { state.top = last.top; state.sub = last.sub; } + state.top = Math.max(0, state.top); + state.sub = Math.max(0, Math.min(state.sub, rowCount(state, state.top, width, look) - 1)); +} + +function scroll(state: ViewerState, delta: number, width: number, height: number, look: ViewerLook): void { + let remaining = delta; + while (remaining > 0) { + const rows = rowCount(state, state.top, width, look); + if (state.sub + remaining < rows) { state.sub += remaining; break; } + remaining -= rows - state.sub; + if (state.top + 1 >= unitCount(state, width, look)) { state.sub = rows - 1; break; } + state.top += 1; + state.sub = 0; + } + while (remaining < 0) { + if (state.sub + remaining >= 0) { state.sub += remaining; break; } + remaining += state.sub + 1; + if (state.top === 0) { state.sub = 0; break; } + state.top -= 1; + state.sub = rowCount(state, state.top, width, look) - 1; + } + clampTop(state, width, height, look); +} + +function reveal(state: ViewerState, unit: number, width: number, height: number, look: ViewerLook): void { + const visible = state.shown && unit >= state.shown.first && unit <= state.shown.last && unit !== state.shown.last; + if (!visible) { state.top = Math.max(0, unit - Math.floor(height / 3)); state.sub = 0; } + clampTop(state, width, height, look); +} + +// ---- Search ------------------------------------------------------------------------------------------------------- + +function findFrom(state: ViewerState, unit: number, offset: number, direction: 1 | -1, width: number, look: ViewerLook): {unit: number; start: number; wrapped: boolean} | undefined { + const query = state.query; + if (!query) return undefined; + const count = unitCount(state, width, look); + for (let step = 0; step <= count; step += 1) { + const raw = unit + direction * step; + const index = ((raw % count) + count) % count; + const text = unitText(state, index, width, look); + const find = smartMatch(text, query); + let found = -1; + if (direction === 1) found = find(step === 0 ? offset : 0); + else { + const limit = step === 0 ? offset - 1 : text.length; + for (let at = find(0); at >= 0 && at <= limit; at = find(at + 1)) found = at; + } + if (found >= 0) return {unit: index, start: found, wrapped: direction === 1 ? raw >= count : raw < 0}; + } + return undefined; +} + +function countMatches(state: ViewerState, width: number, look: ViewerLook): number { + const query = state.query; + if (!query) return 0; + let total = 0; + for (let index = 0; index < unitCount(state, width, look); index += 1) { + const find = smartMatch(unitText(state, index, width, look), query); + for (let at = find(0); at >= 0; at = find(at + query.length)) total += 1; + } + return total; +} + +function step(state: ViewerState, direction: 1 | -1, width: number, height: number, look: ViewerLook): void { + const from = state.match ? {unit: state.match.unit, offset: state.match.start + (direction === 1 ? 1 : 0)} : {unit: state.top, offset: 0}; + const found = findFrom(state, from.unit, from.offset, direction, width, look); + if (!found) { state.match = undefined; state.note = `No match for “${state.query}”.`; return; } + state.match = {unit: found.unit, start: found.start, length: state.query!.length}; + state.note = found.wrapped ? (direction === 1 ? 'Search wrapped to the top.' : 'Search wrapped to the bottom.') : undefined; + reveal(state, found.unit, width, height, look); +} + +// ---- Keys --------------------------------------------------------------------------------------------------------- + +export function viewerKey(state: ViewerState, key: Key, columns: number, height: number, look: ViewerLook = {}): ViewerAction | undefined { + const width = contentWidth(state, columns); + const body = bodyHeight(state, columns, height); + if (state.input) { + const input = state.input; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.input = undefined; return undefined; } + if (key.kind === 'enter') { + state.input = undefined; + if (input.kind === 'goto') { + const line = Number(input.value.trim()); + if (!Number.isInteger(line) || line < 1) { state.note = 'Type a line number.'; return undefined; } + state.top = Math.min(line, unitCount(state, width, look)) - 1; + state.sub = 0; + clampTop(state, width, body, look); + state.note = undefined; + return undefined; + } + state.query = input.value; + state.match = undefined; + if (!state.query) { state.matchCount = undefined; return undefined; } + state.matchCount = countMatches(state, width, look); + step(state, 1, width, body, look); + return undefined; + } + const next = editText(input.value, key); + if (next !== undefined) input.value = next.slice(0, 200); + return undefined; + } + state.note = undefined; + const text = key.kind === 'text' ? key.value : undefined; + if (key.kind === 'escape' && state.query) { state.query = undefined; state.match = undefined; state.matchCount = undefined; return undefined; } + if (key.kind === 'escape' || key.kind === 'interrupt' || text === 'q') return {kind: 'close'}; + if (key.kind === 'up' || text === 'k') scroll(state, -1, width, body, look); + else if (key.kind === 'down' || text === 'j') scroll(state, 1, width, body, look); + else if (key.kind === 'wheelUp') scroll(state, -WHEEL_ROWS, width, body, look); + else if (key.kind === 'wheelDown') scroll(state, WHEEL_ROWS, width, body, look); + else if (key.kind === 'pageUp' || text === 'b') scroll(state, -Math.max(1, body - 1), width, body, look); + else if (key.kind === 'pageDown' || text === ' ' || text === 'f') scroll(state, Math.max(1, body - 1), width, body, look); + else if (key.kind === 'lineHome' || key.kind === 'bufferHome' || text === 'g') { state.top = 0; state.sub = 0; } + else if (key.kind === 'lineEnd' || key.kind === 'bufferEnd' || key.kind === 'latest' || text === 'G') Object.assign(state, lastTop(state, width, body, look)); + else if ((key.kind === 'left' || key.kind === 'right') && !state.wrap && !state.rendered) state.left = Math.max(0, state.left + (key.kind === 'left' ? -HORIZONTAL_STEP : HORIZONTAL_STEP)); + else if (text === 'w' && !state.rendered) { state.wrap = !state.wrap; state.left = 0; state.sub = 0; clampTop(state, width, body, look); } + else if (text === 'l' && !state.rendered) { state.numbers = !state.numbers; state.sub = 0; } + else if (text === 'm' && state.document.format === 'markdown') { + if (!state.rendered && Buffer.byteLength(state.document.text) > RENDER_MARKDOWN_BYTES) state.note = 'Too large to render: showing the Markdown source.'; + else { state.rendered = !state.rendered; state.numbers = !state.rendered; state.top = 0; state.sub = 0; state.match = undefined; state.matchCount = undefined; } + } + else if (text === '/' || key.kind === 'find') state.input = {kind: 'search', value: state.query ?? ''}; + else if (text === ':') state.input = {kind: 'goto', value: ''}; + else if (text === 'n' && state.query) step(state, 1, width, body, look); + else if (text === 'N' && state.query) step(state, -1, width, body, look); + else if (text === 'c') return {kind: 'copy', text: state.document.text, lines: state.document.lines.length}; + else if (text === 'e' && state.document.origin.kind === 'file') return {kind: 'edit', path: state.document.origin.path}; + return undefined; +} + +// ---- Drawing ------------------------------------------------------------------------------------------------------ + +function detail(document: ViewDocument): string { + const lines = `${document.lines.length.toLocaleString('en-US')} line${document.lines.length === 1 ? '' : 's'}`; + if (document.origin.kind === 'output') { + const status = document.origin.exitCode === undefined ? '' : ` · exit ${document.origin.exitCode}`; + return `output ${document.origin.number}${status} · ${lines}${document.format === 'diff' ? ' · diff' : ''}`; + } + const kind = document.format === 'markdown' ? 'Markdown' : document.format === 'diff' ? 'diff' : document.language ?? 'text'; + const ending = document.lineEnding === 'crlf' ? ' · CRLF' : document.lineEnding === 'mixed' ? ' · mixed line endings' : ''; + const link = document.origin.symlink ? ` · → ${inert(document.origin.realPath)}` : ''; + return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; +} + +function headerRows(state: ViewerState, columns: number, height: number): string[] { + const width = Math.max(10, columns - 4); + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const warning = foreground(UI_COLORS.warning); + const title = state.document.origin.kind === 'output' ? `$ ${state.document.title}` : state.document.title; + const shownTitle = truncateText(title.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), Math.max(8, Math.floor(width * 0.6))); + const rows = [` ${primary}${shownTitle}${RESET} ${subtle}${truncateText(detail(state.document), Math.max(1, width - displayWidth(shownTitle) - 2))}${RESET}`]; + const notes = height >= 12 ? state.document.notes.slice(0, 2) : []; + for (const note of notes) rows.push(` ${warning}${truncateText(note, width)}${RESET}`); + return rows; +} + +function controls(state: ViewerState, columns: number): string[] { + const width = Math.max(1, columns - 4); + if (state.input) return renderControlRows([['Enter', state.input.kind === 'search' ? 'find' : 'go'], ['Esc', 'cancel']], width); + return renderControlRows([ + ['↑↓', 'scroll'], ['/', 'find'], ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], + ...(state.document.format === 'markdown' ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), + ...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []), + ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const] : []), ['Esc', 'close'], + ], width); +} + +function bodyHeight(state: ViewerState, columns: number, height: number): number { + return Math.max(1, height - 2 - headerRows(state, columns, height).length - 1 - controls(state, columns).length); +} + +function statusRow(state: ViewerState, width: number, total: number): string { + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + if (state.input) { + const label = state.input.kind === 'search' ? 'Find: ' : 'Go to line: '; + return ` ${accent}${label}${RESET}${truncateText(state.input.value.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), Math.max(1, width - label.length - 1))}${accent}▏${RESET}`; + } + const unit = state.rendered ? 'Row' : 'Line'; + const first = state.shown ? state.shown.first + 1 : 0; + const last = state.shown ? state.shown.last + 1 : 0; + const position = total === 0 ? 'Empty' : `${unit}${last > first ? 's' : ''} ${first.toLocaleString('en-US')}${last > first ? `–${last.toLocaleString('en-US')}` : ''} of ${total.toLocaleString('en-US')}`; + const search = state.query ? ` · ${state.matchCount === undefined ? '' : `${state.matchCount.toLocaleString('en-US')} match${state.matchCount === 1 ? '' : 'es'} for `}“${state.query}”` : ''; + const note = state.note ? ` · ${state.note}` : ''; + return ` ${subtle}${truncateText(`${position}${search}${note}`.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), width)}${RESET}`; +} + +export function renderViewer(state: ViewerState, columns: number, height: number, look: ViewerLook = {}): string[] { + const width = contentWidth(state, columns); + const gutter = gutterWidth(state); + const body = bodyHeight(state, columns, height); + clampTop(state, width, body, look); + const out = headerRows(state, columns, height); + const total = unitCount(state, width, look); + const subtle = foreground(UI_COLORS.subtle); + const rows: string[] = []; + let first = -1; + let last = -1; + for (let index = state.top; index < total && rows.length < body; index += 1) { + const unit = unitAt(state, index, width, look); + const highlights = state.query ? matchRanges(unit.text, state.query, state.match?.unit === index ? state.match.start : -1) : []; + let unitRowsList = unitRows(state, unit, width, highlights); + if (index === state.top) unitRowsList = unitRowsList.slice(state.sub); + unitRowsList.forEach((row, offset) => { + if (rows.length >= body) return; + const number = gutter ? `${subtle}${(offset === 0 && (index !== state.top || state.sub === 0) ? String(unit.number ?? '') : '').padStart(gutter - 1)}${RESET} ` : ''; + rows.push(` ${number}${row}`); + }); + if (first < 0) first = index; + last = index; + } + state.shown = first < 0 ? undefined : {first, last}; + if (!total) rows.push(` ${subtle}${state.document.origin.kind === 'output' ? 'No output.' : 'Empty file.'}${RESET}`); + while (rows.length < body) rows.push(''); + out.push(...rows, statusRow(state, Math.max(1, columns - 4), total), ...controls(state, columns).map(row => ` ${row}`)); + const rule = framePanel([], columns)[0]!; + if (height <= 1) return [rule].slice(0, Math.max(0, height)); + return [...framePanel(out.slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)), columns), rule]; +} + +function matchRanges(text: string, query: string, current: number): Array<{start: number; end: number; current: boolean}> { + const ranges: Array<{start: number; end: number; current: boolean}> = []; + const find = smartMatch(text, query); + for (let at = find(0); at >= 0 && ranges.length < 256; at = find(at + Math.max(1, query.length))) ranges.push({start: at, end: at + query.length, current: at === current}); + return ranges; +} diff --git a/src/viewer/document.ts b/src/viewer/document.ts new file mode 100644 index 00000000..399cc359 --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,185 @@ +import {constants} from 'node:fs'; +import {lstat, open, realpath} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {basename, extname, isAbsolute, join, resolve} from 'node:path'; +import {languageId} from '../markdown/highlight.js'; + +/** + * A document for the native viewer (#397) and editor: lines of text and where they came from. Loading is read-only and + * bounded: only regular files are read (a FIFO or device is refused before anything waits on it), at most + * MAX_VIEW_BYTES and MAX_VIEW_LINES are kept, binary content is refused, and invalid UTF-8 is shown with replacement + * characters and a note. Nothing is executed, followed beyond the file's own symlink, fetched or written. + */ + +export const MAX_VIEW_BYTES = 8 * 1024 * 1024; +export const MAX_VIEW_LINES = 200_000; +/** Bytes inspected for binary content. */ +const SNIFF_BYTES = 8192; +const CHUNK = 64 * 1024; + +export type DocumentFormat = 'text' | 'markdown' | 'diff'; + +export type DocumentOrigin = + | {kind: 'file'; path: string; realPath: string; symlink: boolean; bytes: number; mtimeMs: number} + | {kind: 'output'; command: string; number: number; exitCode?: number}; + +export interface ViewDocument { + /** What the header names: the path as given (home shortened) or the command. */ + title: string; + origin: DocumentOrigin; + lines: string[]; + /** The text copying the whole document gives: the decoded file up to the limits, or the record's output. */ + text: string; + format: DocumentFormat; + /** A grammar id for syntax roles, when the file type is known. */ + language?: string; + /** Limits that applied, said plainly (truncation, encoding). */ + notes: string[]; + lineEnding: 'lf' | 'crlf' | 'mixed' | 'none'; + /** False when the text was cut at a read or line limit. */ + complete: boolean; +} + +export type LoadResult = {ok: true; document: ViewDocument} | {ok: false; reason: string}; + +/** `~` and `~/…` name the home directory; other relative paths are relative to the shell's directory. */ +export function resolveViewPath(input: string, cwd: string, home = homedir()): string { + const trimmed = input.trim(); + if (trimmed === '~') return home; + if (trimmed.startsWith('~/')) return join(home, trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); +} + +/** Text from a file name or path made inert for display: controls and invisible format characters become marks. */ +export function inert(text: string): string { + return text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, character => character === '\u001b' ? '␛' : '·'); +} + +/** The path as people read it (under home it starts with ~), safe to draw: a file name can hold control characters. */ +export function displayPath(path: string, home = homedir()): string { + return inert(home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); +} + +const FILE_NAMES: Record<string, string> = {makefile: 'makefile', gnumakefile: 'makefile', dockerfile: 'dockerfile', containerfile: 'dockerfile', + '.bashrc': 'shell', '.zshrc': 'shell', '.zshenv': 'shell', '.profile': 'shell', '.bash_profile': 'shell', 'config.fish': 'fish', justfile: 'makefile'}; + +/** How a file reads: Markdown and diffs by their extension, syntax roles by extension or well-known name. */ +export function formatForPath(path: string): {format: DocumentFormat; language?: string} { + const name = basename(path).toLowerCase(); + const extension = extname(name).slice(1); + if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return {format: 'markdown', language: 'markdown'}; + if (extension === 'diff' || extension === 'patch') return {format: 'diff', language: 'diff'}; + const language = languageId(FILE_NAMES[name] ?? extension); + return {format: 'text', ...(language ? {language} : {})}; +} + +/** Commands whose output is a unified diff; anything else is plain text (output is never implicitly Markdown). */ +const DIFF_COMMAND = /^\s*(?:git\s+(?:--no-pager\s+)?(?:diff|show|log\s+(?:.*\s)?-p)\b|diff\s+(?:-\S*u|--unified)|hg\s+diff\b|jj\s+diff\b)/u; + +/** A completed command's stored output as a document (the same text `/copy` copies). */ +export function outputDocument(record: {command: string; output: string; exitCode?: number}, number: number): ViewDocument { + const {lines, lineEnding} = splitLines(record.output); + const diff = DIFF_COMMAND.test(record.command) && lines.some(line => line.startsWith('@@')); + return {title: record.command, origin: {kind: 'output', command: record.command, number, ...(record.exitCode !== undefined ? {exitCode: record.exitCode} : {})}, + lines, text: record.output, format: diff ? 'diff' : 'text', ...(diff ? {language: 'diff'} : {}), notes: [], lineEnding, complete: true}; +} + +function splitLines(text: string): {lines: string[]; lineEnding: ViewDocument['lineEnding']} { + if (!text) return {lines: [], lineEnding: 'none'}; + const raw = text.split('\n'); + if (raw.at(-1) === '') raw.pop(); + let crlf = 0; + const terminated = text.endsWith('\n') ? raw.length : raw.length - 1; + const lines = raw.map((line, index) => { + if (line.endsWith('\r')) { if (index < terminated) crlf += 1; return line.slice(0, -1); } + return line; + }); + const lineEnding = terminated === 0 ? 'none' : crlf === 0 ? 'lf' : crlf === terminated ? 'crlf' : 'mixed'; + return {lines, lineEnding}; +} + +function describe(error: unknown, shown: string): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') return `${shown} does not exist.`; + if (code === 'EACCES' || code === 'EPERM') return `${shown} cannot be read: permission denied.`; + if (code === 'ELOOP') return `${shown} is a symlink loop.`; + if (code === 'ENOTDIR') return `${shown}: a part of the path is not a directory.`; + if (code === 'EISDIR') return `${shown} is a directory.`; + if (code === 'ABORT_ERR') return 'Cancelled.'; + return `${shown} cannot be read${code ? ` (${code})` : ''}.`; +} + +/** Read a regular file for viewing (see the module comment for the limits). */ +export async function loadFileDocument(input: string, cwd: string, options: {home?: string; maxBytes?: number; maxLines?: number; signal?: AbortSignal} = {}): Promise<LoadResult> { + const home = options.home ?? homedir(); + const path = resolveViewPath(input, cwd, home); + const shown = displayPath(path, home); + const maxBytes = options.maxBytes ?? MAX_VIEW_BYTES; + let symlink = false; + try { symlink = (await lstat(path)).isSymbolicLink(); } catch (error) { return {ok: false, reason: describe(error, shown)}; } + let handle; + // Non-blocking: opening a FIFO never waits for a writer, and is refused below like any other non-regular file. + try { handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOCTTY ?? 0)); } + catch (error) { return {ok: false, reason: describe(error, shown)}; } + try { + const info = await handle.stat(); + if (info.isDirectory()) return {ok: false, reason: `${shown} is a directory.`}; + if (!info.isFile()) return {ok: false, reason: `${shown} is not a regular file; /view reads files only.`}; + const chunks: Buffer[] = []; + let total = 0; + while (total <= maxBytes) { + if (options.signal?.aborted) return {ok: false, reason: 'Cancelled.'}; + const chunk = Buffer.allocUnsafe(Math.min(CHUNK, maxBytes + 1 - total)); + const {bytesRead} = await handle.read(chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + let bytes = Buffer.concat(chunks, total); + const notes: string[] = []; + let complete = true; + if (bytes.length > maxBytes) { + complete = false; + let cut = maxBytes; + // Never split a UTF-8 sequence: step back over continuation bytes. + while (cut > 0 && (bytes[cut]! & 0xc0) === 0x80) cut -= 1; + bytes = bytes.subarray(0, cut); + notes.push(`Showing the first ${formatBytes(cut)} of ${formatBytes(info.size)}; open it with less or an editor for the rest.`); + } + const sniff = bytes.subarray(0, SNIFF_BYTES); + if ((sniff[0] === 0xff && sniff[1] === 0xfe) || (sniff[0] === 0xfe && sniff[1] === 0xff)) { + return {ok: false, reason: `${shown} is UTF-16 text, which /view does not decode; try less or an editor.`}; + } + if (sniff.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes); /view shows text only.`}; + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) bytes = bytes.subarray(3); + let text: string; + try { text = new TextDecoder('utf-8', {fatal: true}).decode(bytes); } + catch { + text = new TextDecoder('utf-8').decode(bytes); + notes.push('Not valid UTF-8: undecodable bytes are shown as �.'); + } + let {lines, lineEnding} = splitLines(text); + const maxLines = options.maxLines ?? MAX_VIEW_LINES; + if (lines.length > maxLines) { + complete = false; + notes.push(`Showing the first ${maxLines.toLocaleString('en-US')} lines; open it with less or an editor for the rest.`); + lines = lines.slice(0, maxLines); + text = lines.join(lineEnding === 'crlf' ? '\r\n' : '\n'); + } + let realPath = path; + if (symlink) { try { realPath = await realpath(path); } catch { /* the link changed meanwhile; the descriptor read is what is shown */ } } + const {format, language} = formatForPath(path); + return {ok: true, document: {title: shown, origin: {kind: 'file', path, realPath, symlink, bytes: info.size, mtimeMs: info.mtimeMs}, lines, text, format, + ...(language ? {language} : {}), notes, lineEnding, complete}}; + } catch (error) { + return {ok: false, reason: describe(error, shown)}; + } finally { + await handle.close().catch(() => undefined); + } +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} diff --git a/src/viewer/fileEdit.ts b/src/viewer/fileEdit.ts new file mode 100644 index 00000000..e6160361 --- /dev/null +++ b/src/viewer/fileEdit.ts @@ -0,0 +1,205 @@ +import {createHash, randomBytes} from 'node:crypto'; +import {constants} from 'node:fs'; +import {access, link, lstat, open, readFile, realpath, rename, stat, unlink} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {basename, dirname, join} from 'node:path'; +import {displayPath, resolveViewPath} from './document.js'; + +/** + * Opening and saving a small text file for the native editor (#402). Only files that can be saved back exactly and + * safely are opened for editing; anything else is refused with the reason (and stays readable in /view): + * + * - a regular UTF-8 text file of at most MAX_EDIT_BYTES, owned by this user, with one hard link, whose file and + * directory are writable, with one kind of line ending (LF or CRLF); a BOM and the final newline are kept; + * - a path that does not exist yet, in a writable directory, opens empty and is created on save (never over a file + * that appeared meanwhile). + * + * Saving writes a new file beside the original (exclusive create, the original's mode, group when permitted), syncs it, + * checks that the original is still the file that was opened (device, inode, size, time and a content hash) and + * renames it over the original's real path. A changed, replaced or deleted original is a conflict for the person to + * decide; nothing is overwritten silently. Extended attributes and ACLs are not carried over by the rename. + */ + +export const MAX_EDIT_BYTES = 1024 * 1024; +const BOM = Buffer.from([0xef, 0xbb, 0xbf]); + +export interface FileIdentity {dev: number; ino: number; size: number; mtimeMs: number; hash: string} + +export interface EditableFile { + /** The path as given, resolved (it may be a symlink). */ + path: string; + /** The file written: the symlink's target. */ + realPath: string; + title: string; + /** The original's permission bits; undefined for a new file (it gets the default mode under the umask). */ + mode?: number; + gid?: number; + /** Undefined for a file that does not exist yet. */ + identity?: FileIdentity; + lineEnding: 'lf' | 'crlf'; + bom: boolean; + finalNewline: boolean; + lines: string[]; +} + +export type OpenResult = {ok: true; file: EditableFile} | {ok: false; reason: string}; +export type SaveConflict = 'changed' | 'replaced' | 'deleted' | 'exists'; +export type SaveResult = {ok: true; file: EditableFile} | {ok: false; reason: string; conflict?: SaveConflict}; + +const hash = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); +const code = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code; + +function failure(error: unknown, action: string, shown: string): string { + switch (code(error)) { + case 'EACCES': case 'EPERM': return `Could not ${action} ${shown}: permission denied.`; + case 'EROFS': return `Could not ${action} ${shown}: the file system is read-only.`; + case 'ENOSPC': return `Could not ${action} ${shown}: the disk is full.`; + case 'EDQUOT': return `Could not ${action} ${shown}: over the disk quota.`; + case 'ENOENT': return `Could not ${action} ${shown}: it no longer exists.`; + case 'ELOOP': return `Could not ${action} ${shown}: a symlink loop.`; + case 'EISDIR': return `Could not ${action} ${shown}: it is a directory.`; + default: return `Could not ${action} ${shown}${code(error) ? ` (${code(error)})` : ''}.`; + } +} + +async function writable(path: string): Promise<boolean> { + try { await access(path, constants.W_OK); return true; } catch { return false; } +} + +/** Open `input` (relative to `cwd`, `~` for home) for editing, or say why it cannot be edited. */ +export async function openForEdit(input: string, cwd: string, options: {home?: string; maxBytes?: number; uid?: number} = {}): Promise<OpenResult> { + const home = options.home ?? homedir(); + const path = resolveViewPath(input, cwd, home); + const shown = displayPath(path, home); + const uid = options.uid ?? process.getuid?.(); + let realPath: string; + try { realPath = await realpath(path); } + catch (error) { + if (code(error) !== 'ENOENT') return {ok: false, reason: failure(error, 'open', shown)}; + try { await lstat(path); return {ok: false, reason: `${shown} is a symlink to a missing file; edit its target instead.`}; } catch { /* nothing there: a new file */ } + const directory = dirname(path); + let info; + try { info = await stat(directory); } catch { return {ok: false, reason: `${displayPath(directory, home)} does not exist; create the directory first.`}; } + if (!info.isDirectory()) return {ok: false, reason: `${displayPath(directory, home)} is not a directory.`}; + if (!(await writable(directory))) return {ok: false, reason: `${displayPath(directory, home)} is not writable, so ${shown} cannot be created.`}; + // The real path it will have (the folder may be reached through a symlink, as macOS /tmp is): every later save + // checks the file against realpath() of the path as given, which is this once the file exists. + let realDirectory: string; + try { realDirectory = await realpath(directory); } catch (error) { return {ok: false, reason: failure(error, 'open', shown)}; } + return {ok: true, file: {path, realPath: join(realDirectory, basename(path)), title: shown, lineEnding: 'lf', bom: false, finalNewline: true, lines: ['']}}; + } + let handle; + try { handle = await open(realPath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0) | (constants.O_NOCTTY ?? 0)); } + catch (error) { return {ok: false, reason: failure(error, 'open', shown)}; } + try { + const info = await handle.stat(); + if (info.isDirectory()) return {ok: false, reason: `${shown} is a directory.`}; + if (!info.isFile()) return {ok: false, reason: `${shown} is not a regular file.`}; + const maxBytes = options.maxBytes ?? MAX_EDIT_BYTES; + if (info.size > maxBytes) return {ok: false, reason: `${shown} is larger than ${Math.round(maxBytes / 1024)} KiB; the NMSh editor is for small files. Use your editor, or /view to read it.`}; + if (uid !== undefined && info.uid !== uid) return {ok: false, reason: `${shown} belongs to another user; NMSh edits only your own files. /view reads it.`}; + if (info.nlink > 1) return {ok: false, reason: `${shown} has other hard links, which saving by replacement would separate. Use your editor, or /view to read it.`}; + const bytes = await handle.readFile(); + if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) return {ok: false, reason: `${shown} is UTF-16 text; the NMSh editor edits UTF-8 only.`}; + if (bytes.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes).`}; + const bom = bytes.subarray(0, 3).equals(BOM); + let text: string; + try { text = new TextDecoder('utf-8', {fatal: true}).decode(bom ? bytes.subarray(3) : bytes); } + catch { return {ok: false, reason: `${shown} is not valid UTF-8; saving could change bytes it holds. /view reads it.`}; } + const finalNewline = text.endsWith('\n'); + const raw = text.split('\n'); + if (finalNewline) raw.pop(); + const terminated = finalNewline ? raw.length : raw.length - 1; + const crlf = raw.slice(0, terminated).filter(line => line.endsWith('\r')).length; + if (crlf && crlf !== terminated) return {ok: false, reason: `${shown} mixes LF and CRLF line endings; saving would change them. Use your editor, or /view to read it.`}; + const lines = crlf ? raw.map((line, index) => index < terminated ? line.slice(0, -1) : line) : raw; + if (!(await writable(realPath))) return {ok: false, reason: `${shown} is read-only. /view reads it.`}; + if (!(await writable(dirname(realPath)))) return {ok: false, reason: `The folder holding ${shown} is not writable, so it cannot be saved safely. /view reads it.`}; + return {ok: true, file: {path, realPath, title: shown, mode: info.mode & 0o7777, gid: info.gid, + identity: {dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, hash: hash(bytes)}, + lineEnding: crlf ? 'crlf' : 'lf', bom, finalNewline: finalNewline || text === '', lines: lines.length ? lines : ['']}}; + } catch (error) { + return {ok: false, reason: failure(error, 'open', shown)}; + } finally { await handle.close().catch(() => undefined); } +} + +/** The bytes a buffer's lines save as: the file's line ending, BOM and final newline kept. */ +export function encodeLines(file: Pick<EditableFile, 'lineEnding' | 'bom' | 'finalNewline'>, lines: readonly string[]): Buffer { + const eol = file.lineEnding === 'crlf' ? '\r\n' : '\n'; + const body = lines.join(eol) + (file.finalNewline && !(lines.length === 1 && lines[0] === '') ? eol : ''); + const text = Buffer.from(body, 'utf8'); + return file.bom ? Buffer.concat([BOM, text]) : text; +} + +/** Whether the original is still the file that was opened; undefined when it is. */ +async function changedOnDisk(file: EditableFile): Promise<SaveConflict | undefined> { + if (!file.identity) { + try { await lstat(file.realPath); return 'exists'; } catch (error) { return code(error) === 'ENOENT' ? undefined : 'exists'; } + } + try { if (await realpath(file.path) !== file.realPath) return 'replaced'; } + catch (error) { return code(error) === 'ENOENT' ? 'deleted' : 'replaced'; } + let info; + try { info = await lstat(file.realPath); } catch (error) { return code(error) === 'ENOENT' ? 'deleted' : 'replaced'; } + if (!info.isFile() || info.dev !== file.identity.dev || info.ino !== file.identity.ino) return 'replaced'; + if (info.size === file.identity.size && info.mtimeMs === file.identity.mtimeMs) return undefined; + try { return hash(await readFile(file.realPath)) === file.identity.hash ? undefined : 'changed'; } catch { return 'changed'; } +} + +const CONFLICTS: Record<SaveConflict, string> = { + changed: 'changed on disk since you opened it', + replaced: 'was replaced on disk since you opened it', + deleted: 'was deleted since you opened it', + exists: 'was created by something else since you opened it', +}; + +/** + * Save `lines` over the file. Without `force`, a changed original is a conflict and nothing is written. With `force` + * (the person chose Overwrite), the content check is skipped, but only a regular file (or nothing) is ever replaced. + */ +export async function saveFile(file: EditableFile, lines: readonly string[], options: {force?: boolean} = {}): Promise<SaveResult> { + if (!options.force) { + const conflict = await changedOnDisk(file); + if (conflict) return {ok: false, conflict, reason: `${file.title} ${CONFLICTS[conflict]}.`}; + } else { + try { if (!(await lstat(file.realPath)).isFile()) return {ok: false, reason: `${file.title} is no longer a regular file; nothing was written.`}; } + catch (error) { if (code(error) !== 'ENOENT') return {ok: false, reason: failure(error, 'save', file.title)}; } + } + const bytes = encodeLines(file, lines); + const directory = dirname(file.realPath); + const temp = join(directory, `.${basename(file.realPath)}.nmsh-${randomBytes(6).toString('hex')}.tmp`); + let handle; + try { + handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), file.mode === undefined ? 0o666 : 0o600); + await handle.writeFile(bytes); + if (file.mode !== undefined) await handle.chmod(file.mode); + if (file.gid !== undefined) await handle.chown(-1, file.gid).catch(() => undefined); + await handle.sync(); + await handle.close(); + handle = undefined; + // The narrowest window: the original is checked again just before it is replaced. + if (!options.force) { + const conflict = await changedOnDisk(file); + if (conflict) { await unlink(temp).catch(() => undefined); return {ok: false, conflict, reason: `${file.title} ${CONFLICTS[conflict]}.`}; } + } + if (!file.identity && !options.force) { + // A new file is created only if nothing appeared at its path meanwhile: link fails rather than replace. + try { await link(temp, file.realPath); } + catch (error) { + await unlink(temp).catch(() => undefined); + if (code(error) === 'EEXIST') return {ok: false, conflict: 'exists', reason: `${file.title} ${CONFLICTS.exists}.`}; + throw error; + } + await unlink(temp).catch(() => undefined); + } else await rename(temp, file.realPath); + } catch (error) { + await handle?.close().catch(() => undefined); + await unlink(temp).catch(() => undefined); + return {ok: false, reason: failure(error, 'save', file.title)}; + } + // The directory entry itself is durable too, where the platform allows syncing a directory. + try { const dir = await open(directory, constants.O_RDONLY); try { await dir.sync(); } finally { await dir.close(); } } catch { /* best effort */ } + try { + const info = await lstat(file.realPath); + return {ok: true, file: {...file, lines: [...lines], identity: {dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, hash: hash(bytes)}}}; + } catch (error) { return {ok: false, reason: failure(error, 'check the saved', file.title)}; } +} diff --git a/src/viewer/textBuffer.ts b/src/viewer/textBuffer.ts new file mode 100644 index 00000000..6f81b389 --- /dev/null +++ b/src/viewer/textBuffer.ts @@ -0,0 +1,311 @@ +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth} from '../util/text.js'; + +/** + * The text of a small file being edited (#402): lines, a caret, an optional selection anchor and bounded undo/redo. + * Positions are (line, UTF-16 offset) and always sit on grapheme boundaries. Every change goes through one + * replaceRange, which records its inverse; typing and deleting coalesce into one undo step per burst. Movement mirrors + * the composer's rules (words are separated by spaces and tabs) over lines instead of one buffer. + */ + +export interface Position {line: number; column: number} + +interface Edit { + from: Position; + removed: string; + inserted: string; + caretBefore: Position; + anchorBefore?: Position; + caretAfter: Position; + kind: 'type' | 'delete' | 'other'; + at: number; +} + +const MAX_UNDO = 500; +const UNREACHABLE: unique symbol = Symbol('unreachable saved state'); +const COALESCE_MS = 1000; +export const TAB_WIDTH = 4; + +const same = (a: Position, b: Position) => a.line === b.line && a.column === b.column; +const before = (a: Position, b: Position) => a.line < b.line || (a.line === b.line && a.column < b.column); +const isSpace = (character: string | undefined) => character === ' ' || character === '\t'; + +/** The display column of `column` in `line`, with tabs to the next stop and wide characters counted. */ +export function displayColumn(line: string, column: number): number { + let cells = 0; + let offset = 0; + for (const character of graphemes(line)) { + if (offset >= column) break; + cells += character === '\t' ? TAB_WIDTH - (cells % TAB_WIDTH) : displayWidth(character); + offset += character.length; + } + return cells; +} + +/** The offset in `line` whose display column is nearest to `target` without passing it. */ +function columnAt(line: string, target: number): number { + let cells = 0; + let offset = 0; + for (const character of graphemes(line)) { + const width = character === '\t' ? TAB_WIDTH - (cells % TAB_WIDTH) : displayWidth(character); + if (cells + width > target) break; + cells += width; + offset += character.length; + } + return offset; +} + +export class TextBuffer { + lines: string[]; + caret: Position = {line: 0, column: 0}; + anchor?: Position; + private preferred?: number; + private readonly undoStack: Edit[] = []; + private readonly redoStack: Edit[] = []; + /** + * The undo step the saved text ends with (undefined: the text as opened). The buffer is clean exactly when the + * history's top is that step, so undoing back to the saved text reads as clean again. A saved state pushed out of + * the bounded history can never be reached again and is marked unreachable. + */ + private savedTop: Edit | undefined | typeof UNREACHABLE = undefined; + + constructor(lines: readonly string[], private readonly now: () => number = Date.now) { + this.lines = lines.length ? [...lines] : ['']; + } + + get text(): string { return this.lines.join('\n'); } + get dirty(): boolean { return this.undoStack.at(-1) !== this.savedTop; } + get canUndo(): boolean { return this.undoStack.length > 0; } + get canRedo(): boolean { return this.redoStack.length > 0; } + + markSaved(): void { this.savedTop = this.undoStack.at(-1); } + + get selection(): {start: Position; end: Position} | undefined { + if (!this.anchor || same(this.anchor, this.caret)) return undefined; + return before(this.anchor, this.caret) ? {start: this.anchor, end: this.caret} : {start: this.caret, end: this.anchor}; + } + + selectedText(): string { + const selection = this.selection; + return selection ? this.slice(selection.start, selection.end) : ''; + } + + private slice(from: Position, to: Position): string { + if (from.line === to.line) return this.lines[from.line]!.slice(from.column, to.column); + return [this.lines[from.line]!.slice(from.column), ...this.lines.slice(from.line + 1, to.line), this.lines[to.line]!.slice(0, to.column)].join('\n'); + } + + /** Replace [from, to) with text; records the inverse for undo. */ + private replaceRange(from: Position, to: Position, text: string, kind: Edit['kind'], record = true): void { + const removed = this.slice(from, to); + const head = this.lines[from.line]!.slice(0, from.column); + const tail = this.lines[to.line]!.slice(to.column); + const parts = text.split('\n'); + const caretAfter = {line: from.line + parts.length - 1, column: (parts.length === 1 ? head.length : 0) + parts.at(-1)!.length}; + parts[0] = head + parts[0]!; + parts[parts.length - 1] = parts.at(-1)! + tail; + const caretBefore = this.caret; + const anchorBefore = this.anchor; + this.lines.splice(from.line, to.line - from.line + 1, ...parts); + this.caret = caretAfter; + this.anchor = undefined; + this.preferred = undefined; + if (!record) return; + this.redoStack.length = 0; + const previous = this.undoStack.at(-1); + const at = this.now(); + // A burst of typing (or of deleting) is one undo step: same kind, continuing where the last one ended, no line break. + // The saved step is never extended: what the file holds must stay one exact step of the history. + if (previous && previous !== this.savedTop && kind !== 'other' && previous.kind === kind && at - previous.at < COALESCE_MS && !text.includes('\n') && !removed.includes('\n')) { + if (kind === 'type' && !removed && same(previous.caretAfter, from)) { + previous.inserted += text; previous.caretAfter = caretAfter; previous.at = at; return; + } + if (kind === 'delete' && !text && same(previous.from, to) && !previous.inserted) { + previous.from = from; previous.removed = removed + previous.removed; previous.caretAfter = caretAfter; previous.at = at; return; + } + } + this.undoStack.push({from, removed, inserted: text, caretBefore, ...(anchorBefore ? {anchorBefore} : {}), caretAfter, kind, at}); + if (this.undoStack.length > MAX_UNDO) { + const dropped = this.undoStack.shift(); + // The saved text was the state before (or at) the dropped step: undo can no longer return to it. + if (this.savedTop === undefined || this.savedTop === dropped) this.savedTop = UNREACHABLE; + } + } + + private endOf(from: Position, text: string): Position { + const parts = text.split('\n'); + return {line: from.line + parts.length - 1, column: (parts.length === 1 ? from.column : 0) + parts.at(-1)!.length}; + } + + undo(): boolean { + const edit = this.undoStack.pop(); + if (!edit) return false; + this.replaceRange(edit.from, this.endOf(edit.from, edit.inserted), edit.removed, 'other', false); + this.caret = edit.caretBefore; + this.anchor = edit.anchorBefore; + this.redoStack.push(edit); + return true; + } + + redo(): boolean { + const edit = this.redoStack.pop(); + if (!edit) return false; + this.replaceRange(edit.from, this.endOf(edit.from, edit.removed), edit.inserted, 'other', false); + this.caret = edit.caretAfter; + this.undoStack.push(edit); + return true; + } + + // ---- Editing ----------------------------------------------------------------------------------------------------- + + /** Typed or pasted text: CR LF and CR become line breaks, tabs stay, other control characters are dropped. */ + insert(value: string): void { + const text = value.replace(/\r\n?/gu, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, ''); + if (!text && !this.selection) return; + const selection = this.selection; + this.replaceRange(selection?.start ?? this.caret, selection?.end ?? this.caret, text, selection ? 'other' : 'type'); + } + + private deleteSelection(): boolean { + const selection = this.selection; + if (!selection) { this.anchor = undefined; return false; } + this.replaceRange(selection.start, selection.end, '', 'other'); + return true; + } + + backspace(): void { + if (this.deleteSelection()) return; + const from = this.stepLeft(this.caret); + if (!same(from, this.caret)) this.replaceRange(from, this.caret, '', 'delete'); + } + + delete(): void { + if (this.deleteSelection()) return; + const to = this.stepRight(this.caret); + if (!same(to, this.caret)) this.replaceRange(this.caret, to, '', 'other'); + } + + deleteWord(): void { + if (this.deleteSelection()) return; + const from = this.wordStart(this.caret); + if (!same(from, this.caret)) this.replaceRange(from, this.caret, '', 'other'); + } + + deleteLineBefore(): void { + if (this.deleteSelection()) return; + if (this.caret.column > 0) this.replaceRange({line: this.caret.line, column: 0}, this.caret, '', 'other'); + } + + deleteLineAfter(): void { + if (this.deleteSelection()) return; + const length = this.lines[this.caret.line]!.length; + if (this.caret.column < length) this.replaceRange(this.caret, {line: this.caret.line, column: length}, '', 'other'); + } + + // ---- Movement ---------------------------------------------------------------------------------------------------- + + private stepLeft(position: Position): Position { + if (position.column === 0) return position.line === 0 ? position : {line: position.line - 1, column: this.lines[position.line - 1]!.length}; + const characters = graphemes(this.lines[position.line]!.slice(0, position.column)); + return {line: position.line, column: position.column - characters.at(-1)!.length}; + } + + private stepRight(position: Position): Position { + const line = this.lines[position.line]!; + if (position.column >= line.length) return position.line + 1 >= this.lines.length ? position : {line: position.line + 1, column: 0}; + return {line: position.line, column: position.column + graphemes(line.slice(position.column))[0]!.length}; + } + + private wordStart(position: Position): Position { + if (position.column === 0) return this.stepLeft(position); + const line = this.lines[position.line]!; + let column = position.column; + while (column > 0 && isSpace(line[column - 1])) column -= 1; + while (column > 0 && !isSpace(line[column - 1])) column -= 1; + return {line: position.line, column}; + } + + private wordEnd(position: Position): Position { + const line = this.lines[position.line]!; + if (position.column >= line.length) return this.stepRight(position); + let column = position.column; + while (column < line.length && !isSpace(line[column])) column += 1; + while (column < line.length && isSpace(line[column])) column += 1; + return {line: position.line, column}; + } + + private move(to: Position, select: boolean, keepPreferred = false): void { + if (select) this.anchor ??= this.caret; + else this.anchor = undefined; + this.caret = to; + if (!keepPreferred) this.preferred = undefined; + } + + /** Without Shift, Left/Right first collapse a selection to its edge, as the composer does. */ + left(select = false): void { + const selection = this.selection; + if (!select && selection) { this.move(selection.start, false); return; } + this.move(this.stepLeft(this.caret), select); + } + + right(select = false): void { + const selection = this.selection; + if (!select && selection) { this.move(selection.end, false); return; } + this.move(this.stepRight(this.caret), select); + } + + wordLeft(select = false): void { this.move(this.wordStart(this.caret), select); } + wordRight(select = false): void { this.move(this.wordEnd(this.caret), select); } + home(select = false): void { this.move({line: this.caret.line, column: 0}, select); } + end(select = false): void { this.move({line: this.caret.line, column: this.lines[this.caret.line]!.length}, select); } + bufferHome(select = false): void { this.move({line: 0, column: 0}, select); } + bufferEnd(select = false): void { this.move({line: this.lines.length - 1, column: this.lines.at(-1)!.length}, select); } + + selectAll(): void { + this.anchor = {line: 0, column: 0}; + this.caret = {line: this.lines.length - 1, column: this.lines.at(-1)!.length}; + } + + /** Up/Down keep the display column the caret started from across shorter lines. */ + vertical(delta: number, select = false): void { + const target = Math.max(0, Math.min(this.lines.length - 1, this.caret.line + delta)); + if (target === this.caret.line) { this.move(delta < 0 ? {line: target, column: 0} : {line: target, column: this.lines[target]!.length}, select); return; } + this.preferred ??= displayColumn(this.lines[this.caret.line]!, this.caret.column); + this.move({line: target, column: columnAt(this.lines[target]!, this.preferred)}, select, true); + } + + setCaret(position: Position): void { + const line = Math.max(0, Math.min(this.lines.length - 1, position.line)); + this.move({line, column: Math.max(0, Math.min(this.lines[line]!.length, position.column))}, false); + } + + // ---- Search ------------------------------------------------------------------------------------------------------ + + /** The next match after the caret (or the previous one before it), wrapping; smart case. Selects it. */ + find(query: string, direction: 1 | -1 = 1): {wrapped: boolean} | undefined { + if (!query) return undefined; + const insensitive = query === query.toLowerCase(); + const fold = (text: string) => insensitive ? text.toLowerCase() : text; + const needle = fold(query); + const start = direction === 1 ? (this.selection?.end ?? this.caret) : (this.selection?.start ?? this.caret); + const count = this.lines.length; + for (let step = 0; step <= count; step += 1) { + const raw = start.line + direction * step; + const index = ((raw % count) + count) % count; + const line = fold(this.lines[index]!); + let found = -1; + if (direction === 1) found = line.indexOf(needle, step === 0 ? start.column : 0); + else { + const limit = step === 0 ? start.column - needle.length : line.length; + if (limit >= 0) found = line.lastIndexOf(needle, limit); + } + if (found >= 0 && (step > 0 || direction === -1 || found >= start.column)) { + this.anchor = {line: index, column: found}; + this.caret = {line: index, column: found + query.length}; + this.preferred = undefined; + return {wrapped: direction === 1 ? raw >= count : raw < 0}; + } + } + return undefined; + } +} diff --git a/tests/ansiLiveRegion.test.ts b/tests/ansiLiveRegion.test.ts new file mode 100644 index 00000000..64c267d9 --- /dev/null +++ b/tests/ansiLiveRegion.test.ts @@ -0,0 +1,260 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {AnsiOutputParser, plainLine} from '../src/output/AnsiOutputParser.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {ShellSession} from '../src/shell/ShellSession.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * #388: programs that redraw several of their own rows in place (multi-line progress) update those rows instead of + * leaving a copy per frame, and can never reach rows outside their live region. + */ +const plain = (parser: AnsiOutputParser) => parser.allLines().map(line => plainLine(line)); +const ESC = '\u001b'; +const up = (rows: number) => `${ESC}[${rows}A`; +const clearLine = `${ESC}[2K`; + +test('the #388 fixture: a one-row redraw with cursor-up and erase-line leaves one line, not one per frame', () => { + const parser = new AnsiOutputParser(); + for (let step = 0; step <= 10; step += 1) parser.write(`${step ? `${up(1)}${clearLine}` : ''}Downloading ${step * 10}%\r\n`); + assert.deepEqual(plain(parser), ['Downloading 100%']); + // Carriage-return rewriting, the existing control, still works. + parser.write('10%\r20%\r100%\r\n'); + assert.deepEqual(plain(parser), ['Downloading 100%', '100%']); +}); + +test('a two-row progress display interleaved with log lines keeps every log line and only the final progress rows', () => { + const parser = new AnsiOutputParser(); + const frame = (a: number, b: number) => `${clearLine}layer a ${a}%\r\n${clearLine}layer b ${b}%\r\n`; + parser.write(frame(0, 0)); + for (let step = 1; step <= 5; step += 1) { + if (step === 3) parser.write(`${up(2)}${ESC}[Jpulled base image\r\n`); + else parser.write(up(2)); + parser.write(frame(step * 20, step * 10)); + } + assert.deepEqual(plain(parser), ['pulled base image', 'layer a 100%', 'layer b 50%']); +}); + +test('the cursor never leaves the live region: NMSh lines, earlier commands and rows beyond a screen height stay untouched', () => { + const parser = new AnsiOutputParser(); + parser.addLine('$ previous'); + parser.write('previous output\r\n'); + parser.addLine('$ current'); + parser.write('own 1\r\nown 2\r\n'); + parser.write(`${up(50)}X`); + assert.deepEqual(plain(parser), ['$ previous', 'previous output', '$ current', 'Xwn 1', 'own 2'], 'clamped at the first own row'); + + const tall = new AnsiOutputParser(); + tall.setScreenRows(3); + for (let line = 0; line < 10; line += 1) tall.write(`line ${line}\r\n`); + tall.write(`${up(9)}X\r\n`); + assert.deepEqual(plain(tall).slice(6), ['line 6', 'line 7', 'Xine 8', 'line 9'], 'no further up than a screen height'); +}); + +test('an NMSh line written mid-redraw fences the rows above it: later frames continue below it', () => { + const parser = new AnsiOutputParser(); + parser.write('progress 1\r\n'); + parser.addLine('· copied'); + for (let step = 2; step <= 4; step += 1) parser.write(`${up(1)}${clearLine}progress ${step}\r\n`); + assert.deepEqual(plain(parser), ['progress 1', '· copied', 'progress 4']); +}); + +test('save/restore, next/previous line, reverse index and partial erases act within the region', () => { + const parser = new AnsiOutputParser(); + parser.write(`${ESC}7first\r\nsecond\r\n${ESC}8${ESC}[1mF${ESC}[m\r\n`); + assert.deepEqual(plain(parser), ['First', 'second']); + parser.write(`${ESC}[2Fx${ESC}[1Ey\r\n`); + assert.deepEqual(plain(parser), ['xirst', 'yecond']); + parser.write(`${ESC}Mz`); + assert.deepEqual(plain(parser), ['xirst', 'zecond']); + parser.write(`${ESC}[s${up(1)}${ESC}[3G${ESC}[1K${ESC}[u!`); + assert.deepEqual(plain(parser), [' st', 'z!cond']); + parser.write(`${up(1)}\r${ESC}[J`); + assert.deepEqual(plain(parser), [], 'erase below removes the program\'s rows under the cursor'); +}); + +test('sequences split across reads still redraw in place', () => { + const parser = new AnsiOutputParser(); + parser.write('one\r\n'); + for (const chunk of [`${ESC}`, '[', '1', 'A', `${ESC}[2`, 'Ktwo\r', '\n']) parser.write(chunk); + assert.deepEqual(plain(parser), ['two']); +}); + +test('absolute row positioning is not mapped: it appends as before instead of guessing which row is meant', () => { + const parser = new AnsiOutputParser(); + parser.write('a\r\nb\r\n'); + parser.write(`${ESC}[1;1Hc\r\n`); + assert.deepEqual(plain(parser), ['a', 'b', 'c']); +}); + +/** An indicatif-style display: it moves up by the terminal rows it drew (it measures wrapping), erases below, redraws. */ +function wrappedProgress(columns: number, frames = 10): string { + const bar = (n: number) => `Downloading big-archive-name.tar.gz [${'='.repeat(n)}${' '.repeat(frames - n)}] ${n * 10}%`; + const rowsOf = (text: string) => Math.max(1, Math.ceil(text.length / columns)); + let out = 'LOG kept line one\r\nLOG kept line two\r\n'; + let drawn = 0; + for (let n = 0; n <= frames; n += 1) { + if (drawn) out += `\r${up(drawn)}${ESC}[J`; + const frame = [bar(n), `eta ${frames - n}s`]; + out += `${frame.join('\r\n')}\r\n`; + drawn = frame.reduce((sum, line) => sum + rowsOf(line), 0); + } + return `${out}LOG done\r\n`; +} + +test('rows are the terminal\'s rows: a display that moves over its own wrapped line never erases earlier output', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(40); + parser.write(wrappedProgress(40)); + assert.deepEqual(plain(parser), ['LOG kept line one', 'LOG kept line two', 'Downloading big-archive-name.tar.gz [==========] 100%', 'eta 0s', 'LOG done']); + // The same bytes at a width where nothing wraps behave the same. + const wide = new AnsiOutputParser(); + wide.setScreenRows(24); + wide.setScreenColumns(120); + wide.write(wrappedProgress(120)); + assert.deepEqual(plain(wide), plain(parser)); +}); + +test('on a wrapped line, carriage return, erase-line and line feed act on the cursor\'s row, as on the screen', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(10); + parser.write('0123456789abcdefghij'); // two rows, cursor at the end (pending wrap) + parser.write('\rABC'); // back to the start of the second row + assert.deepEqual(plain(parser), ['0123456789ABCdefghij']); + parser.write(`${up(1)}${ESC}[2K`); // up to the first row and clear that row only + assert.deepEqual(plain(parser), [' ABCdefghij']); + parser.write('\r\nxy'); // line feed from the first row goes to the line's second row + assert.deepEqual(plain(parser), [' xyCdefghij']); + parser.write(`\r${ESC}[K`); // erase to the end of the second row: the row stays, blank + parser.write(`${up(1)}first\r\nnext\r\n`); + // The second row is still the same (wrapped) line, as on the screen: drawn at 10 columns it reads "first" / "next". + assert.deepEqual(plain(parser), ['first next']); +}); + +test('a resize re-wraps: the rows a program counts at the new width are the ones it reaches', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(40); + parser.write('kept\r\n'); + parser.write(`${'x'.repeat(30)}\r\n`); + parser.setScreenColumns(20); // the 30-column line now takes two rows + parser.write(`${up(2)}${ESC}[J${'y'.repeat(5)}\r\n`); + assert.deepEqual(plain(parser), ['kept', 'yyyyy']); +}); + +test('a status row below a scroll region the program set is not stored; NMSh lines and log rows are', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(80); + const status = (percent: number) => `${ESC}7${ESC}[24;0f${ESC}[42mProgress: [${percent}%]${ESC}[0m${ESC}8`; + parser.write(`\r\n${ESC}7${ESC}[0;23r${ESC}8${up(1)}`); + for (let step = 1; step <= 3; step += 1) parser.write(`Unpacking package-${step}\r\n${status(step * 30)}`); + parser.addLine('NMSh note while the status row is reserved'); + parser.write(`Setting up package-3\r\n${ESC}7${ESC}[0;24r${ESC}8${ESC}[J`); + const lines = plain(parser); + assert.equal(lines.some(line => /Progress/u.test(line)), false, 'the status row never enters the transcript'); + assert.deepEqual(lines.filter(line => line), ['Unpacking package-1', 'Unpacking package-2', 'Unpacking package-3', 'NMSh note while the status row is reserved', 'Setting up package-3']); + // Without a region, an absolute move is not mapped: the text stays where the cursor is. + const plainParser = new AnsiOutputParser(); + plainParser.write(`a\r\n${ESC}[5;1Hb\r\n`); + assert.deepEqual(plain(plainParser), ['a', 'b']); +}); + +test('a resumed command keeps redrawing its own rows, and a command-line recolor never moves its cursor', () => { + const buffer = new OutputBuffer(); + const startId = buffer.beginCommand('pull', ['$ pull']); + buffer.write('layer 1%\r\n'); + buffer.updateCommandHighlight(startId, ['$ pull']); + buffer.write(`${up(1)}${clearLine}layer 50%\r\n`); + const transcript = buffer.transcript(); + const restored = new OutputBuffer(); + restored.restoreTranscript(transcript); + restored.resumeActive('pull', startId, startId + 1); + restored.write(`${up(1)}${clearLine}layer 100%\r\n`); + const record = restored.complete(0)!; + assert.equal(record.output, 'layer 100%'); +}); + +test('ten thousand frames of a three-row display stay three rows and parse in bounded time', () => { + const buffer = new OutputBuffer(); + buffer.beginCommand('build', ['$ build']); + const started = performance.now(); + for (let step = 0; step < 10_000; step += 1) { + buffer.write(`${step ? up(3) : ''}${clearLine}a ${step}\r\n${clearLine}b ${step}\r\n${clearLine}c ${step}\r\n`); + } + const elapsed = performance.now() - started; + const record = buffer.complete(0)!; + assert.equal(record.output, 'a 9999\nb 9999\nc 9999'); + assert.equal(record.endId - record.outputStartId, 3); + assert.ok(elapsed < 5000, `took ${Math.round(elapsed)}ms`); +}); + +test('a real PTY at 40 columns: a display that counts its wrapped rows keeps the log lines above it', {timeout: 60_000}, async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-wrapped-'))); + const shell = new ShellSession(home, 40, 30, home, {...process.env, HOME: home}); + let output = ''; + try { + await once(shell, 'prompt'); + shell.on('data', data => { output += data; }); + const done = once(shell, 'prompt'); + // The program measures its rows with the PTY's own width, as progress libraries do. + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + shell.submit(`${JSON.stringify(process.execPath)} -e '${script}'`); + await done; + const parser = new AnsiOutputParser(); + parser.setScreenRows(30); + parser.setScreenColumns(40); + parser.write(output); + const lines = plain(parser); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two') && lines.includes('LOG done'), lines.join('\n')); + assert.deepEqual(lines.filter(line => line.startsWith('Downloading')), ['Downloading big-archive-name.tar.gz [==========]']); + } finally { + shell.kill(); + rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +}); + +test('a real PTY: the #388 fixture run by zsh leaves one progress line', {timeout: 60_000}, async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-redraw-'))); + const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home}); + let output = ''; + try { + await once(shell, 'prompt'); + shell.on('data', data => { output += data; }); + const done = once(shell, 'prompt'); + const script = 'for (let i=0;i<=10;i++){process.stdout.write((i?"\\x1b[1A\\x1b[2K":"")+"Downloading "+i*10+"%\\n")}'; + shell.submit(`${JSON.stringify(process.execPath)} -e '${script}'`); + await done; + const parser = new AnsiOutputParser(); + parser.setScreenRows(30); + parser.write(output); + assert.deepEqual(plain(parser).filter(line => line.includes('Downloading')), ['Downloading 100%']); + } finally { + shell.kill(); + rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +}); + +test('live: through the real frontend at 40 columns, the wrapped display redraws in place and the log lines stay in the transcript', {timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 40, rows: 30}); + try { + await frontend.waitFor(/❯/u); + await frontend.run('echo READY', /READY/u); + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + await frontend.run(`${JSON.stringify(process.execPath)} -e '${script}'`, /LOG done/u); + await until(async () => (await sandbox.transcripts().list()).some(session => JSON.stringify(session.transcript).includes('LOG done')), 15_000, 'the journal'); + const sessions = await sandbox.transcripts().list(); + const lines = sessions.flatMap(session => (session.transcript.lines as Array<Array<{text?: string} | null>>).map(line => line.map(cell => cell?.text ?? ' ').join('').trimEnd())); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two'), lines.join('\n')); + assert.equal(lines.filter(line => line.startsWith('Downloading')).length, 1, 'one final progress row, not one per frame'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/backgroundSupervisor.test.ts b/tests/backgroundSupervisor.test.ts index dbcd13c1..a61c30c9 100644 --- a/tests/backgroundSupervisor.test.ts +++ b/tests/backgroundSupervisor.test.ts @@ -109,6 +109,18 @@ test('a pipeline stops as a whole; a detached helper it started (like the adb se }); }); +test('a task that leaves nothing behind is never reported as having processes left, even for a moment; it is removable once exited', async () => { + await withSupervisor(async (supervisor, cwd, changes) => { + for (let run = 0; run < 5; run += 1) { + const {task} = supervisor.start({command: `printf 'done ${run}\\n'`, cwd, shell: SHELL, env: env()}) as {ok: true; task: TaskRecord}; + await waitFor(() => supervisor.get(task.id)!.state === 'exited', 'exited'); + assert.deepEqual(changes.filter(change => change.id === task.id && change.leftovers).map(change => change.state), [], + 'the guard still being alive is not a process left behind'); + assert.equal(supervisor.remove(task.id), true, 'exited with nothing left: removable at once'); + } + }); +}); + test('processes left in the group after the leader exits are reported, and killed only on request', async () => { await withSupervisor(async (supervisor, cwd) => { const {task} = supervisor.start({command: afterSleeperReady(cwd, '60000', 'sleep 1; exit 0'), cwd, shell: SHELL, env: env()}) as {ok: true; task: TaskRecord}; diff --git a/tests/blockActions.test.ts b/tests/blockActions.test.ts index a04c50fe..33c15c19 100644 --- a/tests/blockActions.test.ts +++ b/tests/blockActions.test.ts @@ -23,7 +23,8 @@ 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); - const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references'); + // Conditional actions: explain (failed blocks), references (output with some), details (a Semantic Output listing). + const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references' && action.id !== 'details'); 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')); diff --git a/tests/codexAdapter.test.ts b/tests/codexAdapter.test.ts index ef005ce0..89639216 100644 --- a/tests/codexAdapter.test.ts +++ b/tests/codexAdapter.test.ts @@ -33,7 +33,13 @@ test('messages: items, approvals and turn results normalize from structured fiel } }); -async function session(scenario: string, act: (session: CodexSession, events: AgentEvent[], wait: (kind: AgentEvent['kind'], count?: number) => Promise<void>) => Promise<void>) { +/** + * Runs one scenario and returns what the client sent, as the fake server recorded it. The server records a message when + * it reads it, which can be after the turn settles (a refusal written after the person's answer), so `recorded` says + * what the test needs on record; it is waited for (bounded) rather than read at whatever moment the turn settled. + */ +async function session(scenario: string, act: (session: CodexSession, events: AgentEvent[], wait: (kind: AgentEvent['kind'], count?: number) => Promise<void>) => Promise<void>, + recorded: (client: Array<Record<string, unknown>>) => boolean = () => true) { const directory = await mkdtemp(join(tmpdir(), 'nmsh-codex-')); const log = join(directory, 'client.log'); const events: AgentEvent[] = []; @@ -50,7 +56,9 @@ async function session(scenario: string, act: (session: CodexSession, events: Ag try { assert.deepEqual(codex.start(), {ok: true}); await act(codex, events, wait); - const client = (await readFile(log, 'utf8').catch(() => '')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>); + const read = async () => (await readFile(log, 'utf8').catch(() => '')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>); + let client = await read(); + for (const deadline = Date.now() + 5000; !recorded(client) && Date.now() < deadline; client = await read()) await new Promise(resolve => setTimeout(resolve, 20)); return client; } finally { codex.close(); @@ -78,7 +86,7 @@ test('a session: handshake, thread, a message, one approval answered by the pers ]); assert.deepEqual(events.filter(event => event.kind === 'assistant'), [{kind: 'assistant', text: 'Cleaned.'}]); assert.deepEqual(events.at(-1), {kind: 'settled', ok: true}); - }); + }, client => client.some(message => message.id === 100) && client.some(message => message.id === 101)); // The two responses race (the refusal is immediate, the approval waits for the person), so only the first four are ordered. assert.deepEqual(client.slice(0, 4).map(message => message.method), ['initialize', 'initialized', 'thread/start', 'turn/start']); assert.equal(client.length, 6); diff --git a/tests/configuredCompletion.test.ts b/tests/configuredCompletion.test.ts index 73cd07c0..639b1460 100644 --- a/tests/configuredCompletion.test.ts +++ b/tests/configuredCompletion.test.ts @@ -10,7 +10,9 @@ import {CompletionService} from '../src/shell/CompletionService.js'; test('real configured candidate capture is bounded to 4096 records', async () => { const home = mkdtempSync(join(tmpdir(), 'nmsh-large-completion-')); writeFileSync(join(home, '.zshrc'), 'autoload -Uz compinit\ncompinit -D\n_large() { compadd -J large -- value{1..5000}; }\ncompdef _large large\n'); - const source = new ConfiguredCompletionSource({env: {...process.env, HOME: home}, startupMs: 4000, queryMs: 2000}); + // This bounds the records, not startup: the helper's own startup cap (5 s) and a long query window keep a loaded + // machine's compinit from turning into an empty result. + const source = new ConfiguredCompletionSource({env: {...process.env, HOME: home}, startupMs: 5000, queryMs: 2000}); try { const values = await source.query({buffer: 'large v', cwd: home}, new AbortController().signal); assert.equal(values.length, 4096); diff --git a/tests/contextCapabilities.test.ts b/tests/contextCapabilities.test.ts index b7d213e7..b16d710c 100644 --- a/tests/contextCapabilities.test.ts +++ b/tests/contextCapabilities.test.ts @@ -7,11 +7,11 @@ import {createHash} from 'node:crypto'; import type {CapabilityDefinition} from '../src/context/capability.js'; import {projectPackage} from '../src/context/capabilities/project.js'; import {projectBuild} from '../src/context/capabilities/build.js'; -import {dartRuntime, denoRuntime, dotnetRuntime, elixirRuntime, flutterRuntime, goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, swiftRuntime, versionMismatch, zigRuntime} from '../src/context/capabilities/runtimes.js'; -import {direnv, toolVersions} from '../src/context/capabilities/environment.js'; +import {bunRuntime, dartRuntime, denoRuntime, dotnetRuntime, elixirRuntime, flutterRuntime, goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, swiftRuntime, versionMismatch, zigRuntime} from '../src/context/capabilities/runtimes.js'; +import {direnv, nixShell, 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'; -import {clock, operatingSystem, sessionDuration, sessionJobs, sessionUser} from '../src/context/capabilities/system.js'; +import {clock, container, operatingSystem, sessionDuration, sessionJobs, sessionUser} from '../src/context/capabilities/system.js'; import {gitExtras, jujutsu, mercurial} from '../src/context/capabilities/vcs.js'; import {resetServiceCaches, versionFromInstallPath} from '../src/context/services.js'; import {CORE_CAPABILITIES} from '../src/context/registry.js'; @@ -718,3 +718,40 @@ test('vcs.hg and vcs.jj: Mercurial and Jujutsu state from their own directories, assert.equal(await resolve(jujutsu, {cwd: join(home, 'src'), home}), undefined); } finally { await rm(home, {recursive: true, force: true}); } }); + +test('v0.21 modules: Bun from its own files, a Nix shell from IN_NIX_SHELL, a container from markers and host variables; nothing run', async () => { + resetServiceCaches(); + const root = await workspace(); + const tools = await workspace(); + try { + const cellar = join(tools, 'Cellar', 'bun', '1.1.34', 'bin'); + await mkdir(cellar, {recursive: true}); + await sentinelExecutable(join(cellar, 'bun'), join(tools, 'BUN_RAN'), '9.9.9'); + // Corepack's pin is exact; engines is a range; .bun-version wins over both. + await writeFile(join(root, 'package.json'), JSON.stringify({packageManager: 'bun@1.1.30', engines: {bun: '>=1.0'}})); + assert.deepEqual(await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}), {requested: '1.1.30', requestedFrom: 'package.json packageManager', active: '1.1.34', manager: 'Homebrew', mismatch: true}); + await writeFile(join(root, 'package.json'), JSON.stringify({engines: {bun: '>=1.0'}})); + resetServiceCaches(); + assert.equal((await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}))?.requested, '>=1.0'); + await writeFile(join(root, '.bun-version'), '1.1.34\n'); + resetServiceCaches(); + assert.deepEqual(await resolve(bunRuntime, {cwd: root, root, env: {PATH: cellar}}), {requested: '1.1.34', requestedFrom: '.bun-version', active: '1.1.34', manager: 'Homebrew'}); + // Hostile package.json content stays inert data; a non-bun packageManager is no request. + await rm(join(root, '.bun-version')); + await writeFile(join(root, 'package.json'), JSON.stringify({packageManager: 'pnpm@9.0.0; touch PWNED', engines: 'not an object'})); + resetServiceCaches(); + assert.equal(await resolve(bunRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested']}), undefined); + assert.equal(await exists(join(tools, 'BUN_RAN')), false, 'bun was never run'); + assert.equal(await exists(join(root, 'PWNED')), false); + + assert.deepEqual(await resolve(nixShell, {cwd: root, env: {IN_NIX_SHELL: 'impure'}}), {state: 'impure'}); + assert.deepEqual(await resolve(nixShell, {cwd: root, env: {IN_NIX_SHELL: '1'}}), {state: 'active'}); + assert.equal(await resolve(nixShell, {cwd: root, env: {}}), undefined); + + assert.deepEqual(await resolve(container, {cwd: root, env: {CODESPACES: 'true'}}), {kind: 'Codespaces'}); + assert.deepEqual(await resolve(container, {cwd: root, env: {REMOTE_CONTAINERS: 'true'}}), {kind: 'Dev Container'}); + const onHost = await resolve(container, {cwd: root, env: {}}); + const markers = (await exists('/.dockerenv')) || (await exists('/run/.containerenv')); + assert.equal(onHost === undefined, !markers, 'only the real marker files decide, never workspace content'); + } finally { await rm(root, {recursive: true, force: true}); await rm(tools, {recursive: true, force: true}); resetServiceCaches(); } +}); diff --git a/tests/directoryNavigation.test.ts b/tests/directoryNavigation.test.ts index d34635d2..d9d1b33f 100644 --- a/tests/directoryNavigation.test.ts +++ b/tests/directoryNavigation.test.ts @@ -46,7 +46,9 @@ test('zoxide query uses a temporary copy, caches results and falls back on missi try { await writeFile(join(directory, 'db.zo'), 'original', {mode: 0o600}); const binary = join(directory, 'zoxide'); - await writeFile(binary, `#!${process.execPath}\nconst fs=require('node:fs');if(process.env._ZO_DATA_DIR===process.env.ORIGINAL_DB)process.exit(2);fs.writeFileSync(process.env._ZO_DATA_DIR+'/db.zo','sorted');fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+'\\n');process.stdout.write('20.5 /work/one\\n 4.0 /work/two spaces\\n');\n`, {mode: 0o700}); + // A shell script, like the real (native) zoxide quick to start: a Node stand-in's cold start could exceed the query's + // 1.5 s limit on a loaded machine, and the service then (rightly) fell back to Native. + await writeFile(binary, `#!/bin/sh\n[ "$_ZO_DATA_DIR" = "$ORIGINAL_DB" ] && exit 2\nprintf sorted > "$_ZO_DATA_DIR/db.zo"\nprintf '["%s","%s","%s","%s"]\\n' "$1" "$2" "$3" "$4" >> "$CALLS"\nprintf '20.5 /work/one\\n 4.0 /work/two spaces\\n'\n`, {mode: 0o700}); const calls = join(directory, 'calls'); const service = new DirectoryService({...process.env, PATH: directory, _ZO_DATA_DIR: directory, ORIGINAL_DB: directory, CALLS: calls}); assert.equal((await service.query([], '', 'zoxide'))[0]?.path, '/work/one'); diff --git a/tests/fixtures/fake-codex-app-server.mjs b/tests/fixtures/fake-codex-app-server.mjs index baa1dc6b..bb012c0a 100644 --- a/tests/fixtures/fake-codex-app-server.mjs +++ b/tests/fixtures/fake-codex-app-server.mjs @@ -37,7 +37,9 @@ createInterface({input: process.stdin}).on('line', line => { if (scenario === 'fail') return send({method: 'turn/completed', params: {threadId: 'thr_1', turn: {id: 'turn_1', status: 'failed', error: {message: 'usage limit reached\nsecond line'}, items: []}}}); send({method: 'item/started', params: {threadId: 'thr_1', turnId: 'turn_1', startedAtMs: 1, item: {type: 'commandExecution', id: 'item_1', command: 'rm -rf build', commandActions: [], cwd: '/tmp', status: 'inProgress'}}}); send({id: 100, method: 'item/commandExecution/requestApproval', params: {threadId: 'thr_1', turnId: 'turn_1', itemId: 'item_1', startedAtMs: 1, command: 'rm -rf build', cwd: '/tmp', reason: 'cleans the build'}}); - send({id: 101, method: 'item/tool/requestUserInput', params: {threadId: 'thr_1', turnId: 'turn_1', itemId: 'item_9', questions: []}}); + // A moment later, as a separate read: the person may already have answered 100, so the client's refusal of 101 can + // reach this server after that answer (and after the turn completes). Tests must wait for what they assert. + setTimeout(() => send({id: 101, method: 'item/tool/requestUserInput', params: {threadId: 'thr_1', turnId: 'turn_1', itemId: 'item_9', questions: []}}), 30); return; } if (id === 100) { diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index e5061ead..b620a957 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -8,6 +8,7 @@ import nodePty, {type IPty} from 'node-pty'; import {listLiveSessions} from '../../src/session/connectSession.js'; import {parseSlashCommand} from '../../src/commands/slashCommands.js'; import {TranscriptStore} from '../../src/sessions/TranscriptStore.js'; +import {markFishCompletionsGenerated} from '../../scripts/test-shell-home.mjs'; export const TSX = import.meta.resolve('tsx'); export const ENTRY = fileURLToPath(new URL('../../src/index.ts', import.meta.url)); @@ -37,6 +38,8 @@ export class LiveSandbox { constructor(config: Record<string, unknown> = {}, private readonly extraEnv: Record<string, string> = {}) { mkdirSync(this.home); + // Fish's detached man-page generator would otherwise outlive the sandbox's shells and race disposal (#403). + markFishCompletionsGenerated(this.home); mkdirSync(this.temp); mkdirSync(join(this.config, 'nmsh'), {recursive: true}); mkdirSync(this.runtime, {mode: 0o700}); diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index dabc72e3..619cb8fb 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -83,17 +83,20 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await type(frontend, read.keys); if (read.enter) frontend.pty.write('\r'); await frontend.waitFor(read.answered, mark, 10_000); - // The command completes and the composer is back: an ordinary command runs at once, not queued. + // The answered command completes (its record is journaled after the screen, later on a slower host; Fish's + // reader has the terminal, so its output is not journaled) and the shell is back at its prompt. Until then + // the program's output can be on screen while the command still runs, and an Enter then queues, as designed. + const journaled = async () => (await records(sandbox)).filter(entry => entry.command === read.command); + if (shell !== 'fish') await until(async () => (await journaled()).length > before, 15_000, `${what}: journaled`); + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15_000, `${what}: the shell is back at its prompt`); + // An ordinary command then runs. Once it has, nothing typed for the program ran as a command or is left queued. const tag = `BACK-${reactionMs}-${read.name.length}`; - await frontend.run(`echo ${tag}`, new RegExp(tag, 'u'), {completion: false}); + await frontend.run(`echo ${tag}`, new RegExp(tag, 'u')); const all = await records(sandbox); assert.equal(all.some(record => record.command.trim() === read.keys), false, `${what}: the answer never ran as a command`); assert.equal(await queued(sandbox), 0, `${what}: nothing was queued`); if (read.secret) assert.equal(strip(frontend.output.slice(mark)).includes(SECRET), false, `${what}: the hidden answer is never drawn`); - // Exactly once, from the journal (Fish's reader has the terminal, so its output is not journaled). The journal is - // written after the screen (later on a slower host): wait for this run's own record first. - const journaled = async () => (await records(sandbox)).filter(entry => entry.command === read.command); - if (shell !== 'fish') await until(async () => (await journaled()).length > before, 15_000, `${what}: journaled`); + // Exactly once, from the journal. const record = (await journaled()).at(-1); if (shell !== 'fish' && read.keys === 'hello') assert.equal(record?.output.match(/ANSWER=hello/gu)?.length, 1, `${what}: answered exactly once`); await pause(400); diff --git a/tests/moduleManager.test.ts b/tests/moduleManager.test.ts index 3304ddd3..e537af1f 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 · 16 modules/u); + assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 17 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/moduleReport.test.ts b/tests/moduleReport.test.ts new file mode 100644 index 00000000..798fa72a --- /dev/null +++ b/tests/moduleReport.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {explainModules, moduleTimings} from '../src/prompt/moduleReport.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; + +test('/modules explain and /modules timings parse as reports; plain /modules opens the manager', () => { + assert.deepEqual(parseSlashCommand('/modules explain'), {kind: 'modules', report: 'explain'}); + assert.deepEqual(parseSlashCommand('/modules timings'), {kind: 'modules', report: 'timings'}); + assert.deepEqual(parseSlashCommand('/modules'), {kind: 'modules'}); + assert.notEqual(parseSlashCommand('/modules nonsense')?.kind, 'modules'); +}); + +test('explain: where each module goes, whether it shows and why, and its sources\' states; reading snapshots only', () => { + const configuration = normalizePromptConfiguration({}); + const asked: string[] = []; + const now = 100_000; + const rows = explainModules(configuration.modules, { + status: id => { asked.push(id); return id === 'runtime.deno' ? {state: 'fresh', collectedAt: now - 3000, durationMs: 4.2, evidence: '.dvmrc\u001b[2J'} : {state: 'idle'}; }, + conditionMet: () => true, + }, configuration, now); + const text = rows.join('\n'); + assert.ok(rows.length > configuration.modules.length / 2, 'one or two rows per module'); + assert.match(text, /\(gitBranch\) · Main Prompt · /u); + assert.match(text, /\(nmsh\.project:deno\) · Hidden · turned off in Modules\n reads runtime\.deno fresh 3s ago, took 4 ms \(\.dvmrc \[2J\)/u, 'evidence is drawn inert'); + assert.match(text, /\(nmsh\.project:bun\) · Hidden · turned off in Modules\n reads runtime\.bun not collected/u); + assert.doesNotMatch(text, /\u001b/u); + assert.match(rows.at(-1)!, /collected nothing/u); + assert.ok(asked.length > 0 && asked.every(id => /^[a-z]/u.test(id)), 'only the status snapshot was consulted'); + const off = explainModules([{...configuration.modules[0]!, visible: false}], {}, configuration, now); + assert.match(off[0]!, /turned off in Modules/u); + assert.match(explainModules([{...configuration.modules[0]!, id: 'not.installed' as never}], {}, configuration)[0]!, /its Context Pack is not installed/u); +}); + +test('timings: the slowest last collections first, bounded, honest when nothing was measured', () => { + assert.deepEqual(moduleTimings(() => undefined), ['No module data has been collected in this window yet.']); + const durations: Record<string, number> = {'runtime.node': 40, 'vcs.git': 3, 'cloud.aws': 120, 'system.os': 0.4}; + const rows = moduleTimings(id => durations[id] === undefined ? {state: 'idle'} : {state: 'fresh', collectedAt: 0, durationMs: durations[id]!}, 5000, 3); + assert.match(rows[0]!, /^ +120 ms cloud\.aws · fresh 5s ago$/u); + assert.match(rows[1]!, /^ +40 ms runtime\.node/u); + assert.match(rows[2]!, /^ +3 ms vcs\.git/u); + assert.match(rows[3]!, /^… 1 faster ones not listed$/u); + assert.match(rows.at(-1)!, /excluding time queued; nothing was collected/u); +}); + +import {newFitReport} from '../src/prompt/fitReport.js'; +import {buildContextRail} from '../src/prompt/prompt.js'; +import {DEFAULT_PROMPT_CONFIGURATION, DEFAULT_STATUS_STRIP} from '../src/prompt/configuration.js'; +import {renderStatusStrip} from '../src/status/StatusStrip.js'; + +test('the Status Strip reports which routed modules its width fitting shortened or left out', () => { + const settings = {...DEFAULT_STATUS_STRIP, enabled: true, clock: false, cpu: false, ram: false, uptime: false, battery: false}; + const modules = [ + {id: 'gitBranch', text: 'feature/a-rather-long-branch-name', priority: 90, compact: ['feature/a-r…']}, + {id: 'nodeVersion', text: 'node 22.11.0', priority: 40}, + {id: 'time', text: '06:44:12 local time', priority: 10}, + ]; + const wide = newFitReport(); + renderStatusStrip(settings, {}, 160, undefined, undefined, modules, wide); + assert.deepEqual([...wide.dropped, ...wide.compacted], [], 'everything fits: nothing to explain'); + const narrow = newFitReport(); + renderStatusStrip(settings, {}, 36, undefined, undefined, modules, narrow); + assert.ok(narrow.dropped.has('time') && !narrow.dropped.has('gitBranch'), 'the lowest priority goes first'); + assert.ok(narrow.compacted.has('gitBranch'), 'a narrower form is used before anything with a higher priority drops'); + const tiny = newFitReport(); + renderStatusStrip(settings, {}, 10, undefined, undefined, modules, tiny); + assert.deepEqual([...tiny.dropped].sort(), ['gitBranch', 'nodeVersion', 'time'], 'a strip too narrow to show shows none of them'); +}); + +test('the Context Rail reports the modules its priority fitting left out; explain says so in words', () => { + const configuration = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + configuration.contextRail.mode = 'always'; + configuration.modules = [{id: 'project', visible: true, condition: 'always', surface: 'contextRail'}, + {id: 'exitStatus', visible: true, condition: 'always', surface: 'contextRail'}]; + const context = {cwd: '/qa', project: 'A-project-name-that-is-long', exitStatus: 42}; + const wide = newFitReport(); + buildContextRail(context, 120, configuration, 0, wide); + assert.equal(wide.dropped.size, 0); + const narrow = newFitReport(); + buildContextRail(context, 12, configuration, 0, narrow); + assert.ok(narrow.dropped.size + narrow.compacted.size > 0, 'something had to give at 12 columns'); + const fit = (id: string) => narrow.dropped.has(id) ? 'dropped' as const : narrow.compacted.has(id) ? 'compacted' as const : undefined; + const rows = explainModules(configuration.modules, {conditionMet: () => true}, configuration, 0, fit).join('\n'); + for (const id of narrow.dropped) assert.match(rows, new RegExp(`\\(${id}\\) · Context Rail · .* · left out for width in the last frame`, 'u')); + for (const id of narrow.compacted) assert.match(rows, new RegExp(`\\(${id}\\) · Context Rail · .* · shortened to fit the width in the last frame`, 'u')); + const off = explainModules([{...configuration.modules[0]!, visible: false}], {}, configuration, 0, () => 'dropped'); + assert.doesNotMatch(off[0]!, /left out for width/u, 'a module turned off is explained as off, not as cut for width'); +}); diff --git a/tests/nativeCaptureLifecycle.test.ts b/tests/nativeCaptureLifecycle.test.ts index c6ff5207..1d186cb6 100644 --- a/tests/nativeCaptureLifecycle.test.ts +++ b/tests/nativeCaptureLifecycle.test.ts @@ -55,7 +55,8 @@ test('native cancellation reaps a PTY before it can process the initialization c // The inner shell never reads the queued command that formerly recorded its PID. const module = new URL('../src/shell/CompletionService.ts', import.meta.url).href; const program = `import {NativeCompletionSource} from ${JSON.stringify(module)}; - import {existsSync} from 'node:fs'; + import {existsSync, writeFileSync} from 'node:fs'; + writeFileSync(${JSON.stringify(join(root, 'owner-ready'))}, ''); const controller = new AbortController(); const request = new NativeCompletionSource().query({buffer:'g',cwd:${JSON.stringify(root)}},controller.signal); while (!existsSync(${JSON.stringify(join(root, 'early-pid'))})) await new Promise(r=>setTimeout(r,5)); @@ -69,6 +70,9 @@ test('native cancellation reaps a PTY before it can process the initialization c }); let inner = 0; try { + // A cold `node --import=tsx` compiling CompletionService's imports is not what this test bounds; under a full parallel + // run it alone took more than three seconds. The tight bounds below start once the owner is running. + await until(() => existsSync(join(root, 'owner-ready')) || owner.exitCode !== null, 30_000, 'the owner process to load'); 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); @@ -242,6 +246,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 { + // The owner's cold `node --import=tsx` start is not what this test bounds (under a full parallel run it alone can + // take longer than the capture's own window); the bounds below start once it has loaded. + await until(() => loadedAt !== undefined || owner.exitCode !== null, 30_000, 'the owner process to load'); const identity = await publishedCapture(root, prefix); const transport = identity.transport; inner = identity.pid; @@ -296,12 +303,15 @@ 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)}; +import {writeFileSync} from 'node:fs'; +writeFileSync(${JSON.stringify(join(root, 'owner-ready'))}, ''); 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(join(root, 'owner-ready')) || owner.exitCode !== null, 30_000, 'the owner process to load'); await until(() => existsSync(marker), 3000, 'controlled PID rewrite'); inner = Number(readFileSync(join(root, 'inner-pid'), 'utf8')); assert.ok(Number.isSafeInteger(inner) && inner > 1); diff --git a/tests/postV020Folding.test.ts b/tests/postV020Folding.test.ts index ff61d3be..ed9abb14 100644 --- a/tests/postV020Folding.test.ts +++ b/tests/postV020Folding.test.ts @@ -15,12 +15,14 @@ import {beginSelection} from '../src/output/TranscriptSelection.js'; import {executionFrameFixture} from './support/executionTimelineFrames.js'; import {DEFAULT_PROMPT_CONFIGURATION, loadPromptConfiguration} from '../src/prompt/configuration.js'; import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {markFishCompletionsGenerated} from '../scripts/test-shell-home.mjs'; const sequence = (n: number) => Array.from({length: n}, (_, i) => `${i + 1}`).join('\n') + '\n'; async function realApp(shell: ShellId) { const root = realpathSync(mkdtempSync(join(tmpdir(), `nmsh-fold-${shell}-`))); const cwd = join(root, 'project'); mkdirSync(cwd); + markFishCompletionsGenerated(root, {dataHome: join(root, 'data')}); const keys = ['HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'NMSH_RETURN_SESSION', 'NMSH_HANDOFF_SHELL', 'TERM_PROGRAM']; const saved = new Map(keys.map(key => [key, process.env[key]])); const previousCwd = process.cwd(); diff --git a/tests/queryOrder.test.ts b/tests/queryOrder.test.ts index fbd0f12e..e3b04c3d 100644 --- a/tests/queryOrder.test.ts +++ b/tests/queryOrder.test.ts @@ -6,6 +6,7 @@ import {join} from 'node:path'; import {tmpdir} from 'node:os'; import {QueryOrder} from '../src/shell/QueryOrder.js'; import {ShellSession} from '../src/shell/ShellSession.js'; +import {markFishCompletionsGenerated} from '../scripts/test-shell-home.mjs'; const DA1 = '\u001b[?62;22c'; @@ -114,6 +115,7 @@ const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish']. 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(join(tmpdir(), 'nt-qo-')); + markFishCompletionsGenerated(home, {dataHome: join(home, 'data')}); 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 = ''; diff --git a/tests/queuedHighlight.test.ts b/tests/queuedHighlight.test.ts index aea05775..165c1ba1 100644 --- a/tests/queuedHighlight.test.ts +++ b/tests/queuedHighlight.test.ts @@ -1,18 +1,46 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import {once} from 'node:events'; import {isolateConfig} from './support/isolatedConfig.js'; import {TerminalApp} from '../src/app/TerminalApp.js'; -/** A semantic service that classifies on request, as the real one does, without starting a shell. */ -function classifyingApp() { - const app = new TerminalApp(); - app['render'] = () => {}; +/** + * A semantic service that classifies on request, as the real one does. The app's real shell reports its startup + * knowledge first: that replaces the classification cache, so it must land before the test's own classifications + * rather than in the middle of them (#408). Every lookup the app starts is recorded, so a test waits for exactly + * those (and the recoloring chained on them) instead of guessing at event-loop turns. + */ +async function classifyingApp() { + const app = await readyApp(); const semantic = app['semanticService']; semantic.cache.clear(); - semantic.classifyCommand = async (word: string) => { semantic.cache.set(word.split(' ')[0]!, 'executable'); return 'executable' as const; }; + const lookups: Promise<unknown>[] = []; + semantic.classifyCommand = (word: string) => { + const lookup = (async () => { semantic.cache.set(word.split(' ')[0]!, 'executable'); return 'executable' as const; })(); + lookups.push(lookup); + return lookup; + }; + // The recoloring callbacks were attached to each lookup before this waits on them, so they have run when it resolves. + const settle = async () => { await Promise.all(lookups); }; + return {app, settle}; +} + +/** An app whose real shell has reported its startup knowledge. */ +async function readyApp(): Promise<TerminalApp> { + const app = new TerminalApp(); + app['render'] = () => {}; + try { await once(app['session'], 'prompt', {signal: AbortSignal.timeout(20_000)}); } + catch (error) { app['stop'](0); app['session'].kill(); throw error; } return app; } -const settle = () => new Promise(resolve => setImmediate(resolve)); + +async function until(check: () => boolean, what: string, timeoutMs = 10_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} type Cell = {text: string; style: string}; const cells = (app: TerminalApp, startId: number): Cell[] => app['output'].transcript().lines[startId] as Cell[]; const text = (line: Cell[]) => line.map(cell => cell.text).join(''); @@ -21,7 +49,7 @@ const styleOf = (line: Cell[], word: string) => line[text(line).indexOf(word)]!. test('a queued command is highlighted like a composer command: its command word is classified and the stored line recolored', async () => { const isolation = isolateConfig(); - const app = classifyingApp(); + const {app, settle} = await classifyingApp(); try { const sgr = app['syntaxSgr']; // The queue runs an entry the composer never saw, so its command word is not classified yet. @@ -41,7 +69,7 @@ test('a queued command is highlighted like a composer command: its command word test('a command the composer submitted and a queued one end with the same colors', async () => { const isolation = isolateConfig(); - const app = classifyingApp(); + const {app, settle} = await classifyingApp(); try { app['onShellExec']('cargo build --release', Date.now(), undefined, 'cargo build --release'); const queuedId = app['output'].activeStartId!; @@ -53,3 +81,20 @@ test('a command the composer submitted and a queued one end with the same colors assert.equal(styleOf(queued, 'cargo'), app['syntaxSgr'].KnownCommand, 'and so does the queued block'); } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } }); + +test('a stored command whose classification newer shell knowledge superseded still ends classified (#408)', {skip: process.platform === 'win32'}, async () => { + const isolation = isolateConfig(); + const app = await readyApp(); + try { + const sgr = app['syntaxSgr']; + app['semanticService'].cache.clear(); + // A fast queue: the entry starts, and the previous entry's prompt knowledge lands while its lookup is in flight. + app['onShellExec']('ls -la', Date.now(), undefined, 'ls -la'); + const startId = app['output'].activeStartId!; + assert.equal(styleOf(cells(app, startId), 'ls'), sgr.Command, 'unclassified at first'); + app['semanticService'].applyShellKnowledge('complete\n'); + await until(() => styleOf(cells(app, startId), 'ls') === sgr.KnownCommand, 'the stored line to be recolored from the new knowledge'); + assert.equal(styleOf(cells(app, startId), '-la'), sgr.Flag); + assert.ok(app['formatCommandAnsi']('ls -la', null)[0]!.includes(`${sgr.KnownCommand}l`), 'the composer path agrees'); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } +}); diff --git a/tests/removeDirectory.test.ts b/tests/removeDirectory.test.ts new file mode 100644 index 00000000..a3882779 --- /dev/null +++ b/tests/removeDirectory.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {removeDirectoryNow} from '../src/util/removeDirectory.js'; + +// The configured-completion helper's private directory (#417 CI): its shells may write a pid file and its own cleanup +// may `rm -rf` the directory while NMSh removes it. Plain rmSync then threw ENOTEMPTY (macOS) or returned with the +// directory still present (Linux) in roughly one round in ten. +test('a directory another process is writing into and removing is gone when removeDirectoryNow returns', async () => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-rmnow-'))); + try { + for (let round = 0; round < 120; round += 1) { + const root = mkdtempSync(join(base, 'r-')); + for (let index = 0; index < 40; index += 1) writeFileSync(join(root, `f${index}`), 'x'); + const helper = spawn('/bin/sh', ['-c', `i=0; while [ $i -lt 10 ]; do : > '${root}/w'$i 2>/dev/null; i=$((i+1)); done; rm -rf '${root}'`]); + await new Promise(resolve => setTimeout(resolve, round % 5)); + removeDirectoryNow(root); + assert.equal(existsSync(root), false, `round ${round}: the directory is gone`); + if (helper.exitCode === null && helper.signalCode === null) await once(helper, 'close'); + } + assert.deepEqual(readdirSync(base), [], 'no renamed copy is left behind'); + } finally { rmSync(base, {recursive: true, force: true}); } +}); + +test('removeDirectoryNow: a missing directory is fine; nothing outside it is touched', () => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-rmnow-'))); + try { + writeFileSync(join(base, 'keep'), 'x'); + removeDirectoryNow(join(base, 'missing')); + assert.deepEqual(readdirSync(base), ['keep']); + } finally { rmSync(base, {recursive: true, force: true}); } +}); diff --git a/tests/semantic.test.ts b/tests/semantic.test.ts index 2a4b7ff8..8e64921b 100644 --- a/tests/semantic.test.ts +++ b/tests/semantic.test.ts @@ -1,5 +1,5 @@ import test, {beforeEach, afterEach} from 'node:test'; -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import assert from 'node:assert'; @@ -100,3 +100,15 @@ test('SemanticService ignores commands when dead and cleans resources', async () assert.equal((service as any).zdotdir, '', 'zdotdir must be cleared'); assert.equal((service as any).isDead, true, 'service must be marked dead'); }); + +test('a slow configuration delays the first answer, not the helper\'s life: lookups are timed from when it reads them', {timeout: 30_000}, async () => { + // Sourcing takes longer than one lookup's limit (nvm, a large framework); the first lookup used to time out, end the + // helper, and leave every later command without its known/unknown color for the session. + writeFileSync(join(fixtureHome, '.zshrc'), 'sleep 2\n'); + const service = createService(); + const [first, source] = await Promise.all([service.classifyCommand('ls'), service.resolveSource('ls')]); + assert.strictEqual(first, 'executable'); + assert.strictEqual(source?.kind, 'executable'); + assert.strictEqual((service as any).isDead, false); + assert.strictEqual(await service.classifyCommand('cd'), 'builtin', 'later lookups are still answered'); +}); diff --git a/tests/semanticOutput.test.ts b/tests/semanticOutput.test.ts new file mode 100644 index 00000000..63b3cb1c --- /dev/null +++ b/tests/semanticOutput.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {MAX_ITEMS, recognizeOutput, semanticText} from '../src/output/semantic.js'; + +const lines = (...rows: string[]) => rows.join('\n'); + +test('node --test (spec reporter): counts as printed; failing tests from its failing-tests section', () => { + const passing = recognizeOutput('npm test', lines('▶ suite', ' ✔ a (0.1ms)', '✔ suite (0.5ms)', 'ℹ tests 1797', 'ℹ suites 3', 'ℹ pass 1772', 'ℹ fail 0', + 'ℹ cancelled 0', 'ℹ skipped 25', 'ℹ todo 0', 'ℹ duration_ms 8400.1'), 0); + assert.deepEqual({source: passing?.source, outcome: passing?.outcome, overview: passing?.overview, items: passing?.items}, + {source: 'node --test', outcome: 'passed', overview: '1,772 passed · 0 failed · 25 skipped', items: []}); + const failing = recognizeOutput('node --test', lines('✔ ok test (1ms)', '✖ broken test (2ms)', ' AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:', + 'ℹ tests 2', 'ℹ pass 1', 'ℹ fail 1', 'ℹ cancelled 0', 'ℹ skipped 0', 'ℹ todo 0', '', '✖ failing tests:', '', 'test at tests/a.test.ts:3:1', + '✖ broken test (2ms)', ' AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:'), 1); + assert.equal(failing?.outcome, 'failed'); + assert.equal(failing?.overview, '1 passed · 1 failed'); + assert.deepEqual(failing?.items, [{text: 'broken test', tone: 'failure'}]); +}); + +test('TAP: the # summary and top-level not ok lines (TODO and SKIP are not failures)', () => { + const result = recognizeOutput('node --test --test-reporter=tap', lines('TAP version 13', 'ok 1 - a', 'not ok 2 - b', ' ---', ' ...', + 'not ok 3 - later # TODO', '1..3', '# tests 3', '# pass 1', '# fail 1', '# todo 1'), 1); + assert.equal(result?.overview, '1 passed · 1 failed · 1 todo'); + assert.deepEqual(result?.items.map(item => item.text), ['b']); +}); + +test('Jest, Vitest and pytest: their own summary lines and failure headings', () => { + const jest = recognizeOutput('npx jest', lines('FAIL src/a.test.js', ' ● Math › adds', '', ' expect(received).toBe(expected)', ' ● Console', 'PASS src/b.test.js', + 'Test Suites: 1 failed, 1 passed, 2 total', 'Tests: 1 failed, 1 skipped, 10 passed, 12 total'), 1); + assert.equal(jest?.source, 'Jest'); + assert.equal(jest?.overview, '10 passed · 1 failed · 1 skipped'); + assert.deepEqual(jest?.items.map(item => item.text), ['Math › adds']); + const vitest = recognizeOutput('npx vitest run', lines(' FAIL src/a.test.ts > math > adds', 'AssertionError: expected 3 to be 4', ' Test Files 1 failed | 2 passed (3)', + ' Tests 1 failed | 10 passed (11)'), 1); + assert.equal(vitest?.source, 'Vitest'); + assert.equal(vitest?.overview, '10 passed · 1 failed'); + assert.deepEqual(vitest?.items.map(item => item.text), ['src/a.test.ts > math > adds']); + const pytest = recognizeOutput('pytest -q', lines('..F.', '=========================== short test summary info ============================', + 'FAILED tests/test_a.py::test_x - assert 1 == 2', '========================= 1 failed, 3 passed, 2 skipped in 0.12s ========================='), 1); + assert.equal(pytest?.overview, '3 passed · 1 failed · 2 skipped'); + assert.deepEqual(pytest?.items.map(item => item.text), ['tests/test_a.py::test_x - assert 1 == 2']); +}); + +test('cargo test sums its test binaries; go test counts packages and failing tests', () => { + const cargo = recognizeOutput('cargo test', lines('running 3 tests', 'test a::one ... ok', 'test a::two ... FAILED', 'test a::three ... ignored', + 'test result: FAILED. 1 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s', 'running 2 tests', + 'test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s'), 101); + assert.equal(cargo?.overview, '3 passed · 1 failed · 1 ignored'); + assert.deepEqual(cargo?.items.map(item => item.text), ['a::two']); + const go = recognizeOutput('go test ./...', lines('--- FAIL: TestFoo (0.00s)', ' foo_test.go:9: want 1, got 2', 'FAIL', 'FAIL\texample.com/a\t0.004s', + 'ok \texample.com/b\t0.003s', 'ok \texample.com/c\t(cached)', '? \texample.com/d\t[no test files]'), 1); + assert.equal(go?.overview, '2 packages ok · 1 package failed · 1 failing test'); + assert.deepEqual(go?.items.map(item => item.text), ['TestFoo']); +}); + +test('compiler diagnostics: TypeScript (pretty and plain), Rust headings with locations, GCC/Clang', () => { + const pretty = recognizeOutput('npx tsc --noEmit', lines("src/a.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.", '', + '3 const x: string = 1;', ' ~', '', "src/b.ts(10,1): error TS2304: Cannot find name 'y'.", 'Found 2 errors in 2 files.'), 2); + assert.equal(pretty?.source, 'TypeScript'); + assert.equal(pretty?.overview, '2 errors in 2 files'); + assert.deepEqual(pretty?.items.map(item => item.text), ["src/a.ts:3:7 TS2322: Type 'number' is not assignable to type 'string'.", "src/b.ts:10:1 TS2304: Cannot find name 'y'."]); + const rust = recognizeOutput('cargo build', lines('warning: unused variable: `x`', ' --> src/main.rs:2:9', ' |', 'error[E0308]: mismatched types', + ' --> src/main.rs:4:18', 'error: could not compile `demo` (bin "demo") due to 1 previous error; 1 warning emitted'), 101); + assert.equal(rust?.overview, '1 error · 1 warning'); + assert.deepEqual(rust?.items, [{text: 'src/main.rs:2:9 warning: unused variable: `x`', tone: 'warning'}, {text: 'src/main.rs:4:18 error[E0308]: mismatched types', tone: 'failure'}]); + const clang = recognizeOutput('make', lines("src/a.c:3:5: error: use of undeclared identifier 'x'", 'src/a.c:4:1: warning: control reaches end of non-void function', + '1 warning and 1 error generated.'), 2); + assert.equal(clang?.source, 'GCC/Clang'); + assert.equal(clang?.overview, '1 error · 1 warning'); + assert.equal(clang?.items[1]?.tone, 'warning'); +}); + +test('fails closed: incomplete summaries, look-alike text from non-build commands, and plain output read as nothing', () => { + assert.equal(recognizeOutput('cat notes.txt', lines('# tests 2', '# fail 0'), 0), undefined, 'no pass count: not a summary'); + assert.equal(recognizeOutput('rg error', "src/a.c:3:5: error: quoted in a search result", 0), undefined, 'a successful search is not a build'); + assert.equal(recognizeOutput('git status', lines('On branch main', 'nothing to commit, working tree clean'), 0), undefined); + assert.equal(recognizeOutput('echo', '', 0), undefined); + assert.equal(recognizeOutput('go version', 'ok \texample.com/a\t0.1s', 0), undefined, 'go test lines only from go test'); + // A failing non-build command that printed compiler diagnostics still gets them (the failure makes them relevant). + assert.equal(recognizeOutput('./build.sh', "src/a.c:3:5: error: x", 1)?.source, 'GCC/Clang'); +}); + +test('items are bounded, deduplicated and counted; very large output is read from its end and says so; text export', () => { + const many = Array.from({length: 30}, (_, index) => `src/f${index}.c:1:1: error: bad ${index}`); + const result = recognizeOutput('make', lines(...many, ...many), 2)!; + assert.equal(result.items.length, MAX_ITEMS); + assert.equal(result.omitted, 10, 'duplicates are not counted twice'); + assert.equal(result.overview, '60 errors', 'the overview counts every diagnostic line printed'); + const text = semanticText(result); + assert.match(text, /^GCC\/Clang: 60 errors\nsrc\/f0\.c:1:1 error: bad 0\n/u); + assert.match(text, /… 10 more in the raw output$/u); + const huge = recognizeOutput('npm test', `${'x'.repeat(3 * 1024 * 1024)}\nℹ tests 1\nℹ pass 1\nℹ fail 0`, 0)!; + assert.equal(huge.complete, false); + assert.match(semanticText(huge), /read from the last 2 MiB/u); +}); + +// ---- Presentation and copy ------------------------------------------------------------------------------------------ + +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {logicalLines} from '../src/output/TranscriptSearch.js'; +import {parseCopyArgs, semanticCopyPayload} from '../src/clipboard/copySelection.js'; + +function block(buffer: OutputBuffer, command: string, output: string, exitCode: number) { + buffer.beginCommand(command, [`$ ${command}`]); + buffer.write(output.replace(/\n/gu, '\r\n')); + return buffer.complete(exitCode)!; +} +const passingRun = lines(...Array.from({length: 12}, (_, index) => `✔ test ${index} (1ms)`), 'ℹ tests 12', 'ℹ pass 12', 'ℹ fail 0', ''); +const failingRun = lines('✔ ok (1ms)', '✖ broken one (2ms)', '✖ broken two (2ms)', 'ℹ tests 3', 'ℹ pass 1', 'ℹ fail 2', '', '✖ failing tests:', '✖ broken one (2ms)', '✖ broken two (2ms)', ''); + +test('presentation: an overview row inside the block, details by the raw fold, nothing stored and nothing searchable', () => { + const buffer = new OutputBuffer(); + const pass = block(buffer, 'npm test', passingRun, 0); + const before = JSON.stringify(buffer.transcript().lines); + const rows = buffer.wrapped(100); + const overview = rows.filter(row => row.derived); + assert.deepEqual(overview.map(row => row.plain), [' ✔ node --test · 12 passed · 0 failed']); + assert.equal(overview[0]!.blockStartId, pass.startId, 'the row belongs to its block'); + assert.ok(rows.findIndex(row => row.derived) < rows.findIndex(row => row.plain.includes('test 0')), 'before the output'); + assert.equal(JSON.stringify(buffer.transcript().lines), before, 'drawing a reading stores nothing'); + assert.equal(logicalLines(rows).some(line => line.text.includes('node --test')), false, 'raw search never sees derived rows'); + + const fail = block(buffer, 'npm test', failingRun, 1); + const failRows = buffer.wrapped(100).filter(row => row.derived && row.blockStartId === fail.startId); + assert.equal(fail.expanded, true, 'a short failing output stays expanded'); + assert.deepEqual(failRows.map(row => row.plain), [' ✘ node --test · 1 passed · 2 failed', ' › 2 failing tests · click or Actions to show'], + 'raw is expanded, so the details (what it shows) start closed'); + assert.equal(failRows[1]!.semanticToggle, fail.startId); + assert.ok(buffer.toggleSemanticDetails(fail.startId)); + assert.deepEqual(buffer.wrapped(100).filter(row => row.derived && row.blockStartId === fail.startId).map(row => row.plain).slice(1), + [' ⌄ 2 failing tests', ' ✘ broken one', ' ✘ broken two']); + buffer.toggleExpanded(0); + assert.equal(buffer.semanticDetailsOpen(fail), true, 'folding the raw output leaves the reader\'s choice alone'); + + buffer.setSemanticOutput(false); + assert.equal(buffer.wrapped(100).some(row => row.derived), false, 'Off: raw only'); + assert.equal(buffer.semanticFor(fail), undefined); +}); + +test('presentation: short passing output reads well already; failures and diagnostics are always summarized', () => { + const buffer = new OutputBuffer(); + block(buffer, 'npm test', lines('ℹ tests 1', 'ℹ pass 1', 'ℹ fail 0'), 0); + assert.equal(buffer.wrapped(80).some(row => row.derived), false); + block(buffer, 'make', "a.c:1:1: error: nope", 2); + assert.deepEqual(buffer.wrapped(80).filter(row => row.derived).map(row => row.plain), [' ✘ GCC/Clang · 1 error', ' › 1 diagnostic · click or Actions to show']); + block(buffer, 'git status', lines('On branch main', 'nothing to commit, working tree clean'), 0); + assert.equal(buffer.wrapped(80).filter(row => row.derived).length, 2, 'unrecognized output gets nothing'); +}); + +test('/copy: --raw, --semantic and --both are one choice, never with --report or the picker; semantic copies fall back to raw once', () => { + assert.deepEqual(parseCopyArgs('2 --semantic'), {ok: true, request: {selector: {kind: 'indices', indices: [2]}, content: 'semantic'}}); + assert.deepEqual(parseCopyArgs('--both -2'), {ok: true, request: {selector: {kind: 'latest', count: 2}, content: 'both'}}); + assert.deepEqual(parseCopyArgs('--raw'), {ok: true, request: {selector: {kind: 'default'}, content: 'raw'}}); + assert.match((parseCopyArgs('--raw --semantic') as {error: string}).error, /one of --raw, --semantic or --both/u); + assert.match((parseCopyArgs('--both --report') as {error: string}).error, /report has its own format/u); + assert.match((parseCopyArgs('ui --semantic') as {error: string}).error, /copy directly/u); + const buffer = new OutputBuffer(); + const failing = block(buffer, 'npm test', failingRun, 1); + const plain = block(buffer, 'echo hi', 'hi\n', 0); + const semantic = semanticCopyPayload([failing, plain], 'semantic', false, record => buffer.semanticFor(record)); + assert.equal(semantic.payload, 'node --test: 1 passed · 2 failed\nbroken one\nbroken two\nhi\n'); + assert.equal(semantic.unavailable, 1); + const both = semanticCopyPayload([failing], 'both', false, record => buffer.semanticFor(record)); + assert.match(both.payload, /^── NMSh · raw output ──\n✔ ok \(1ms\)\n[\s\S]*✖ broken two \(2ms\)\n── NMSh · Semantic Output ──\nnode --test: 1 passed · 2 failed\nbroken one\nbroken two$/u); + assert.equal(both.payload.split('✔ ok (1ms)').length, 2, 'raw appears once'); +}); + +test('hostile output cannot make recognition slow: every pattern is linear in what it reads', () => { + const block = (line: string, times: number) => Array.from({length: times}, () => line).join('\n'); + const cases: Array<[string, string, string]> = [ + ['one 2 MB line of spaces', 'cat x', `${' '.repeat(2_000_000)}x`], + ['Jest-like lines of digits', 'npx jest', block(`Tests: ${'1'.repeat(2000)}`, 1000)], + ['TAP lines padded with spaces', 'node --test', `${block(`not ok 1 - ${' '.repeat(2000)}x`, 1000)}\n# tests 1\n# pass 0\n# fail 1`], + ['colon-digit chains', 'make', block(`${'a:1'.repeat(660)}: error: x`, 1000)], + ['TypeScript-like prefixes', 'npx tsc', block(`${'(1,'.repeat(600)} - error TS1: x`, 1000)], + ['pytest-like banners', 'pytest', block(`= ${'1 a, '.repeat(390)} in 1s =`, 1000)], + ['Rust locations', 'cargo build', block(`error: x\n --> ${':1'.repeat(1000)}`, 500)], + ['Vitest-like lines', 'npx vitest', block(` Tests ${'(1'.repeat(1000)})`, 1000)], + ]; + for (const [name, command, output] of cases) { + const started = performance.now(); + recognizeOutput(command, output, 1); + const elapsed = performance.now() - started; + assert.ok(elapsed < 750, `${name}: ${Math.round(elapsed)}ms`); + } +}); diff --git a/tests/semanticOutputLive.test.ts b/tests/semanticOutputLive.test.ts new file mode 100644 index 00000000..b82bf1d3 --- /dev/null +++ b/tests/semanticOutputLive.test.ts @@ -0,0 +1,35 @@ +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'; + +/** Semantic Output through the real composer and PTY: the reading is drawn in its block and /copy --semantic copies it. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: a failing test summary gets its overview and failure listing; /copy --semantic copies the reading, /copy latest the raw output', {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: 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); + await frontend.run("printf '✔ ok (1ms)\\n✖ broken (2ms)\\nℹ tests 2\\nℹ pass 1\\nℹ fail 1\\n\\n✖ failing tests:\\n✖ broken (2ms)\\n'; false", + /node --test · 1 passed · 1 failed[\s\S]*1 failing test/u); + let mark = frontend.mark; + frontend.pty.write('/copy --semantic\r'); + await frontend.waitFor(/Copied[^\n]*semantic/u, mark); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').includes('broken'), 15_000, 'the reading on the clipboard'); + assert.equal(readFileSync(clip, 'utf8'), 'node --test: 1 passed · 1 failed\nbroken'); + mark = frontend.mark; + frontend.pty.write('/copy latest\r'); + await until(() => readFileSync(clip, 'utf8').startsWith('✔ ok (1ms)'), 15_000, 'the raw output on the clipboard'); + assert.doesNotMatch(readFileSync(clip, 'utf8'), /node --test:/u, '/copy without --semantic stays raw'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/shellAdapters.test.ts b/tests/shellAdapters.test.ts index dd95fd6c..136bc775 100644 --- a/tests/shellAdapters.test.ts +++ b/tests/shellAdapters.test.ts @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; import {once} from 'node:events'; -import {chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; import {connect, type Socket} from 'node:net'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -19,6 +20,7 @@ import {parseSlashCommand} from '../src/commands/slashCommands.js'; import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; import {createShellPanel, shellPanelKey} from '../src/shell/ShellPanel.js'; import type {ShellMarker} from '../src/shell/ShellProtocol.js'; +import {markFishCompletionsGenerated} from '../scripts/test-shell-home.mjs'; const available = (id: ShellId) => Boolean(shellAdapter(id).resolveExecutable(process.env)); const scratch = (prefix: string) => realpathSync(mkdtempSync(join(tmpdir(), prefix))); @@ -89,8 +91,9 @@ interface Lifecycle { markers: ShellMarker[]; execs: Array<[string, number | und async function startShell(id: ShellId): Promise<Lifecycle> { const home = scratch(`nmsh-adapter-${id}-`); mkdirSync(join(home, 'work')); + markFishCompletionsGenerated(home); const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local', 'share'), - LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8'}, id); + XDG_CACHE_HOME: join(home, '.cache'), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8'}, id); const state: Lifecycle = {markers: [], execs: [], output: '', shell, home}; shell.on('prompt', marker => state.markers.push(marker)); shell.on('exec', (command, allowed) => state.execs.push([command, allowed])); @@ -151,6 +154,19 @@ async function stopShell(state: Lifecycle): Promise<void> { rmSync(state.home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); } +test('fish: a test home marked as generated starts no detached man-page completion generator (#403)', {skip: !available('fish') && 'fish is not installed', timeout: 60_000}, async () => { + const state = await startShell('fish'); + try { + // Fish starts the generator during interactive startup, before its first prompt, and it runs for seconds. + await run(state, 'true'); + const generators = (spawnSync('ps', ['-axo', 'command='], {encoding: 'utf8'}).stdout ?? '').split('\n') + .filter(line => line.includes('--manpath') && line.includes(state.home)); + assert.deepEqual(generators, [], 'nothing outlives the shell and writes into its home'); + assert.deepEqual(readdirSync(join(state.home, '.cache', 'fish', 'generated_completions')), []); + assert.deepEqual(readdirSync(join(state.home, '.local', 'share', 'fish', 'generated_completions')), []); + } finally { await stopShell(state); } +}); + test('fish: a leading space is private, and Fish completion feeds structured candidates', {skip: !available('fish') && 'fish is not installed', timeout: 60_000}, async () => { const state = await startShell('fish'); try { @@ -224,6 +240,7 @@ test('hot swap in the service: zsh → fish → bash → zsh keeps session ident const runtimeDir = scratch('nmsh-swap-'); chmodSync(runtimeDir, 0o700); const home = scratch('nmsh-swaph-'); mkdirSync(join(home, 'project')); + markFishCompletionsGenerated(home, {dataHome: join(home, '.d')}); const service = new SessionService({runtimeDir, startupIdleMs: 60_000}); await service.start(); const keeper = new Peer(service.socketPath); diff --git a/tests/shellContextEnvironment.test.ts b/tests/shellContextEnvironment.test.ts index a24c8c24..cf573cd3 100644 --- a/tests/shellContextEnvironment.test.ts +++ b/tests/shellContextEnvironment.test.ts @@ -5,6 +5,7 @@ import {mkdtempSync, realpathSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {ShellSession} from '../src/shell/ShellSession.js'; +import {markFishCompletionsGenerated} from '../scripts/test-shell-home.mjs'; import {shellAdapter} from '../src/shell/adapters/registry.js'; import {SHELL_IDS, knowledgeJobCount, type ShellId} from '../src/shell/adapters/ShellAdapter.js'; import {parseShellKnowledge} from '../src/shell/ShellKnowledge.js'; @@ -15,6 +16,7 @@ const available = (id: ShellId) => Boolean(shellAdapter(id).resolveExecutable(pr async function start(id: ShellId): Promise<{shell: ShellSession; home: string}> { const home = realpathSync(mkdtempSync(join(tmpdir(), `nmsh-envsnap-${id}-`))); + markFishCompletionsGenerated(home); // The launch environment carries a secret and a frontend-only value: neither may be reported as a value. const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local', 'share'), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', AWS_SESSION_TOKEN: 'LAUNCH_TOKEN_VALUE', AWS_PROFILE: 'launch-profile'}, id); diff --git a/tests/shellIntelligence.test.ts b/tests/shellIntelligence.test.ts index 86fbe9a8..fb1e9616 100644 --- a/tests/shellIntelligence.test.ts +++ b/tests/shellIntelligence.test.ts @@ -102,11 +102,17 @@ test('complete snapshots reveal unshadowed commands, partial snapshots preserve service.applyShellKnowledge('function positive\npartial\n'); assert.equal(await service.classifyCommand('positive'), 'function'); assert.equal(await service.classifyCommand('configured_only'), 'alias'); + // A lookup in flight when newer knowledge arrives is asked again against that knowledge (#408): the answer its + // old knowledge implied (plain lookup: unknown) is never cached, and the new one (configured: the rc alias) is. + service.applyShellKnowledge('complete\n'); + const superseded = service.classifyCommand('configured_only'); + service.applyShellKnowledge('partial\n'); + assert.equal(await superseded, 'alias'); + assert.equal(service.cache.get('configured_only'), 'alias'); service.applyShellKnowledge('alias live_alias\ncomplete\n'); - const stale = service.classifyCommand('stale_result'); + const named = service.classifyCommand('latest'); service.applyShellKnowledge('function latest\ncomplete\n'); - assert.equal(await stale, 'unknown'); - assert.equal(service.cache.has('stale_result'), false); + assert.equal(await named, 'function', 'answered from the knowledge that superseded it, not settled as unknown'); service.kill(); assert.equal(await service.classifyCommand('latest'), 'function'); } finally { diff --git a/tests/textEditor.test.ts b/tests/textEditor.test.ts new file mode 100644 index 00000000..5701f594 --- /dev/null +++ b/tests/textEditor.test.ts @@ -0,0 +1,341 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TextBuffer} from '../src/viewer/textBuffer.js'; +import {encodeLines, openForEdit, saveFile, type EditableFile} from '../src/viewer/fileEdit.js'; +import {createEditor, editorKey, renderEditor, type EditorState} from '../src/viewer/EditorPanel.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const scratch = () => realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-edit-'))); +async function opened(path: string): Promise<EditableFile> { + const result = await openForEdit(path, '/'); + if (!result.ok) throw new Error(result.reason); + return result.file; +} +const refusal = async (path: string) => { + const result = await openForEdit(path, '/'); + assert.equal(result.ok, false); + return (result as {reason: string}).reason; +}; + +// ---- Buffer ------------------------------------------------------------------------------------------------------- + +test('buffer: typing, line breaks, tabs and pasted CR LF; control characters never enter the text', () => { + let clock = 0; + const buffer = new TextBuffer(['ab'], () => clock); + buffer.end(); + buffer.insert('c\u001b[2J\u0007'); + buffer.insert('\n\tline two\r\nthree\rfour'); + assert.deepEqual(buffer.lines, ['abc[2J', '\tline two', 'three', 'four']); + assert.deepEqual(buffer.caret, {line: 3, column: 4}); + buffer.backspace(); + buffer.home(); + buffer.backspace(); + assert.deepEqual(buffer.lines, ['abc[2J', '\tline two', 'threefou'], 'backspace at a line start joins the lines'); + clock += 5000; + assert.ok(buffer.dirty); +}); + +test('buffer: grapheme, word and vertical movement keep a display column across tabs and wide characters', () => { + const buffer = new TextBuffer(['hello world next', '\tx', '中文字符', 'éé']); + buffer.wordRight(); + assert.deepEqual(buffer.caret, {line: 0, column: 6}); + buffer.wordRight(); + assert.deepEqual(buffer.caret, {line: 0, column: 13}); + buffer.wordLeft(); + assert.deepEqual(buffer.caret, {line: 0, column: 6}); + buffer.setCaret({line: 0, column: 5}); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 1, column: 2}, 'column 5 lands after the tab (4 cells) and x'); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 2, column: 2}, 'wide characters: column 5 falls inside the third, so the caret stops before it'); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 3, column: 4}, 'the preferred column survives a shorter line'); + buffer.left(); + assert.deepEqual(buffer.caret, {line: 3, column: 2}, 'a combining sequence is one step'); +}); + +test('buffer: a selection is replaced by typing, cut by delete, and Left/Right collapse it like the composer', () => { + const buffer = new TextBuffer(['one two', 'three']); + buffer.wordRight(true); + assert.equal(buffer.selectedText(), 'one '); + buffer.insert('1 '); + assert.deepEqual(buffer.lines, ['1 two', 'three']); + buffer.selectAll(); + assert.equal(buffer.selectedText(), '1 two\nthree'); + buffer.left(); + assert.deepEqual(buffer.caret, {line: 0, column: 0}); + buffer.vertical(1, true); + buffer.delete(); + assert.deepEqual(buffer.lines, ['three']); +}); + +test('buffer: undo and redo coalesce bursts, restore the caret, and the saved text reads clean again', () => { + let clock = 0; + const buffer = new TextBuffer(['x'], () => clock); + buffer.end(); + for (const character of 'abc') { buffer.insert(character); clock += 100; } + clock += 5000; + buffer.insert('!'); + buffer.markSaved(); + assert.equal(buffer.dirty, false); + buffer.backspace(); + assert.equal(buffer.dirty, true); + assert.ok(buffer.undo()); + assert.equal(buffer.dirty, false, 'back to the saved text'); + assert.ok(buffer.undo()); + assert.deepEqual(buffer.lines, ['xabc']); + assert.ok(buffer.undo(), 'typing abc was one step'); + assert.deepEqual(buffer.lines, ['x']); + assert.deepEqual(buffer.caret, {line: 0, column: 1}); + assert.equal(buffer.dirty, true); + assert.ok(buffer.redo() && buffer.redo()); + assert.deepEqual(buffer.lines, ['xabc!']); + assert.equal(buffer.dirty, false); + // A saved state pushed out of the bounded history can never read as clean again. + const long = new TextBuffer(['']); + long.markSaved(); + for (let step = 0; step < 520; step += 1) { long.insert('\n'); } + while (long.undo()) { /* as far back as the history goes */ } + assert.equal(long.dirty, true); +}); + +test('buffer: find selects the next match, smart case, wrapping', () => { + const buffer = new TextBuffer(['Alpha beta', 'gamma ALPHA', 'alpha']); + assert.deepEqual(buffer.find('alpha'), {wrapped: false}); + assert.equal(buffer.selectedText(), 'Alpha'); + buffer.find('alpha'); + assert.deepEqual(buffer.caret, {line: 1, column: 11}); + buffer.find('alpha'); + assert.deepEqual(buffer.find('alpha'), {wrapped: true}); + assert.deepEqual(buffer.anchor, {line: 0, column: 0}); + assert.equal(buffer.find('Zeta'), undefined); +}); + +// ---- Opening and saving --------------------------------------------------------------------------------------------- + +test('open: only files that can be saved back exactly and safely; every refusal says why', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'mixed.txt'), 'a\r\nb\nc\n'); + assert.match(await refusal(join(root, 'mixed.txt')), /mixes LF and CRLF/u); + writeFileSync(join(root, 'bad.txt'), Buffer.from([0x61, 0xff, 0x0a])); + assert.match(await refusal(join(root, 'bad.txt')), /not valid UTF-8/u); + writeFileSync(join(root, 'bin'), Buffer.from([1, 0, 2])); + assert.match(await refusal(join(root, 'bin')), /binary/u); + writeFileSync(join(root, 'big.txt'), 'x'.repeat(2 * 1024 * 1024)); + assert.match(await refusal(join(root, 'big.txt')), /larger than 1024 KiB/u); + writeFileSync(join(root, 'linked.txt'), 'a\n'); + linkSync(join(root, 'linked.txt'), join(root, 'second-link.txt')); + assert.match(await refusal(join(root, 'linked.txt')), /other hard links/u); + mkdirSync(join(root, 'dir')); + assert.match(await refusal(join(root, 'dir')), /is a directory/u); + assert.match(await refusal(join(root, 'nowhere', 'x.txt')), /does not exist; create the directory first/u); + symlinkSync(join(root, 'absent.txt'), join(root, 'dangling.txt')); + assert.match(await refusal(join(root, 'dangling.txt')), /symlink to a missing file/u); + if (process.getuid?.() !== 0) { + writeFileSync(join(root, 'ro.txt'), 'r\n'); + chmodSync(join(root, 'ro.txt'), 0o444); + assert.match(await refusal(join(root, 'ro.txt')), /read-only/u); + mkdirSync(join(root, 'locked')); + writeFileSync(join(root, 'locked', 'in.txt'), 'i\n'); + chmodSync(join(root, 'locked'), 0o555); + assert.match(await refusal(join(root, 'locked', 'in.txt')), /folder holding .* is not writable/u); + chmodSync(join(root, 'locked'), 0o755); + } + const fresh = await opened(join(root, 'new.txt')); + assert.equal(fresh.identity, undefined); + assert.deepEqual(fresh.lines, ['']); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: atomic replacement keeps mode, BOM, CRLF and a missing final newline; through a symlink the link stays', async () => { + const root = scratch(); + try { + const path = join(root, 'notes.txt'); + writeFileSync(path, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('one\r\ntwo', 'utf8')])); + chmodSync(path, 0o640); + const file = await opened(path); + assert.deepEqual(file.lines, ['one', 'two']); + assert.equal(file.lineEnding, 'crlf'); + assert.equal(file.bom, true); + assert.equal(file.finalNewline, false); + const saved = await saveFile(file, ['one', 'two', 'three']); + assert.ok(saved.ok); + assert.deepEqual(readFileSync(path), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('one\r\ntwo\r\nthree', 'utf8')])); + assert.equal(statSync(path).mode & 0o777, 0o640); + assert.deepEqual(readdirSync(root), ['notes.txt'], 'no temporary file is left behind'); + + writeFileSync(join(root, 'target.md'), '# t\n'); + symlinkSync(join(root, 'target.md'), join(root, 'link.md')); + const viaLink = await opened(join(root, 'link.md')); + assert.equal(viaLink.realPath, join(root, 'target.md')); + assert.ok((await saveFile(viaLink, ['# changed'])).ok); + assert.ok(lstatSync(join(root, 'link.md')).isSymbolicLink(), 'the symlink is kept; its target is written'); + assert.equal(readFileSync(join(root, 'target.md'), 'utf8'), '# changed\n'); + assert.deepEqual(encodeLines({lineEnding: 'lf', bom: false, finalNewline: true}, ['']), Buffer.alloc(0), 'an emptied file is empty'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: a changed, replaced or deleted original is a conflict; Overwrite is explicit; a new file never replaces one that appeared', async () => { + const root = scratch(); + try { + const path = join(root, 'conf.txt'); + writeFileSync(path, 'mine\n'); + const file = await opened(path); + writeFileSync(path, 'theirs, longer\n'); + const changed = await saveFile(file, ['edited']); + assert.ok(!changed.ok && changed.conflict === 'changed'); + assert.equal(readFileSync(path, 'utf8'), 'theirs, longer\n', 'nothing was written'); + assert.ok((await saveFile(file, ['edited'], {force: true})).ok); + assert.equal(readFileSync(path, 'utf8'), 'edited\n'); + + const again = await opened(path); + writeFileSync(join(root, 'other.txt'), 'other\n'); + renameSync(join(root, 'other.txt'), path); + const replaced = await saveFile(again, ['x']); + assert.ok(!replaced.ok && replaced.conflict === 'replaced'); + + const gone = await opened(path); + unlinkSync(path); + const deleted = await saveFile(gone, ['x']); + assert.ok(!deleted.ok && deleted.conflict === 'deleted'); + + const fresh = await opened(join(root, 'fresh.txt')); + writeFileSync(join(root, 'fresh.txt'), 'someone else\n'); + const exists = await saveFile(fresh, ['mine']); + assert.ok(!exists.ok && exists.conflict === 'exists'); + assert.equal(readFileSync(join(root, 'fresh.txt'), 'utf8'), 'someone else\n'); + unlinkSync(join(root, 'fresh.txt')); + const created = await saveFile(fresh, ['mine']); + assert.ok(created.ok && created.file.identity); + assert.equal(readFileSync(join(root, 'fresh.txt'), 'utf8'), 'mine\n'); + assert.deepEqual(readdirSync(root).filter(name => name.includes('.nmsh-')), [], 'no temporary files'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: a new file in a folder reached through a symlink saves again without a false conflict', async () => { + // macOS /tmp and /var are symlinks, and a shell's directory keeps the path as typed. + const root = scratch(); + try { + mkdirSync(join(root, 'real')); + symlinkSync(join(root, 'real'), join(root, 'alias')); + let file = await opened(join(root, 'alias', 'notes.txt')); + assert.equal(file.identity, undefined, 'a new file'); + const first = await saveFile(file, ['one']); + assert.ok(first.ok, first.ok ? '' : first.reason); + file = first.file; + const second = await saveFile(file, ['one', 'two']); + assert.ok(second.ok, second.ok ? '' : `second save: ${second.reason}`); + assert.equal(readFileSync(join(root, 'real', 'notes.txt'), 'utf8'), 'one\ntwo\n'); + assert.ok(lstatSync(join(root, 'alias')).isSymbolicLink(), 'the folder link is kept'); + // The resolved path is still checked: a file replaced after the first save is a conflict, not overwritten. + writeFileSync(join(root, 'real', 'other.txt'), 'theirs\n'); + renameSync(join(root, 'real', 'other.txt'), join(root, 'real', 'notes.txt')); + const replaced = await saveFile(second.file, ['mine']); + assert.ok(!replaced.ok && replaced.conflict === 'replaced'); + assert.equal(readFileSync(join(root, 'real', 'notes.txt'), 'utf8'), 'theirs\n'); + assert.deepEqual(readdirSync(join(root, 'real')).filter(name => name.includes('.nmsh-')), [], 'no temporary files'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: a failure keeps the text and leaves nothing behind', {skip: process.getuid?.() === 0 && 'root ignores permissions'}, async () => { + const root = scratch(); + try { + mkdirSync(join(root, 'd')); + const path = join(root, 'd', 'f.txt'); + writeFileSync(path, 'a\n'); + const file = await opened(path); + chmodSync(join(root, 'd'), 0o555); + const result = await saveFile(file, ['b']); + assert.ok(!result.ok && !result.conflict); + assert.match(result.reason, /permission denied/u); + chmodSync(join(root, 'd'), 0o755); + assert.equal(readFileSync(path, 'utf8'), 'a\n'); + assert.deepEqual(readdirSync(join(root, 'd')), ['f.txt']); + } finally { chmodSync(join(root, 'd'), 0o755); rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Panel -------------------------------------------------------------------------------------------------------- + +const keysFor = (bytes: string): Key[] => decodeKeys(bytes); +function press(state: EditorState, input: string | Key, columns = 80, height = 24) { + let action; + for (const key of typeof input === 'string' ? keysFor(input) : [input]) action = editorKey(state, key, columns, height) ?? action; + return action; +} + +test('panel: the editor keys work from real terminal bytes (Ctrl+S save, Ctrl+Z/Ctrl+Y, Ctrl+F, Ctrl+C/Ctrl+X)', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'a.txt'), 'hello\n'); + const state = createEditor(await opened(join(root, 'a.txt'))); + press(state, '\u001b[F'); + press(state, ' world'); + assert.deepEqual(state.buffer.lines, ['hello world']); + assert.deepEqual(press(state, '\u0013'), {kind: 'save'}); + press(state, '\u001a'); + assert.deepEqual(state.buffer.lines, ['hello']); + press(state, '\u0019'); + assert.deepEqual(state.buffer.lines, ['hello world']); + press(state, '\u0006'); + press(state, 'wor'); + press(state, '\r'); + assert.equal(state.buffer.selectedText(), 'wor'); + assert.deepEqual(press(state, '\u0003'), {kind: 'copy', text: 'wor'}); + assert.deepEqual(press(state, '\u0018'), {kind: 'copy', text: 'wor'}); + assert.deepEqual(state.buffer.lines, ['hello ld']); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('panel: Esc with unsaved changes asks; Discard, Save and Keep editing do what they say; conflicts offer Overwrite or Reload', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'b.txt'), 'b\n'); + const file = await opened(join(root, 'b.txt')); + const clean = createEditor(file); + assert.deepEqual(press(clean, {kind: 'escape'}), {kind: 'close'}, 'nothing to lose: Esc closes'); + const state = createEditor(file); + press(state, 'x'); + assert.equal(press(state, {kind: 'escape'}), undefined); + assert.equal(state.dialog?.kind, 'unsaved'); + assert.match(stripAnsi(renderEditor(state, 80, 24).join('\n')), /unsaved changes[\s\S]*Save[\s\S]*Discard changes[\s\S]*Keep editing/u); + assert.equal(press(state, {kind: 'escape'}), undefined, 'Esc in the question keeps editing'); + assert.equal(state.dialog, undefined); + press(state, {kind: 'escape'}); + assert.deepEqual(press(state, 's'), {kind: 'save', thenClose: true}); + press(state, {kind: 'escape'}); + assert.deepEqual(press(state, {kind: 'right'}), undefined); + assert.deepEqual(press(state, {kind: 'enter'}), {kind: 'close'}, 'Discard was focused'); + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed', reason: 'b.txt changed on disk since you opened it.'}; + assert.match(stripAnsi(renderEditor(state, 80, 24).join('\n')), /changed on disk[\s\S]*Keep editing[\s\S]*Overwrite[\s\S]*Reload, discard mine/u); + assert.deepEqual(press(state, 'o'), {kind: 'save', force: true}); + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed'}; + assert.deepEqual(press(state, 'r'), {kind: 'reload'}); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('panel: hostile file content is drawn inert, the caret and selection are visible without color, and it fits narrow panels', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'h.txt'), 'evil \u001b]8;;https://x\u0007link\u001b[2J and ‮right\n\tindented 中文\n'); + const state = createEditor(await opened(join(root, 'h.txt'))); + press(state, '\u001b[1;2C'); + for (const [columns, height] of [[80, 24], [32, 10], [20, 6]] as const) { + const rows = renderEditor(state, columns, height); + assert.ok(rows.length <= height); + for (const row of rows) { + assert.doesNotMatch(row.replace(/\u001b\[[0-9;]*m/gu, ''), /[\u001b‮]/u); + assert.ok(displayWidth(stripAnsi(row)) <= columns); + } + } + const wide = renderEditor(state, 100, 24).join('\n'); + assert.match(stripAnsi(wide), /evil ␛\]8;;https:\/\/x·link␛\[2J and ·right/u); + assert.match(wide, /\u001b\[7me\u001b\[0m/u, 'the selected first character is reverse video'); + assert.match(stripAnsi(wide), /Ln 1, Col 2 · 1 selected/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/themeFamilies.test.ts b/tests/themeFamilies.test.ts index 281fade2..c326e3f6 100644 --- a/tests/themeFamilies.test.ts +++ b/tests/themeFamilies.test.ts @@ -63,7 +63,8 @@ test('Starship and Oh My Posh imports preview lossy mapping before saving and pr }); 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']); + assert.deepEqual(THEME_FAMILIES.map(family => family.id), ['nmsh', 'catppuccin', 'dracula', 'tokyonight', 'gruvbox', 'rosepine', 'nord', 'solarized', 'onedark', + 'ayu', 'kanagawa', 'everforest', 'flexoki', 'nightowl', 'custom']); for (const family of THEME_FAMILIES.filter(item => item.id !== 'nmsh' && item.id !== 'custom')) { assert.ok(family.source?.startsWith('https://github.com/'), family.id); assert.ok(family.license, family.id); @@ -83,6 +84,18 @@ test('theme families: canonical variants, valid ids, licenses recorded, NMSh the assert.equal(themeVariant('solarizedDark')!.roles.project, '#268bd2'); assert.equal(themeVariant('rosePine')!.roles.project, '#c4a7e7'); assert.equal(themeVariant('oneDark')!.roles.project, '#61afef'); + // v0.21 families, against the pinned upstream files named in docs/design/theme-families.md. + assert.deepEqual(familyVariants('ayu').map(variant => variant.variant), ['Dark', 'Mirage', 'Light']); + assert.deepEqual(familyVariants('kanagawa').map(variant => variant.variant), ['Wave', 'Dragon', 'Lotus']); + assert.deepEqual(familyVariants('everforest').map(variant => variant.variant), ['Dark', 'Light']); + assert.deepEqual(familyVariants('flexoki').map(variant => variant.variant), ['Dark', 'Light']); + assert.deepEqual(familyVariants('nightowl').map(variant => variant.variant), ['Night Owl', 'Light Owl']); + assert.equal(themeVariant('ayuMirage')!.ui.accent, '#ffcc66', 'ayu mirage accent tint'); + assert.equal(themeVariant('kanagawaWave')!.ui.primary, '#dcd7ba', 'fujiWhite'); + assert.equal(themeVariant('kanagawaWave')!.roles.project, '#7e9cd8', 'crystalBlue (fun)'); + assert.equal(themeVariant('everforestDark')!.ui.primary, '#d3c6aa'); + assert.equal(themeVariant('flexokiLight')!.roles.project, '#205ea6', 'blue-600'); + assert.equal(themeVariant('nightOwl')!.ui.selection, '#1d3b53', 'editor.selectionBackground'); assert.equal(NATIVE_PALETTE_IDS.length, 12, 'the NMSh family keeps its twelve themes'); }); @@ -314,3 +327,30 @@ test('color picker: truecolor field and hue, 256-color grid, NO_COLOR channels, try { assert.ok(renderColorPicker(createColorPicker('#ffffff', 'truecolor'), 'x', 50, 'truecolor').some(row => row.includes('+'))); } finally { setIconStyle('nerd'); } }); + +test('Theme Studio: / filters the built-in list by name, family or dark/light; Esc clears before it closes', () => { + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => []}; + const state = createThemeStudio(context); + const press = (...keys: Array<string | {kind: string}>) => { + let action; + for (const key of keys) action = studioKey(state, (typeof key === 'string' ? {kind: 'text', value: key} : key) as never, 'none', '/', context); + return action; + }; + press('/', ...'kanagawa'.split('')); + const shown = plain(renderThemeStudio(state, context, 80, 30, 'none', [])); + assert.match(shown, /Filter: kanagawa▏ · 3 of \d+/u); + assert.match(shown, /Kanagawa Wave[\s\S]*Kanagawa Dragon[\s\S]*Kanagawa Lotus/u); + assert.doesNotMatch(shown, /Lavender Native/u); + press({kind: 'enter'}, {kind: 'down'}); + assert.deepEqual(press({kind: 'enter'}), {kind: 'activate', ref: 'builtin:kanagawaDragon'}, 'selection follows the filtered list'); + assert.equal(state.previewChroma, false); + press({kind: 'escape'}); + assert.equal(state.builtinFilter, undefined, 'Esc cleared the filter'); + press('/', ...'light ayu'.split('')); + assert.match(plain(renderThemeStudio(state, context, 80, 30, 'none', [])), /1 of \d+[\s\S]*Ayu Light/u); + press({kind: 'escape'}, '/', ...'catppuccin latte'.split('')); + assert.match(plain(renderThemeStudio(state, context, 80, 30, 'none', [])), /1 of \d+[\s\S]*Catppuccin Latte/u); + assert.equal(state.previewChroma, false, 'c typed into the filter is text, not the Chroma toggle'); + press({kind: 'escape'}); + assert.deepEqual(press({kind: 'escape'}), {kind: 'close'}); +}); diff --git a/tests/tmpdirHygiene.test.ts b/tests/tmpdirHygiene.test.ts index 4a4c808e..58ff75b3 100644 --- a/tests/tmpdirHygiene.test.ts +++ b/tests/tmpdirHygiene.test.ts @@ -62,6 +62,31 @@ test('suite runner uses short roots, removes only its own root, and reports norm } finally { rmSync(fixture, {recursive: true, force: true}); } }); +test('a test file that stalls with a child still running finishes, reports the stall, and leaves nothing running (#415 CI)', async () => { + const fixture = mkdtempSync(join(tmpdir(), 'runner-')); + try { + const pidFile = join(fixture, 'child.pid'); + const stalled = join(fixture, 'stalled.test.mjs'); + // A piped child keeps the file's process alive after its test is cancelled: without teardown the run never ends. + writeFileSync(stalled, `import test from 'node:test'; import {spawn} from 'node:child_process'; import {writeFileSync} from 'node:fs'; +test('stalls past its timeout', {timeout: 300}, async () => { + const child = spawn('sleep', ['120'], {stdio: ['ignore', 'pipe', 'pipe']}); + writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)); + await new Promise(() => {}); +});\n`); + const started = Date.now(); + const previous = process.env.NMSH_TEST_TEARDOWN_MS; + process.env.NMSH_TEST_TEARDOWN_MS = '500'; + let result; + try { result = await runTestFiles([stalled], [], {stdio: 'ignore'}); } + finally { if (previous === undefined) delete process.env.NMSH_TEST_TEARDOWN_MS; else process.env.NMSH_TEST_TEARDOWN_MS = previous; } + assert.equal(result.code, 1, 'the cancelled test is reported as a failure'); + assert.ok(Date.now() - started < 20_000, 'the run ends instead of waiting for the child'); + const pid = Number(fs.readFileSync(pidFile, 'utf8')); + assert.throws(() => process.kill(pid, 0), 'the child it left running was ended'); + } finally { rmSync(fixture, {recursive: true, force: true}); } +}); + test('sandbox disposal waits for an untracked late writer whose cwd still owns the sandbox', async () => { const sandbox = new LiveSandbox(); const child = spawn(process.execPath, ['-e', "setTimeout(() => require('node:fs').writeFileSync('late-journal.txt', 'done'), 150)"], diff --git a/tests/track.test.ts b/tests/track.test.ts new file mode 100644 index 00000000..83abf4fe --- /dev/null +++ b/tests/track.test.ts @@ -0,0 +1,317 @@ +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 {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {parseWatch, watchSafety, WatchTasks, MIN_LOCAL_MS} from '../src/tasks/WatchTasks.js'; +import {renderWatchPanel, watchNoticeRows, watchPanelKey, watchRow} from '../src/tasks/WatchPanel.js'; +import {setIconStyle} from '../src/ui/glyphs.js'; +import {stripAnsi} from '../src/util/text.js'; + +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (predicate()) { resolve(); return; } + if (Date.now() - started > timeoutMs) { reject(new Error('track did not reach expected state')); return; } + setTimeout(tick, 20); + }; + tick(); + }); +} + +test('/track shares the /watch scheduler and parser; it is flagged for its transcript block', () => { + assert.deepEqual(parseSlashCommand('/track df -h /Volumes/T7Projects'), + {kind: 'watch', op: 'start', arguments: 'df -h /Volumes/T7Projects', track: true}); + assert.deepEqual(parseSlashCommand('/track --every 5s -- docker system df'), + {kind: 'watch', op: 'start', arguments: '--every 5s -- docker system df', track: true}); + for (const text of ['/track', '/track list']) { + assert.deepEqual(parseSlashCommand(text), {kind: 'watch', op: 'list', arguments: '', track: true}); + } + assert.deepEqual(parseSlashCommand('/track refresh 2'), {kind: 'watch', op: 'now', arguments: '2', track: true}); + assert.deepEqual(parseSlashCommand('/track pause'), {kind: 'watch', op: 'pause', arguments: '', track: true}); + assert.deepEqual(parseSlashCommand('/track resume 1'), {kind: 'watch', op: 'resume', arguments: '1', track: true}); + assert.deepEqual(parseSlashCommand('/track stop all'), {kind: 'watch', op: 'stop', arguments: 'all', track: true}); + assert.deepEqual(parseSlashCommand('/watch stop 2'), {kind: 'watch', op: 'stop', arguments: '2'}, + 'existing /watch commands must remain compatible'); + assert.deepEqual(parseSlashCommand('/track copy 2'), {kind: 'watch', op: 'copy', arguments: '2', track: true}); + assert.deepEqual(parseSlashCommand('/watch copy'), {kind: 'watch', op: 'start', arguments: 'copy'}, 'copy is a /track word only'); + assert.deepEqual(parseWatch('df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects'}); + assert.deepEqual(parseWatch('--every 5s -- docker system df'), + {command: 'docker system df', intervalMs: 5000}); + assert.deepEqual(parseWatch('--every 2s -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: MIN_LOCAL_MS}); + assert.deepEqual(parseWatch('--every 1500ms -- docker system df'), + {command: 'docker system df', intervalMs: 1500}); + assert.deepEqual(parseWatch('--every 2 minutes -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: 120000}); + for (const invalid of ['--every', '--every 5s', '--every -1s -- df', '--every 1e100s df', '--every 0s df', + '--every 99999999999999999999999999999999999999m df', '--every 61m df', '--every banana df']) { + assert.ok(parseWatch(invalid).error, invalid); + } +}); + +test('/track classifies disk information as read-only, but never assumes Docker writes are safe', () => { + assert.equal(watchSafety('df -h /Volumes/T7Projects').kind, 'allowed'); + assert.equal(watchSafety('docker system df').kind, 'allowed'); + assert.equal(watchSafety('docker system df -v').kind, 'allowed'); + assert.equal(watchSafety('docker system prune').kind, 'confirm'); + assert.equal(watchSafety('docker system df; rm -rf /tmp/x').kind, 'refused'); + assert.equal(watchSafety('docker system df && docker system prune').kind, 'confirm'); + assert.equal(watchSafety('sudo docker system df').kind, 'refused'); + assert.equal(watchSafety('docker system df $(touch /tmp/unsafe)').kind, 'refused'); + assert.equal(watchSafety('df -h /Volumes/T7Projects $(rm -rf /tmp/x)').kind, 'refused'); + assert.equal(watchSafety('df -h `touch /tmp/x`').kind, 'refused'); + assert.equal(watchSafety('docker system df > /tmp/report').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp &').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp <<EOF').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp && date').kind, 'allowed'); +}); + +test('/track reuses one current and previous snapshot, does not overlap manual refresh', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('sleep 0.3; printf "sample\\n"', cwd, 2000, + {kind: 'allowed', network: false, remote: false}); + assert.equal(engine.runNow(task.id), false, 'a running sample refuses concurrent refresh'); + assert.equal(engine.runNow(task.id), false); + await waitFor(() => task.runs === 1); + assert.equal(task.runs, 1, 'manual refresh must not overlap a running sample'); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous, undefined, 'no artificial previous sample'); + assert.equal(engine.active().length, 1); + assert.match(stripAnsi(watchRow(task, Date.now())), /Track · sleep 0\.3.*updated/u); + assert.deepEqual(watchPanelKey({selected: 0, output: true}, {kind: 'text', value: 'c'}, [task]), + {kind: 'copy', id: task.id}, 'copy targets exactly the selected tracker'); + assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), + /Latest completed output[\s\S]*sample/u); + assert.equal(engine.runNow(task.id), true, 'refresh begins after completion'); + await waitFor(() => task.runs === 2); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous?.output, 'sample\n'); + assert.equal(task.changes.length, 0); + assert.ok(engine.pause(task.id)); + assert.equal(engine.scheduled, 0); + assert.ok(engine.resume(task.id)); + assert.ok(engine.stop(task.id)); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track emits a live refresh-start event before the sample completes', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-refresh-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('sleep 0.2; printf "ready\\n"', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + let notifiedWhileSampling = false; + const unsubscribe = engine.onChange(changed => { + if (changed.id === task.id && engine.sampling(task.id)) notifiedWhileSampling = true; + }); + assert.equal(engine.runNow(task.id), true); + assert.equal(notifiedWhileSampling, true, 'presenter is notified during refresh'); + await waitFor(() => task.runs === 2); + unsubscribe(); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track strips terminal control sequences from the displayed snapshot', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-controls-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('printf "\\033]0;bad-title\\007alpha\\n\\033[31mbeta\\033[0m\\n"', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.equal(task.current?.output, 'alpha\nbeta\n'); + assert.ok(engine.stop(task.id)); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track bounds retained snapshots, preserves split UTF-8 and records truncation', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-bound-')); + const engine = new WatchTasks('/bin/sh'); + try { + const cmd = `node -e 'process.stdout.write("π"); process.stdout.write("x".repeat(250000))'`; + const task = engine.start(cmd, cwd, 2000, {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.ok(task.current?.truncated, 'truncation is visible'); + assert.ok(Buffer.byteLength(task.current?.output ?? '') <= 200100, 'output is bounded'); + assert.ok(task.current?.output.startsWith('π'), 'UTF-8 survives split chunks'); + engine.stop(task.id); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track caps concurrent trackers and rejects non-finite intervals', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-limit-')); + const engine = new WatchTasks('/bin/sh'); + try { + assert.throws(() => engine.start('true', cwd, Infinity, {kind: 'allowed', network: false, remote: false}), /interval/u); + const active = Array.from({length: 8}, () => + engine.start('sleep 1', cwd, 5000, {kind: 'allowed', network: false, remote: false})); + assert.equal(engine.active().length, 8); + assert.throws(() => engine.start('true', cwd, 5000, {kind: 'allowed', network: false, remote: false}), /At most 8/u); + for (const task of active) engine.stop(task.id); + await waitFor(() => engine.scheduled === 0); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track fails visibly when the command or working directory is unavailable', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-fallback-')); + const engine = new WatchTasks('/bin/sh'); + try { + const missingCommand = engine.start('nmsh-nonexistent-test-command-393', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCommand.runs === 1); + assert.notEqual(missingCommand.current?.exitCode, 0); + const missingCwd = engine.start('true', join(cwd, 'not-here'), 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCwd.runs === 1); + assert.notEqual(missingCwd.current?.exitCode, 0, 'invalid cwd cannot be a successful refresh'); + assert.equal(missingCwd.current?.error, `the directory ${join(cwd, 'not-here')} is no longer available`, 'names the directory, not the shell'); + assert.equal(engine.active().length, 2); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track ends what a sample leaves in its process group: after stop, and after a sample that finished', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-orphans-')); + const engine = new WatchTasks('/bin/sh'); + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + // A child that ignores TERM and closes its output: the shell that started it can end while it keeps running. + const stubborn = (name: string) => `sh -c 'trap "" TERM; echo $$ > ${name}; exec >/dev/null 2>&1; exec sleep 30'`; + const pidOf = async (name: string) => { + let pid = 0; + await waitFor(() => { try { pid = Number(readFileSync(join(cwd, name), 'utf8').trim()); } catch { /* not yet */ } return pid > 0; }); + return pid; + }; + try { + const stopped = engine.start(`${stubborn('stopped.pid')}; echo done`, cwd, 5000, {kind: 'allowed', network: false, remote: false}); + const stoppedPid = await pidOf('stopped.pid'); + assert.ok(alive(stoppedPid)); + assert.equal(engine.stop(stopped.id), true); + await waitFor(() => !alive(stoppedPid), 6000); + + const finished = engine.start(`${stubborn('finished.pid')} & echo started`, cwd, 5000, {kind: 'allowed', network: false, remote: false}); + await waitFor(() => finished.runs === 1, 6000); + assert.match(finished.current?.output ?? '', /started/u); + assert.equal(finished.current?.exitCode, 0, 'the sample itself finished normally'); + const finishedPid = await pidOf('finished.pid'); + await waitFor(() => !alive(finishedPid), 6000); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track confirmation refuses clipped small-terminal review', () => { + const confirm = {command: 'unknown status-command', cwd: '/tmp', intervalMs: 5000, reason: 'Unknown command', choice: 'no' as const}; + const rows = renderWatchPanel({selected: 0, output: false, confirm}, [], 30, Date.now(), 6); + const text = stripAnsi(rows.join('\n')); + assert.match(text, /Resize to review tracked/u); + assert.doesNotMatch(text, /Yes|confirm/u, 'clipped layouts cannot display approval controls'); +}); + +// ---- v0.21: the live transcript block ------------------------------------------------------------------------------ + +import {finalResultRows, MAX_TRACK_ROWS, trackBlockRows, trackStatus} from '../src/tasks/trackBlock.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {logicalLines} from '../src/output/TranscriptSearch.js'; +import type {WatchTask} from '../src/tasks/WatchTasks.js'; + +const task = (overrides: Partial<WatchTask> = {}): WatchTask => ({id: 'watch-x', command: 'df -h', cwd: '/', intervalMs: 5000, startedAt: 0, runs: 0, + status: 'running', changes: [], network: false, ...overrides}); +const run = (output: string, extra: Partial<NonNullable<WatchTask['current']>> = {}) => ({output, exitCode: 0, at: 1_000, durationMs: 100, ...extra}); + +test('track status says only what is known: pending, live, failed, timed out, paused, stale, refreshing, stopped', () => { + const plain = (watch: WatchTask, now = 2_000, sampling = false) => stripAnsi(trackStatus(watch, now, sampling)); + assert.match(plain(task()), /^◌ first run pending · every 5s · 0 runs$/u); + assert.match(plain(task(), 2_000, true), /first run…/u); + assert.match(plain(task({current: run('ok'), runs: 1})), /^◉ live · every 5s · updated \d\d:\d\d:\d\d · exit 0 · 1 run$/u); + assert.match(plain(task({current: run('no', {exitCode: 2}), runs: 3})), /exit 2/u); + assert.match(plain(task({current: run('', {timedOut: true, exitCode: null, durationMs: 15_000}), runs: 1})), /timed out after 15s/u); + assert.match(plain(task({current: run('ok'), status: 'paused', runs: 1})), /^‖ paused/u); + assert.match(plain(task({current: run('ok'), runs: 1}), 1_000 + 100 + 16_000), /no new result for 16s/u); + assert.match(plain(task({current: run('ok'), runs: 1}), 2_000, true), /refreshing…/u); + assert.match(plain(task({current: run('ok', {truncated: true}), runs: 1})), /output cut at 200 kB/u); + assert.match(plain(task({current: run('ok'), status: 'stopped', runs: 4})), /^■ stopped/u); + setIconStyle('safe'); + try { + assert.match(plain(task()), /^o first run pending/u, 'Safe glyphs: ASCII marks'); + assert.match(plain(task({current: run('ok'), runs: 1})), /^\* live/u); + assert.match(plain(task({current: run('ok'), status: 'paused', runs: 1})), /^= paused/u); + assert.match(plain(task({current: run('ok'), status: 'stopped', runs: 4})), /^# stopped/u); + assert.match(stripAnsi(watchRow(task({current: run('ok'), status: 'paused', runs: 1}), 2_000)), /^= Track/u); + } finally { setIconStyle('nerd'); } +}); + +test('the live result keeps a stable height: shorter results pad, longer ones say how much is not shown; text is inert', () => { + const first = trackBlockRows(task({current: run('a\nb\nc\n'), runs: 1}), 80, 2_000, 0); + assert.equal(first.reserved, 3); + assert.deepEqual(first.rows.slice(1).map(row => stripAnsi(row)), [' a', ' b', ' c']); + const shorter = trackBlockRows(task({current: run('a\n'), runs: 2}), 80, 2_000, first.reserved); + assert.equal(shorter.rows.length, first.rows.length, 'a shorter refresh never moves what is below'); + const long = trackBlockRows(task({current: run(Array.from({length: 40}, (_, index) => `line ${index}`).join('\n')), runs: 3}), 80, 2_000, shorter.reserved); + assert.equal(long.reserved, MAX_TRACK_ROWS); + assert.match(stripAnsi(long.rows.at(-1)!), /… 29 more lines · \/track copy copies them all/u); + const hostile = trackBlockRows(task({current: run('\u001b[2Jclear\u202e!'), runs: 1}), 20, 2_000, 0); + assert.doesNotMatch(hostile.rows.join(''), /\u001b\[2J|\u202e/u); + for (const row of hostile.rows) assert.ok(stripAnsi(row).length <= 20); + assert.deepEqual(finalResultRows(task({current: run(Array.from({length: 5}, (_, index) => `${index}`).join('\n')), runs: 2}), 3).slice(1), + ['0', '1', '2', '… 2 more lines were not kept']); +}); + +test('the block: stored lines once, live rows replaced in place under them, never stored or searched', () => { + const buffer = new OutputBuffer(); + let watch = task({current: run('first\n'), runs: 1}); + let reserved = 0; + buffer.setLiveRows((id, width) => { const drawn = trackBlockRows(id === 'watch-x' ? watch : undefined, width, 2_000, reserved); reserved = drawn.reserved; return drawn.rows; }); + const anchor = buffer.addTrackBlock('watch-x', '/track df -h', ['⎿ Tracking df -h here']); + const stored = JSON.stringify(buffer.transcript().lines); + const before = buffer.wrapped(80); + const live = before.filter(row => row.derived); + assert.deepEqual(live.map(row => row.plain.trim()).slice(1), ['first']); + assert.ok(live.every(row => row.lineIndex === anchor)); + watch = task({current: run('second\n'), runs: 2}); + const after = buffer.wrapped(80); + assert.equal(after.length, before.length, 'one block, replaced in place'); + assert.deepEqual(after.filter(row => row.derived).map(row => row.plain.trim()).slice(1), ['second']); + assert.equal(JSON.stringify(buffer.transcript().lines), stored, 'refreshes store nothing'); + assert.equal(logicalLines(after).some(line => line.text.includes('second')), false, 'find never matches the live result'); + buffer.clearPresentation(); + assert.equal(buffer.wrapped(80).some(row => row.derived), false); +}); + +test('above the composer: one row per tracker for up to two, then one summary row', () => { + const one = task({current: run('ok'), runs: 1}); + const failing = task({id: 'w2', current: run('no', {exitCode: 2}), runs: 1}); + const paused = task({id: 'w3', current: run('ok'), status: 'paused', runs: 1}); + assert.equal(watchNoticeRows([one, failing], 2_000).length, 2); + const summary = watchNoticeRows([one, failing, paused, task({id: 'w4'})], 2_000); + assert.equal(summary.length, 1); + assert.equal(stripAnsi(summary[0]!), '◉ 4 trackers · 1 failing · 1 paused · /track opens the manager'); + assert.deepEqual(watchNoticeRows([], 2_000), []); +}); diff --git a/tests/trackApp.test.ts b/tests/trackApp.test.ts new file mode 100644 index 00000000..ebcce954 --- /dev/null +++ b/tests/trackApp.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +async function until(check: () => boolean, what: string, timeoutMs = 15_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} +const stored = (app: TerminalApp) => (app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('').trimEnd()); +const live = (app: TerminalApp) => app['output'].wrapped(100).filter((row: {derived?: boolean}) => row.derived).map((row: {plain: string}) => row.plain.trim()); + +test('/track in the app: one block whose live result refreshes in place; /track stop leaves the last result once; /watch keeps its manager', {timeout: 60_000}, async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-trackapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'value.txt'), 'one\n'); + app['handleWatch']('/track --every 2s -- cat value.txt', 'start', '--every 2s -- cat value.txt', true); + assert.equal(app['watchPanel'], undefined, '/track shows its result in the transcript, not the manager'); + await until(() => live(app).includes('one'), 'the first result'); + const linesAfterFirst = stored(app).length; + assert.match(stored(app).join('\n'), /\/track --every 2s -- cat value\.txt\n.*Tracking cat value\.txt here with sh -c/u); + writeFileSync(join(root, 'value.txt'), 'two\n'); + await until(() => live(app).includes('two'), 'the refreshed result', 10_000); + assert.equal(live(app).includes('one'), false, 'replaced, not appended'); + assert.equal(stored(app).length, linesAfterFirst, 'refreshes add no transcript lines'); + app['handleWatch']('/track refresh', 'now', '', true); + app['handleWatch']('/track refresh', 'now', '', true); + assert.match(stored(app).join('\n'), /still being produced; no second one was started/u, 'a refresh while one runs is refused, never overlapped'); + await until(() => !app['watches'].sampling(app['watches'].active()[0]!.id), 'the refresh to finish'); + app['handleWatch']('/track stop', 'stop', '', true); + await until(() => stored(app).some(line => line.includes('Stopped · last result')), 'the final result'); + const text = stored(app).join('\n'); + assert.match(text, /Stopped · last result \d\d:\d\d:\d\d · exit 0 · \d+ runs?\n\s*two/u); + assert.equal(text.split('Stopped · last result').length, 2, 'written once'); + assert.equal(app['trackBlocks'].size, 0, 'a stopped tracker releases its live height bookkeeping'); + assert.match(live(app)[0] ?? '', /^■ stopped/u, 'the live rows end with the stopped state'); + + app['handleWatch']('/watch ls', 'start', 'ls', false); + assert.ok(app['watchPanel'], '/watch opens its manager as before'); + app['watchPanel'] = undefined; + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/trackLive.test.ts b/tests/trackLive.test.ts new file mode 100644 index 00000000..5fc630ba --- /dev/null +++ b/tests/trackLive.test.ts @@ -0,0 +1,126 @@ +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, processAlive, until} from './helpers/liveFrontend.js'; +import {shellAdapter} from '../src/shell/adapters/registry.js'; + +/** /track through the real composer and PTY: the live result updates on screen, copy and stop work, history is not spammed. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /track refreshes one result in place, /track copy copies it, /track stop leaves it once', {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: 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); + writeFileSync(join(sandbox.home, 'value.txt'), 'alpha-1\n'); + let mark = frontend.mark; + frontend.pty.write('/track --every 2s -- cat value.txt\r'); + await frontend.waitFor(/Tracking cat value\.txt[\s\S]*live · every 2s[\s\S]*alpha-1/u, mark); + mark = frontend.mark; + writeFileSync(join(sandbox.home, 'value.txt'), 'alpha-2\n'); + await frontend.waitFor(/alpha-2/u, mark); + // The shell stays usable while the tracker refreshes. + await frontend.run('echo SHELL-STILL-HERE', /SHELL-STILL-HERE/u); + mark = frontend.mark; + frontend.pty.write('/track copy\r'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8') === 'alpha-2\n', 15_000, 'the latest result on the clipboard'); + mark = frontend.mark; + frontend.pty.write('/track stop\r'); + await frontend.waitFor(/Stopped · last result[\s\S]*alpha-2/u, mark); + await frontend.run('echo AFTER-TRACK', /AFTER-TRACK/u); + // The saved transcript holds NMSh's lines and the final result once, never one copy per refresh. + await until(async () => (await sandbox.transcripts().list()).some(session => JSON.stringify(session.transcript).includes('AFTER-TRACK')), 15_000, 'the journal'); + const sessions = await sandbox.transcripts().list(); + const text = sessions.flatMap(session => (session.transcript.lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join(''))).join('\n'); + assert.equal(text.split('alpha-2').length - 1, 1, 'the final result is stored once'); + assert.equal(text.includes('alpha-1'), false, 'earlier refreshes are never stored'); + } finally { await sandbox.dispose(); } +}); + +test('live: several trackers at once (slow, failing, missing, large), resized, then /track stop all ends them and their processes', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const home = sandbox.home; + const script = (name: string, body: string) => { writeFileSync(join(home, name), `#!/bin/sh\n${body}\n`); chmodSync(join(home, name), 0o755); }; + script('slow.sh', 'echo $$ >> slow.pids\nsleep 3\necho SLOW-RESULT'); + script('fail.sh', 'echo FAIL-RESULT\nexit 3'); + writeFileSync(join(home, 'big.txt'), `${Array.from({length: 5_000}, (_, index) => `big-line-${index}`).join('\n')}\n`); + const frontend = sandbox.launch([], {cols: 100, rows: 48}); + // Commands NMSh can't vouch for ask first: Yes starts them. + const confirmed = async (command: string) => { + const mark = frontend.mark; + frontend.pty.write(`/track --every 2s -- ${command}\r`); + await frontend.waitFor(new RegExp(`Track ${command.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&')}\\?`, 'u'), mark); + frontend.pty.write('y'); + frontend.pty.write('\r'); + await frontend.waitFor(/Tracking /u, mark); + }; + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + await confirmed('./slow.sh'); + await frontend.waitFor(/SLOW-RESULT/u, mark); + // A refresh that takes a while shows as refreshing over the previous result (#420), then completes in place. + await frontend.waitFor(/live · every 2s[^\n]*refreshing…/u, mark); + mark = frontend.mark; + await confirmed('./fail.sh'); + await frontend.waitFor(/live · every 2s · updated \d\d:\d\d:\d\d · exit 3/u, mark); + mark = frontend.mark; + await confirmed('nmsh-no-such-command-393'); + await frontend.waitFor(/exit 127/u, mark); + mark = frontend.mark; + frontend.pty.write('/track --every 2s -- cat big.txt\r'); + await frontend.waitFor(/big-line-0[\s\S]*… 4,?989 more lines · \/track copy copies them all/u, mark); + // Four trackers: one summary row above the composer, not one row each. + await frontend.waitFor(/4 trackers · 2 failing · \/track opens the manager/u, mark); + // Narrower and back: the trackers keep drawing, the shell stays usable. + frontend.pty.resize(56, 48); + await frontend.run('echo NARROW-OK', /NARROW-OK/u); + frontend.pty.resize(100, 48); + await frontend.run('echo SHELL-STILL-HERE', /SHELL-STILL-HERE/u); + mark = frontend.mark; + frontend.pty.write('/track stop all\r'); + await frontend.waitFor(/Stopped every tracker/u, mark); + await frontend.waitFor(/(?:Stopped · last result[\s\S]*){4}/u, mark); + const pids = readFileSync(join(home, 'slow.pids'), 'utf8').trim().split('\n').map(Number); + await until(() => pids.every(pid => !processAlive(pid)), 10_000, 'every tracked process to end'); + await frontend.run('echo AFTER-ALL', /AFTER-ALL/u); + } finally { await sandbox.dispose(); } +}); + +for (const shell of ['bash', 'fish'] as const) { + const installed = Boolean(shellAdapter(shell).resolveExecutable(process.env)); + test(`live: /track from ${shell} finds what the live shell's PATH finds, including a change made after startup`, {skip: !supported ? 'unsupported platform' : !installed && `${shell} is not installed`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}); + const tools = join(sandbox.home, 'tools'); + mkdirSync(tools); + writeFileSync(join(tools, 'nmsh-track-tool'), '#!/bin/sh\necho TOOL-FROM-SHELL-PATH\n'); + chmodSync(join(tools, 'nmsh-track-tool'), 0o755); + const frontend = sandbox.launch([], {cols: 100, rows: 40}); + try { + await frontend.waitFor(/Vespyr|notMyShell|bash|fish/u); + await frontend.run('echo READY', /READY/u); + await frontend.run(shell === 'fish' ? `set -gx PATH ${tools} $PATH; echo PATH-SET` : `export PATH="${tools}:$PATH"; echo PATH-SET`, /PATH-SET/u); + let mark = frontend.mark; + frontend.pty.write('/track --every 2s -- nmsh-track-tool\r'); + await frontend.waitFor(/Track nmsh-track-tool\?/u, mark); + frontend.pty.write('y'); + frontend.pty.write('\r'); + await frontend.waitFor(/live · every 2s[\s\S]*TOOL-FROM-SHELL-PATH/u, mark); + mark = frontend.mark; + frontend.pty.write('/track stop\r'); + await frontend.waitFor(/Stopped · last result[\s\S]*TOOL-FROM-SHELL-PATH/u, mark); + await frontend.run('echo AFTER-TRACK', /AFTER-TRACK/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/trackSafety.test.ts b/tests/trackSafety.test.ts new file mode 100644 index 00000000..094cf9f9 --- /dev/null +++ b/tests/trackSafety.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {classifyCommand, splitCommands, writesFiles} from '../src/input/pasteGuard.js'; +import {watchSafety} from '../src/tasks/WatchTasks.js'; + +const kind = (command: string) => watchSafety(command).kind; +const remote = (command: string) => { const safety = watchSafety(command); return safety.kind === 'refused' ? undefined : safety.remote; }; + +test('a redirect into a file is a write even when /dev/null appears elsewhere (paste review and /track)', () => { + for (const command of ['echo pwned > ~/.zshrc 2>/dev/null', 'ls 2>/dev/null >out', 'echo x >/dev/null >out', 'ls >| out', 'ls >&out', 'ls <>out', 'ls &>out', 'ls 1>>log']) { + assert.ok(writesFiles(command), command); + assert.ok(splitCommands(command).some(part => classifyCommand(part.text, part.pipedFrom).includes('modifies')), `paste review: ${command}`); + assert.equal(kind(command), 'refused', command); + } + for (const command of ['ls >/dev/null', 'ls 2>/dev/null', 'npm test 2>&1', 'ls >&2', 'ls 2>&1 | tail -5', 'echo "a > b"', "echo 'x >y'", 'echo a\\>b', 'ls >&-']) { + assert.ok(!writesFiles(command), command); + assert.notEqual(kind(command), 'refused', command); + } + assert.deepEqual(splitCommands('ls >| out').map(part => part.text), ['ls >| out'], '>| is a redirection, not a pipe'); + // POSIX sh (dash) reads `cmd &>/dev/null` as a background job: never repeated, even to /dev/null. + assert.equal(watchSafety('ls &>/dev/null').kind, 'refused'); + assert.equal(watchSafety('ls &>/dev/null').kind === 'refused' && watchSafety('ls &>/dev/null').reason, 'Background operators and heredocs cannot be tracked safely.'); +}); + +test('/track asks before a command that runs another command or changes its environment', () => { + for (const command of ['env rm -rf /tmp/x', 'env -i rm -rf x', 'env -u HOME rm x', 'env -- rm x', 'env FOO=1 df', 'env -S "rm x"', 'FOO=1 df', 'LD_PRELOAD=/tmp/x.so df', + 'GIT_EXTERNAL_DIFF=/tmp/x git diff', 'git -c core.fsmonitor=/tmp/x status', 'git --config-env=core.pager=X log', 'git --exec-path=/tmp status', 'git diff --ext-diff']) { + assert.equal(kind(command), 'confirm', command); + } + for (const command of ['env', 'env | grep PATH', 'env -0', 'printenv HOME', 'git status', 'git -C sub status', 'git log --oneline -5', 'git log -c -1', 'git diff --stat', 'df -h']) { + assert.equal(kind(command), 'allowed', command); + } +}); + +test('/track refuses options that write files, update what they check, never finish, or power off', () => { + for (const command of ['git log --output=x', 'git diff --output x.patch', 'git show --output=f HEAD', 'sort -o out in', 'sort -no out in', 'sort --output=out in', + 'find . -fprint out', 'find . -fls out', 'find . -okdir rm {} ;', 'tree -o out', 'yq -i .a=1 f.yaml', 'npx jest -u', 'npx vitest run -u', 'npm test -- -u', + 'npm run lint -- --fix', 'npx prettier --write .', 'pytest --snapshot-update', 'npx jest --watch', 'scp host:a b', 'rsync -a a b', + 'shutdown now', 'reboot', '/sbin/halt', 'poweroff', 'systemctl reboot', 'launchctl reboot', 'init 0']) { + assert.equal(kind(command), 'refused', command); + } + for (const command of ['sort file', 'sort -n -k2 file', 'find . -name x', 'tree -L 2', 'npx jest', 'npm test', 'npm run lint', 'npx eslint --fix-dry-run .', 'rsync -an a b']) { + assert.notEqual(kind(command), 'refused', command); + } +}); + +test('/track starts only read-only network probes at once; sending, saving, signing in or running packages asks first', () => { + for (const command of ['ping -c1 localhost', 'curl -s http://localhost:3000/health', 'curl -fsSL http://127.0.0.1:8080/', 'curl -s -o /dev/null -w "%{http_code}" http://localhost/', + 'curl -X HEAD http://localhost/', 'wget -qO- http://localhost/x', 'wget -O - http://localhost/x', 'wget --spider http://localhost/', 'nc -z localhost 5432', 'nc -zv localhost 80', + 'dig example.com', 'npm view react version', 'npm outdated', 'npm audit', 'git ls-remote origin']) { + assert.equal(kind(command), 'allowed', command); + } + for (const command of ['curl -X POST http://localhost:3000/x', 'curl -d a=1 http://localhost/x', 'curl --data-binary @f http://localhost/x', 'curl --json {} http://localhost/', + 'curl -o out http://localhost/x', 'curl -sSLo out http://localhost/x', 'curl -O http://localhost/x', 'curl -T f http://localhost/', 'curl -K cfg http://localhost/', 'curl -c jar http://localhost/', + 'curl --request=DELETE http://localhost/x', 'curl gopher://localhost:6379/_FLUSHALL', 'wget http://localhost/x', 'wget -O page http://localhost/x', 'wget -r http://localhost/', + 'http POST localhost:3000', 'ssh host uptime', 'ssh host rm -rf x', 'sftp host', 'telnet host', 'nc localhost 80', 'git fetch', + 'npm publish', 'pnpm publish', 'npm login', 'npm logout', 'npm audit fix', 'npm x cowsay', 'pnpm dlx cowsay', 'bun x cowsay']) { + assert.equal(kind(command), 'confirm', command); + } +}); + +test('/track treats a network target as remote unless it is this computer', () => { + for (const command of ['ping -c 3 -i 0.2 localhost', 'curl -s http://localhost:3000/health', 'curl localhost:3000/health', 'nc -z 127.0.0.1 5432', 'curl -s http://[::1]:8080/']) { + assert.equal(remote(command), false, command); + } + for (const command of ['ping -c1 8.8.8.8', 'ping nas', 'dig @1.1.1.1 example.com', 'curl -s https://example.com/health', 'ssh host uptime', 'npm outdated', 'git fetch', 'curl -s']) { + assert.equal(remote(command), true, command); + } +}); diff --git a/tests/transcriptRecallQa.test.ts b/tests/transcriptRecallQa.test.ts index 323afed9..919bd084 100644 --- a/tests/transcriptRecallQa.test.ts +++ b/tests/transcriptRecallQa.test.ts @@ -115,7 +115,7 @@ test('Setup Cat: a Transcript step after Editor, reusing the transcript rows onc const ids = SETUP_SECTIONS.map(section => section.id); assert.equal(ids.indexOf('transcript'), ids.indexOf('editor') + 1); const transcript = SETUP_SECTIONS[sectionIndex('transcript')]!.rows.map(item => item.row.id); - assert.deepEqual(transcript, ['transcriptPresentation', 'historicalPrompt', 'historyColors', 'historyTheme', 'divider', 'dividerDensity', 'dividerColors', 'outputFolding']); + assert.deepEqual(transcript, ['transcriptPresentation', 'historicalPrompt', 'historyColors', 'historyTheme', 'divider', 'dividerDensity', 'dividerColors', 'outputFolding', 'semanticOutput']); const all = SETUP_SECTIONS.flatMap(section => section.rows.map(item => item.row.id)); assert.equal(all.filter(id => id === 'transcriptPresentation').length, 1, 'Transcript presentation appears once'); assert.equal(all.filter(id => id === 'outputFolding').length, 1); diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts new file mode 100644 index 00000000..903d49cf --- /dev/null +++ b/tests/viewer.test.ts @@ -0,0 +1,205 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {loadFileDocument, outputDocument, resolveViewPath, type ViewDocument} from '../src/viewer/document.js'; +import {createViewer, renderViewer, viewerKey, type ViewerState} from '../src/viewer/ViewerPanel.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const scratch = () => realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-'))); +const text = (value: string): Key => ({kind: 'text', value}); +const press = (state: ViewerState, keys: Array<Key | string>, columns = 80, height = 24) => { + let action; + for (const key of keys) action = viewerKey(state, typeof key === 'string' ? text(key) : key, columns, height); + return action; +}; +const draw = (state: ViewerState, columns = 80, height = 24) => renderViewer(state, columns, height); +const shown = (rows: string[]) => rows.map(row => stripAnsi(row)); +async function load(path: string, options: Parameters<typeof loadFileDocument>[2] = {}): Promise<ViewDocument> { + const result = await loadFileDocument(path, '/', options); + if (!result.ok) throw new Error(result.reason); + return result.document; +} + +test('/view parses a path, an output number or nothing', () => { + assert.deepEqual(parseSlashCommand('/view README.md'), {kind: 'view', target: 'README.md'}); + assert.deepEqual(parseSlashCommand('/view 2'), {kind: 'view', target: '2'}); + assert.deepEqual(parseSlashCommand('/view'), {kind: 'view', target: ''}); + assert.notEqual(parseSlashCommand('/viewer')?.kind, 'view'); + assert.equal(resolveViewPath('~/notes.md', '/work', '/home/me'), '/home/me/notes.md'); + assert.equal(resolveViewPath('docs/a.md', '/work', '/home/me'), '/work/docs/a.md'); +}); + +test('files: only regular, decodable text is read, within limits, and every refusal says why', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'a.ts'), 'const a = 1;\r\nconst b = 2;\r\n'); + const code = await load(join(root, 'a.ts')); + assert.deepEqual(code.lines, ['const a = 1;', 'const b = 2;']); + assert.equal(code.lineEnding, 'crlf'); + assert.equal(code.format, 'text'); + assert.equal(code.language, 'js'); + assert.equal(code.complete, true); + + mkdirSync(join(root, 'dir')); + assert.match((await loadFileDocument(join(root, 'dir'), '/') as {reason: string}).reason, /is a directory/u); + assert.match((await loadFileDocument(join(root, 'missing.txt'), '/') as {reason: string}).reason, /does not exist/u); + writeFileSync(join(root, 'bin'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, 2])); + assert.match((await loadFileDocument(join(root, 'bin'), '/') as {reason: string}).reason, /binary/u); + writeFileSync(join(root, 'utf16.txt'), Buffer.from([0xff, 0xfe, 0x61, 0x00])); + assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16/u); + + if (spawnSync('mkfifo', [join(root, 'pipe')]).status === 0) { + // Opening a FIFO must not wait for a writer: it is refused at once. + const started = Date.now(); + assert.match((await loadFileDocument(join(root, 'pipe'), '/') as {reason: string}).reason, /not a regular file/u); + assert.ok(Date.now() - started < 2000); + } + if (process.getuid?.() !== 0) { + writeFileSync(join(root, 'secret.txt'), 'x'); + chmodSync(join(root, 'secret.txt'), 0o000); + assert.match((await loadFileDocument(join(root, 'secret.txt'), '/') as {reason: string}).reason, /permission denied/u); + chmodSync(join(root, 'secret.txt'), 0o600); + } + + writeFileSync(join(root, 'bad.txt'), Buffer.from([0x61, 0xff, 0x62, 0x0a])); + const bad = await load(join(root, 'bad.txt')); + assert.deepEqual(bad.lines, ['a�b']); + assert.match(bad.notes.join(' '), /Not valid UTF-8/u); + + // A cut never splits a UTF-8 sequence, and says what it left out. + writeFileSync(join(root, 'big.txt'), 'é'.repeat(100)); + const cut = await load(join(root, 'big.txt'), {maxBytes: 51}); + assert.equal(cut.text, 'é'.repeat(25)); + assert.equal(cut.complete, false); + assert.match(cut.notes[0]!, /Showing the first/u); + writeFileSync(join(root, 'many.txt'), Array.from({length: 50}, (_, index) => `line ${index}`).join('\n')); + const many = await load(join(root, 'many.txt'), {maxLines: 10}); + assert.equal(many.lines.length, 10); + assert.equal(many.complete, false); + + symlinkSync(join(root, 'a.ts'), join(root, 'link.ts')); + const linked = await load(join(root, 'link.ts')); + assert.ok(linked.origin.kind === 'file' && linked.origin.symlink && linked.origin.realPath === join(root, 'a.ts')); + assert.match(shown(draw(createViewer(linked), 400)).join('\n'), new RegExp(`→ ${join(root, 'a.ts').replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&')}`, 'u')); + + // A file name is untrusted text too: its controls never reach the transcript or the header as controls. + const hostileName = join(root, 'evil\u001b[2J\u202e.txt'); + const missing = await loadFileDocument(hostileName, '/') as {reason: string}; + assert.doesNotMatch(missing.reason, /[\u001b\u202e]/u); + assert.match(missing.reason, /evil␛\[2J·\.txt does not exist/u); + writeFileSync(hostileName, 'x\n'); + assert.doesNotMatch(draw(createViewer(await load(hostileName))).join('').replace(/\u001b\[[0-9;]*m/gu, ''), /[\u001b\u202e]/u); + + // Reading changes nothing. + assert.equal(readFileSync(join(root, 'a.ts'), 'utf8'), 'const a = 1;\r\nconst b = 2;\r\n'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('hostile text is drawn inert at every width: no terminal controls, no bidi tricks, nothing wider than the panel', () => { + const hostile = ['\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007 \u001b[2J\u001b[31mred', 'admin‮gnp.exe', 'zero​width\ttab', '中文字符 and emoji 🎉', 'x'.repeat(500)].join('\n'); + const document = outputDocument({command: 'cat hostile', output: hostile}, 1); + for (const columns of [32, 80, 120]) { + const rows = draw(createViewer(document), columns, 24); + for (const row of rows) { + const visible = stripAnsi(row); + assert.doesNotMatch(row.replace(/\u001b\[[0-9;]*m/gu, ''), /\u001b/u, 'only NMSh SGR styling remains'); + assert.doesNotMatch(visible, /[‮​]/u, 'format characters are shown as marks'); + assert.ok(displayWidth(visible) <= columns, `row fits ${columns} columns`); + } + const joined = shown(rows).join('\n'); + assert.match(joined, /␛\]8;;https:\/\/evil/u, 'escape sequences are visible text, never links or colors'); + assert.match(joined, /admin·gnp\.exe/u); + } + assert.equal(document.format, 'text', 'command output is never implicitly Markdown'); +}); + +test('Markdown files render, switch to source with m, and output whose command is a diff reads as a diff', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'README.md'), '# Title\n\nSome **bold** text and `code`.\n\n- one\n- two\n'); + const document = await load(join(root, 'README.md')); + const viewer = createViewer(document); + assert.equal(viewer.rendered, true); + const rendered = shown(draw(viewer)).join('\n'); + assert.match(rendered, /Title/u); + assert.doesNotMatch(rendered, /\*\*bold\*\*/u, 'rendered, not source'); + press(viewer, ['m']); + assert.match(shown(draw(viewer)).join('\n'), /\*\*bold\*\*/u, 'source on m'); + const diff = outputDocument({command: 'git diff', output: 'diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n'}, 1); + assert.equal(diff.format, 'diff'); + assert.equal(outputDocument({command: 'cat notes.md', output: '# not markdown here\n'}, 1).format, 'text'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('search finds, counts and steps through matches, wrapping with a note; Esc clears it before closing', () => { + const document = outputDocument({command: 'log', output: Array.from({length: 300}, (_, index) => index % 50 === 7 ? `ERROR at ${index}` : `ok ${index}`).join('\n')}, 1); + const viewer = createViewer(document); + draw(viewer); + press(viewer, ['/', ...'error'.split(''), {kind: 'enter'}]); + assert.equal(viewer.matchCount, 6, 'smart case: a lowercase query ignores case'); + assert.equal(viewer.match?.unit, 7); + draw(viewer); + press(viewer, ['n']); + assert.equal(viewer.match?.unit, 57); + press(viewer, ['N', 'N']); + assert.equal(viewer.match?.unit, 257, 'wrapped to the bottom'); + assert.match(viewer.note ?? '', /wrapped/u); + const rows = shown(draw(viewer)).join('\n'); + assert.match(rows, /6 matches for “error”/u); + assert.equal(press(viewer, [{kind: 'escape'}]), undefined, 'first Esc clears the search'); + assert.equal(viewer.query, undefined); + assert.deepEqual(press(viewer, [{kind: 'escape'}]), {kind: 'close'}); +}); + +test('navigation: page, end, go to line, wrap off with horizontal scroll, line numbers, mouse wheel, copy all', () => { + const lines = Array.from({length: 1000}, (_, index) => `${index + 1}: ${'word '.repeat(30)}`); + const viewer = createViewer(outputDocument({command: 'seq', output: lines.join('\n')}, 3)); + draw(viewer); + press(viewer, [{kind: 'pageDown'}]); + assert.ok(viewer.top > 0); + press(viewer, ['G']); + draw(viewer); + assert.equal(viewer.shown?.last, 999, 'End shows the last line at the bottom'); + press(viewer, [':', '5', '0', '0', {kind: 'enter'}]); + assert.equal(viewer.top, 499); + assert.match(shown(draw(viewer)).join('\n'), /^\s+500 500: word/mu, 'numbered from the source line'); + press(viewer, ['w', {kind: 'right'}, {kind: 'right'}]); + assert.equal(viewer.wrap, false); + assert.equal(viewer.left, 16); + assert.match(shown(draw(viewer)).join('\n'), /‹/u, 'more to the left is marked'); + press(viewer, [{kind: 'wheelDown'}]); + assert.equal(viewer.top, 502); + press(viewer, ['l']); + assert.equal(viewer.numbers, false); + assert.deepEqual(press(viewer, ['c']), {kind: 'copy', text: lines.join('\n'), lines: 1000}); + assert.match(shown(draw(viewer)).join('\n'), /output 3 · 1,000 lines/u); +}); + +test('bounded: a 200,000-line document opens and jumps to its end quickly, and one huge line wraps to a summary', () => { + const big = createViewer(outputDocument({command: 'big', output: Array.from({length: 200_000}, (_, index) => `row ${index}`).join('\n')}, 1)); + const started = performance.now(); + draw(big, 120, 40); + press(big, ['G'], 120, 40); + draw(big, 120, 40); + assert.equal(big.shown?.last, 199_999); + assert.ok(performance.now() - started < 1500, `took ${Math.round(performance.now() - started)}ms`); + const wide = createViewer(outputDocument({command: 'one line', output: 'y'.repeat(1_000_000)}, 1)); + const rows = shown(draw(wide, 80, 400)); + assert.ok(rows.some(row => /more characters on this line/u.test(row))); + assert.ok(rows.length <= 400); +}); + +test('narrow and tiny panels still fit and keep the close control reachable', () => { + const viewer = createViewer(outputDocument({command: 'x', output: 'a\nb\nc'}, 1)); + for (const [columns, height] of [[32, 10], [20, 6], [80, 3]] as const) { + const rows = draw(viewer, columns, height); + assert.ok(rows.length <= height, `${columns}x${height}: ${rows.length} rows`); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns); + } + assert.deepEqual(press(viewer, ['q']), {kind: 'close'}); +}); diff --git a/tests/viewerApp.test.ts b/tests/viewerApp.test.ts new file mode 100644 index 00000000..c755f7aa --- /dev/null +++ b/tests/viewerApp.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +/** A completed command block, as the shell would have produced it. */ +function completed(app: TerminalApp, command: string, output: string, exitCode = 0): number { + const buffer = app['output']; + const startId = buffer.beginCommand(command, [`$ ${command}`]); + buffer.write(output); + buffer.complete(exitCode); + return startId; +} + +async function until(check: () => boolean, what: string): Promise<void> { + const deadline = Date.now() + 10_000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +const transcriptText = (app: TerminalApp) => (app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + +test('/view and View output open the native viewer on stored output; it owns keys until Esc and never writes to the transcript', async () => { + const isolation = isolateConfig(); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + completed(app, 'printf a', 'first\r\n'); + const startId = completed(app, 'printf b', 'second\tline\r\nthird\r\n', 2); + const before = JSON.stringify(app['output'].transcript()); + app['openViewer']('', '/view'); + const viewer = app['viewer']!; + assert.equal(viewer.document.title, 'printf b'); + assert.deepEqual(viewer.document.lines, ['second\tline', 'third'], 'the text /copy copies, tabs as written'); + assert.ok(viewer.document.origin.kind === 'output' && viewer.document.origin.exitCode === 2 && viewer.document.origin.number === 1); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'text', value: 'x'}); + assert.equal(app['editor'].text, '', 'keys belong to the viewer, not the composer'); + app['handleKey']({kind: 'escape'}); + assert.equal(app['viewer'], undefined); + app['openViewer']('2', '/view 2'); + assert.equal(app['viewer']?.document.title, 'printf a'); + app['handleKey']({kind: 'escape'}); + await app['runBlockAction'](startId, 'view'); + assert.equal(app['viewer']?.document.title, 'printf b'); + app['handleKey']({kind: 'escape'}); + assert.equal(JSON.stringify(app['output'].transcript()), before, 'viewing writes nothing to the transcript'); + app['openViewer']('9', '/view 9'); + assert.equal(app['viewer'], undefined); + assert.match(transcriptText(app), /No completed command output at 9\./u); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } +}); + +test('/view <file> reads relative to the shell directory; a missing file is reported in the transcript', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-viewapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'notes.md'), '# Notes\n\nhello\n'); + app['openViewer']('notes.md', '/view notes.md'); + await until(() => app['viewer'] !== undefined, 'the viewer'); + assert.equal(app['viewer']!.document.format, 'markdown'); + assert.equal(app['viewer']!.rendered, true); + app['handleKey']({kind: 'escape'}); + app['openViewer']('missing.txt', '/view missing.txt'); + await until(() => /does not exist/u.test(transcriptText(app)), 'the missing-file notice'); + assert.equal(app['viewer'], undefined); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); + +test('/edit opens a small file, Ctrl+S saves it, Esc closes; e in /view edits the file shown; refusals are reported', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'todo.txt'), 'buy milk\n'); + app['openTextEditor']('todo.txt', '/edit todo.txt'); + await until(() => app['textEditor'] !== undefined, 'the editor'); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'bufferEnd'}); + app['handleKey']({kind: 'text', value: ' and eggs'}); + assert.equal(app['editor'].text, '', 'keys belong to the editor, not the composer'); + app['handleKey']({kind: 'sendToProgram'}); + await until(() => readFileSync(join(root, 'todo.txt'), 'utf8') === 'buy milk and eggs\n', 'the save'); + await until(() => app['textEditor']?.busy === false, 'the save to settle'); + assert.equal(app['textEditor']!.buffer.dirty, false); + app['handleKey']({kind: 'escape'}); + assert.equal(app['textEditor'], undefined); + + app['openViewer']('todo.txt', '/view todo.txt'); + await until(() => app['viewer'] !== undefined, 'the viewer'); + app['handleKey']({kind: 'text', value: 'e'}); + await until(() => app['textEditor'] !== undefined, 'the editor from the viewer'); + assert.equal(app['viewer'], undefined); + app['handleKey']({kind: 'escape'}); + + writeFileSync(join(root, 'blob'), Buffer.from([0, 1, 2])); + app['openTextEditor']('blob', '/edit blob'); + await until(() => /looks binary/u.test(transcriptText(app)), 'the refusal'); + assert.equal(app['textEditor'], undefined); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/viewerLive.test.ts b/tests/viewerLive.test.ts new file mode 100644 index 00000000..ec0910e5 --- /dev/null +++ b/tests/viewerLive.test.ts @@ -0,0 +1,79 @@ +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'; + +/** /view through the real composer and PTY: keys decoded from bytes, the real screen, a stand-in clipboard. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /view reads a file and the latest output; find, go to line, copy all and Esc back to the composer', {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: 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); + writeFileSync(join(sandbox.home, 'notes.md'), '# Field notes\n\nThe **quick** fox.\n\n- alpha\n- beta\n'); + let mark = frontend.mark; + frontend.pty.write('/view notes.md\r'); + await frontend.waitFor(/notes\.md[\s\S]*Markdown · 6 lines[\s\S]*Field notes[\s\S]*quick fox[\s\S]*Esc close/u, mark); + mark = frontend.mark; + frontend.pty.write('m'); + await frontend.waitFor(/\*\*quick\*\*/u, mark); + // Esc closes; the composer is drawn again before anything is typed (a lone Esc followed at once by text is ambiguous). + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + + await frontend.run('seq 1 400', /399[\s\S]*400/u); + mark = frontend.mark; + frontend.pty.write('/view\r'); + await frontend.waitFor(/\$ seq 1 400[\s\S]*output 1 · exit 0 · 400 lines[\s\S]*Lines 1–/u, mark); + mark = frontend.mark; + frontend.pty.write('/37\r'); + await frontend.waitFor(/match(?:es)? for “37”/u, mark); + mark = frontend.mark; + frontend.pty.write(':390\r'); + await frontend.waitFor(/Lines 3\d\d–400 of 400/u, mark); + frontend.pty.write('c'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').length > 0, 15_000, 'the document on the clipboard'); + assert.equal(readFileSync(clip, 'utf8'), Array.from({length: 400}, (_, index) => String(index + 1)).join('\n') + '\n'); + // The first Esc clears the search, the second closes. + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/of 400 +↑↓ scroll/u, mark); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + // Back at the composer: a command runs as usual. + await frontend.run('echo AFTER-VIEW', /AFTER-VIEW/u); + } finally { await sandbox.dispose(); } +}); + +test('live: /edit creates a file, typed text and Ctrl+S from real terminal bytes save it, Esc returns to the composer', {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(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + frontend.pty.write('/edit notes.txt\r'); + await frontend.waitFor(/notes\.txt[\s\S]*new file[\s\S]*Ctrl\+S save/u, mark); + frontend.pty.write('first line\rsecond\tline'); + mark = frontend.mark; + frontend.pty.write('\u0013'); + await frontend.waitFor(/Saved/u, mark); + assert.equal(readFileSync(join(sandbox.home, 'notes.txt'), 'utf8'), 'first line\nsecond\tline\n'); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + await frontend.run('cat notes.txt', /second\s+line/u); + } finally { await sandbox.dispose(); } +});