diff --git a/AGENTS.md b/AGENTS.md index 0fa00eb8..a2ea3a81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,11 @@ 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. +## Graphics and typography (permanent rule) +Any actual image goes through the shared graphics service (`src/graphics/`, [graphics service](docs/design/graphics-service.md)); no surface decodes, scales, uploads, places or deletes an image itself, and none selects a protocol by terminal name. Capability detection uses bounded terminal replies (never the PTY's input), and every surface has a character-art or placeholder fallback with the reason shown. Document and celebration typography goes through `typeset()` ([typography](docs/design/typography.md)): components ask for a role, the backend decides whether OSC 66 sizing is verified; sized text is never written to transcripts, saved documents or the clipboard, and is only drawn when every one of its rows is visible. Markdown and HTML share one heading scale. Menus, tabs, selection controls and chrome are always text. +## 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 @@ -71,6 +76,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). +## Surfaces released in v0.22.0 (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 @@ -86,6 +94,13 @@ runner. GitHub CI provides independent platform verification, not a replacement for local checks. See [development verification](docs/development-verification.md) for sharding, platform gates and exact-release evidence requirements. +**Dispatching CI.** The `CI` workflow has one concurrency group per workflow and ref with `cancel-in-progress`, so a second +run on the same ref cancels the first (a pull request's run uses its own ref, so it does not). Before `gh workflow run` or a +rerun, list the runs for that SHA (`gh run list --workflow CI --branch `) and reuse a queued or running one; the PR's +automatic run is enough unless the exact-release evidence needs the dispatched jobs (timing smokes, Fedora), and then dispatch +once. Follow a run with `gh run watch`, never by dispatching again. A `cancelled` run superseded by a newer one is not a test +failure: report the authoritative run IDs and the SHA, and call CI green only when every required check of those runs succeeded. + ```bash npm run verify:fast npm run verify @@ -130,7 +145,8 @@ Key references: - [ARCHITECTURE.md](ARCHITECTURE.md) — the human-readable architecture overview (runtime and data flows, repository map); deeper detail in `docs/architecture/` and `docs/design/` - [ROADMAP.md](ROADMAP.md) — product direction and issue index - [GitHub Issues](https://github.com/raiseCatError/notMyShell/issues) — actionable work -- [v0.20.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0) — current stable release, merged through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392); v0.19.0 was never published separately +- [v0.22.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.22.0) — current stable release, merged through [PR #444](https://github.com/raiseCatError/notMyShell/pull/444); v0.19.0 and v0.21.0 were never published separately +- [v0.20.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0) — previous stable release, merged through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392) - [PR #303](https://github.com/raiseCatError/notMyShell/pull/303) — the merged cumulative v0.16 release - [PR #328](https://github.com/raiseCatError/notMyShell/pull/328) — merged checkpoint on master; included in the published v0.18.0 release through [PR #345](https://github.com/raiseCatError/notMyShell/pull/345) - [GitHub Project](https://github.com/users/raiseCatError/projects/1) — live development status board 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 `` mean the directory selected by the shared path resolver: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adfa9c6..19110b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,118 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.22.0] - 2026-10-21 + +**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. + +**How this release was verified.** Automated: hosted CI (unit and real-PTY suites on macOS and Ubuntu, Node 22 and 26, a Fedora portability subset and the timing smokes) on the exact release commit, in a pull-request run and a dispatched run, both green. The maintainer accepted that hosted run in place of a local `npm run verify:release` on an 8 GB machine; the one local full run made (3,730 of 3,741 passing) had 11 load-sensitive live-terminal non-passes that passed when rerun alone, which is recorded here as that and not as a clean pass. **Physical: not performed for the nine required passes and the final-addition passes (multiline modes, images, text sizing, the document workspace, `/browser`, the arcade). The maintainer decided to release without them and accepted that risk; that is an acceptance, not a test result.** State, evidence and every open risk are in the [v0.22 physical QA record](docs/testing/v022-physical-qa.md) and the [v0.22 ledger](docs/development/v022-ledger.md). + +### Added + +- **Reviewed multiline pastes run three ways** ([design](docs/design/multiline-execution-modes.md)). *Review commands* now uses NMSh's shared panel components (a mode strip, the selection band, the standard controls) instead of bracketed options. **One at a time** (the default; each command waits in the composer for your Enter), **Queue all** (one confirmation, then the commands run in order; shell commands go through the session's queue and its failure policy, NMSh commands such as `/modules`, `/track` and `/view` run in NMSh and never reach the shell; it stops on a failure, a paused or cleared queue, or Esc, and never types into a running program) and **Run as block** (a shell-only paste as one original submission, with a warning that shells may read a block differently; not offered when NMSh commands are in the list, with the reason). A paste that cannot be separated safely offers Run as block, Edit and Cancel, with Edit as the default. Nothing runs until Enter on a mode. +- **Real images in NMSh** ([design](docs/design/graphics-service.md)). `/view image.png` (PNG, JPEG, GIF first frame, WebP through a converter already on the machine such as `sips`) opens the image in the viewer's own frame: fit with aspect kept, zoom 150–400% with pan, `n`/`p` through the folder's images, `c` copies the path. Markdown and HTML documents draw local images where they are written (inside the document's folder or git project); remote, `data:` and escaping references are never fetched and show `[image: alt]` with the reason; `i` opens a document's image. One graphics service decides how: Kitty graphics with Unicode placeholders where the terminal answers the protocol query, iTerm2 inline images (known host), Sixel where the terminal reports it, and quadrant-block character art everywhere else (Zed, Terminal.app, tmux, SSH, `NO_COLOR`). Settings → General → Images (Auto, Kitty graphics, iTerm2 inline, Sixel, Unicode, Off); `/doctor` shows what was detected and why. SVG is never drawn. +- **Larger headings where the terminal can scale text** ([design](docs/design/typography.md)). Kitty's text sizing protocol (OSC 66) is detected with the specification's cursor-position test, scale and width separately, and used for Markdown `#` and HTML `

`–`

` in `/view` (one 3×, 2×, 1.5× then ordinary-size scale), game titles and celebrations. Body text, code and tables are never enlarged; nothing is sized in a terminal that did not prove it, and sized text is never written to the transcript, a file or the clipboard. Settings → General → Text sizing (Auto, Off, Enhanced). +- **One workspace for Markdown and HTML files** ([design](docs/design/document-workspace.md)). `/view README.md` opens the file's source (read-only navigation); `h` shows the rendered preview, `e` edits the same buffer, `r` refreshes, `Ctrl+S` saves with the editor's atomic save, BOM and line endings kept. The preview shows the *current buffer*, saved or not, and never saves. While typing, `h`, `e` and `r` are text; `Ctrl+O` switches to the preview from anywhere. `/edit file.md` opens the editor directly. A file that cannot be edited safely stays readable in the viewer, with the reason. +- **`/browser`: a real browser in the terminal** ([design](docs/design/browser.md)). Chromium rendering (CSS layout, fonts, animations, JavaScript, SVG, canvas) of a page, shown through the graphics service as Kitty images where the terminal proved them and as character art elsewhere, with tabs, an address bar, back/forward, reload, find, zoom, mouse and keyboard forwarding, and a focus model that never steals typing. It uses a Chrome or Chromium you already have (set `NMSH_BROWSER` for another), starts only when a browser surface opens and stops when idle, in a throwaway profile; nothing is downloaded. Settings → General → Real browser (Auto, Off). With it, the workspace preview is the same Chromium (CSS animations and transitions included, scripts off unless you trust the file with `T`); without it, NMSh's own renderer stays the preview. +- **Vespyr Arcade** ([design](docs/design/arcade.md)). `/games` (also `/game` and `/arcade`; `/game run`, `flight`, `dash`, `sudoku`, `vespyr-doku`): **Vespyr Run**, **Vespyr Flight** and **Vespyr Dash** (endless, deterministic, with course generators proven fair by simulation), **Classic Sudoku** (four difficulties, notes, hints, undo, a unique-solution generator) and **Vespyr Doku** (a cat-placement puzzle on colored regions; one cat per row, column and region, none touching). Colors come from the active theme and Chroma; NO_COLOR, Safe Glyphs and reduced motion are respected; scores and unfinished puzzles stay in a small local file next to the configuration. The arcade never opens over a running command and closes if one starts, so a game can never take a password or a reply meant for a program. + +- **`/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 ``, ``, `
`, ``), 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. + - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. +- **Semantic Output** for test runs and builds ([#386](https://github.com/raiseCatError/notMyShell/issues/386), first phase). A completed block whose output NMSh recognizes gets one overview row with the counts the tool printed (`✔ node --test · 1,772 passed · 0 failed · 25 skipped`, `✘ TypeScript · 3 errors in 2 files`) and, when it listed failures or diagnostics, a details row that opens them, taken verbatim from the output. Recognized: Node's test runner (spec and TAP), Jest, Vitest, pytest, `cargo test`, `go test`, TypeScript, Rust and GCC/Clang diagnostics; anything else stays raw only, and short passing output gets nothing. Details start open when the raw output is folded; click the details row or use Actions → Show or hide details. The output itself, its folding, search, selection and plain `/copy` are unchanged. `/copy --semantic` copies the reading (raw for an output without one, as the note says) and `/copy --both` the raw output and the reading under labelled NMSh separators. Settings → Transcript → Semantic Output turns it off. +- **Native modules: Bun, Nix shell and container** ([#364](https://github.com/raiseCatError/notMyShell/issues/364)). Bun's requested version comes from `.bun-version` or `package.json` (`packageManager`, `engines.bun`) and the active one from its install location; the Nix shell module shows `pure`/`impure` from `IN_NIX_SHELL`; the container module names Docker, Podman, a dev container or Codespaces from marker files and host variables. None runs anything; all are off until enabled in `/modules`, which recommends Bun and Nix when their files are present. +- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. +- **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`. +- **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). +- **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. 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 + +- **Reviewing a multiline paste starts on One at a time, and `q`, `b` and `o` only choose a mode.** The first Enter on the review no longer queues the commands (v0.22 candidates did); choose Queue all (`q`) and press Enter. The old bracketed options are gone. +- **`/view` on an editable Markdown or HTML file opens its source first** (`h` for the rendered view, `m` is no longer needed). Files the editor refuses (too large, read-only, mixed line endings) still open rendered in the read-only viewer. + +- **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. + +### 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. +- `/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 + +- **Images and documents cannot read or reveal what they should not.** Image references from a document are checked lexically and then on the real path against the document's folder or project before any file is opened, so a symlink or `../` out is refused and nothing is learned about paths outside; remote and `data:` references are never fetched; files are read from a checked descriptor with size, pixel and inflation limits; SVG is never rendered; file names are drawn with control characters replaced. +- **The browser preview is isolated.** A document preview is served from memory on a loopback-only server behind an unguessable path, with a Host check, a strict path and file-type allow-list and a restrictive Content-Security-Policy; scripts are off unless you trust the file, and a preview can reach only its own origin (a clicked link is reported, never followed). The browser runs with sandboxing on, no remote debugging port (a pipe), no extensions, downloads and permission prompts denied, a minimal environment and a throwaway profile; previews share no cookies with browsing. +- **A game can never own the keyboard of a running command.** The arcade and the browser refuse to open while a command runs, and close at once if a command starts. +- **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. +- **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 +- Optional: a Chrome or Chromium (version 110 or newer) enables `/browser` and the rendered document preview; NMSh never downloads one, and works fully without. A terminal that answers Kitty's graphics query gets real images; text sizing needs Kitty's OSC 66 (nothing else is scaled). New runtime dependencies: `jpeg-js` (BSD-3, JPEG decoding) and `marked` (MIT, Markdown for the preview; loaded only when a preview opens). +- 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; 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 +- **Reviewed pastes:** press `q` (Queue all) then Enter where you used to press Enter on the review. +- **`/view README.md`** shows the source first; press `h` for the rendered view. +- New settings, all defaulting to Auto: Images, Text sizing, Real browser. Arcade scores are written to `arcade.json` in NMSh's configuration folder only when you play. +- **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 +- `/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)). 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. + - *Typing that reaches a finished command's prompt hook in pieces* (fixed in this release): a line typed after the shell said the command was done, and before its prompt, was held as one approval entry per piece the terminal delivered it in, so a slow macOS prompt hook turned `echo NEXT` into about sixteen entries of fragments (`e`, `c`, `h`...). Nothing ever ran by itself, but approving them ran the fragments. The pieces are put back together as the lines typed (corrections such as Backspace reach the text they correct), and a line left without Enter is held once the typing stops. + - *A line entered the instant a queued command is approved:* a window learns what the shell is doing from the shell's events, which reach it a few milliseconds after the shell acts (longer on a loaded machine). A line submitted in that gap, before the window has seen the approved queue entry start, runs after it, once and in order, with nothing lost or run unapproved, but the window attributes the first prompt to the line it submitted: the queued command's transcript record is missing and the submitted line is recorded twice. Found by a live test that typed within about ten milliseconds of the approval; the test now waits for the approved command's record. + - *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. +- NMSh's own HTML renderer (the fallback preview when no browser is installed or the setting is Off) shows a subset: no CSS layout, forms or embedded media. With a browser installed the preview is Chromium's. +- **Hosted macOS live-terminal tests are intermittent.** Over the last two days about 4 in 10 of the repository's hosted CI runs passed on the first attempt, before and after this integration; the failures were live-PTY tests on macOS (typing-after-read, input ownership, queue ownership) and, before they were fixed, two multiplexer tests. Two causes were found and fixed (test timing assumptions; typing held in pieces after a command was done), but the cause of the remaining macOS failures is not established, and they are not shown to be harmless. A failed hosted job on a pull request that passes on the dispatched run is that known instability and not by itself a regression; a failure that repeats on a rerun is not. +- **Not physically verified (NOT RUN):** Kitty graphics and Unicode placeholders in Ghostty and Kitty, OSC 66 text sizing in any terminal, character art in Zed and Terminal.app, iTerm2 and Sixel output, Chromium frames in any terminal, and play of the five games. Automated tests cover the protocol output, fallbacks and game rules, not how any of it looks or feels in a real terminal. See [v0.22 physical QA](docs/testing/v022-physical-qa.md). +- Ghostty accepts text sizing sequences but is not known to draw scaled glyphs, so Auto leaves it off there; Enhanced is the explicit override. +- Images are never fetched from the network, animated images show their first frame, and WebP needs a converter that is already installed (`sips` on macOS, `dwebp`, ImageMagick or `ffmpeg`). +- Inside tmux, screen and Zellij images are character art and text is not scaled (graphics would need passthrough, which NMSh does not use). +- The browser preview refreshes when you ask (`r`); there is no split view, so no automatic refresh while typing. Leaving the preview closes its page; the scroll position is kept across refreshes only. `/browser` forwards what a terminal can express: no smooth scrolling, gestures or file pickers, and downloads are refused. +- 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. 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 **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. @@ -608,7 +720,8 @@ Session & Interaction UX, plus the appearance and customization system. ### Fixed - 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 +[Unreleased]: https://github.com/raiseCatError/notMyShell/compare/v0.22.0...HEAD +[0.22.0]: https://github.com/raiseCatError/notMyShell/compare/v0.20.0...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/CONTRIBUTING.md b/CONTRIBUTING.md index 14e2edcd..c5deaaa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Before starting work, check: - **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the pieces fit together; start here before changing a subsystem - **[ROADMAP.md](ROADMAP.md)** — product direction and what is planned - **[GitHub Issues](https://github.com/raiseCatError/notMyShell/issues)** — concrete actionable work; acceptance criteria in each issue are authoritative -- **[v0.20.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0)** — current stable release, merged through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392) (see [ROADMAP.md](ROADMAP.md)) +- **[v0.22.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.22.0)** — current stable release, merged through [PR #444](https://github.com/raiseCatError/notMyShell/pull/444) (see [ROADMAP.md](ROADMAP.md)) - **[CHANGELOG.md](CHANGELOG.md)** — the authoritative record of notable changes; see [Changelog](#changelog) below - **GitHub Issues** — [raiseCatError/notMyShell issues](https://github.com/raiseCatError/notMyShell/issues) — public bug reports, proposals and development status diff --git a/README.md b/README.md index 8861926e..20e2de2e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ **notMyShell (NMSh)** runs your real zsh, Bash or Fish in a persistent session and gives it a better front end: a composer that stays put, semantic highlighting, a readable transcript, live command feedback, sessions that survive closing the window, and themes that can reach the tools you use. -Latest 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. +Latest stable release: [v0.22.0 — Native viewing, images, a real browser and Vespyr Arcade](https://github.com/raiseCatError/notMyShell/releases/tag/v0.22.0), released 2026-10-21: `/view` and `/edit`, images, a Markdown and HTML workspace with an optional Chromium preview, `/browser`, Vespyr Arcade, three ways to run a reviewed multiline paste, Semantic Output, `/track`, safer typing while commands run, history browsing, Ask command suggestions from your local model, more native modules and theme families. It includes the v0.21.0 candidate, which was never published separately. The `master` branch includes this released work; see the [changelog](CHANGELOG.md#0220---2026-10-21) for its scope, upgrade notes and known limitations, including what was not verified in a physical terminal. The previous stable release is [v0.20.0](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0). ## See notMyShell in motion @@ -60,6 +60,8 @@ NMSh is a **frontend**. Your shell stays underneath and does what it always did: - **Theme Studio and Theme Bridge** — built-in, imported and custom themes in `/theme`; opt-in `/theme-bridge` carries the active theme to fzf, less/man, file listings, bat, tmux, Vim, Neovim and Helix through files NMSh owns and you review. - **Curated tools** — `/tools` finds, explains and (on request) installs a short list of shell tools; `/providers` picks the picker, history, navigation and suggestion providers; `/tmux` and `/dotfiles` import settings safely. - **Shell frameworks, handled carefully** — Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote are detected; shell config is treated as code, never as harmless data. +- **Documents, images and a real browser (v0.22)** — `/view` opens Markdown and HTML as one workspace with a source, an editor and a rendered preview of the same buffer; images are drawn in Ghostty and Kitty and as character art everywhere else; `/browser` shows a real Chromium page when you have Chrome installed (nothing is downloaded). Larger headings appear in terminals that prove they can scale text. +- **Vespyr Arcade (v0.22)** — `/games` (`/game`, `/arcade`): three endless Vespyr games, Classic Sudoku and Vespyr Doku, with local scores and no accounts. - **Keep Awake** — `/zoomies` (also `/caffeinate`, `/awake`) keeps the machine or display awake through the OS's own mechanism and shows that it is on, quietly. - **Personality, optional** — Chroma color treatments, motion, screensavers and Vespyr, the NMSh cat. All of it respects Reduced Motion, Safe glyphs and `NO_COLOR`. @@ -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 issues](https://github.com/raiseCatError/notMyShell/issues); 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,8 @@ 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) | `/view` (files, images and completed output; Markdown and HTML open as source, editor and preview of one buffer), `/edit`, `/browser` (a real browser, optional), `/track` (one live result of a command), `/copy --semantic` | +| Games (v0.22) | `/games` (`/game`, `/arcade`; `/game run`, `flight`, `dash`, `sudoku`, `vespyr-doku`) | | 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` | @@ -225,6 +229,7 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability. - Powerlevel10k's right prompt, instant prompt and gitstatus daemon are not reproduced by the provider. - Theme Bridge recolors new tool instances; editors and shells already running outside NMSh are not recolored live. delta is never managed (NMSh leaves git config alone); its syntax highlighting follows bat's NMSh theme through `BAT_THEME` unless your git config pins it, and its diff colors stay yours. - Hosts without mouse reporting scroll the transcript with PageUp/PageDown. +- Images, scaled headings, the Chromium frames and the games are covered by automated tests but have not been physically verified in a real terminal; character art is the fallback everywhere a graphics protocol is not proven. See the [v0.22 physical QA record](docs/testing/v022-physical-qa.md). ## Documentation @@ -233,6 +238,7 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability. - [Context Modules](docs/architecture/context-modules.md) — facts, routing, Context Rail and declarative pack direction - [Demo gallery](docs/demos.md) — every feature clip in one place - [ShellAdapter](docs/architecture/shell-adapter.md), [platforms](docs/architecture/platforms.md), [terminal stack](docs/architecture/terminal-stack.md) +- [Graphics service](docs/design/graphics-service.md), [typography](docs/design/typography.md), [document workspace](docs/design/document-workspace.md), [real browser](docs/design/browser.md), [Vespyr Arcade](docs/design/arcade.md), [multiline execution modes](docs/design/multiline-execution-modes.md) - [Themes, imports and Theme Bridge](docs/design/theme-bridge.md), [Chroma and UI chrome](docs/design/chroma-and-ui-chrome.md), [idle visuals](docs/design/idle-visuals.md) - [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Support](SUPPORT.md) diff --git a/ROADMAP.md b/ROADMAP.md index 224e77e7..41a4a19a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,17 +4,17 @@ | | | |---|---| -| **Current release** | [v0.20.0 — Terminal workflow, first-run setup, background tasks & native modules](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0) — released 2026-10-18 through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392); physical QA partial ([record](docs/testing/v020-physical-qa.md)) | -| **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) | +| **Current release** | [v0.22.0 — Native viewing, images, a real browser and Vespyr Arcade](https://github.com/raiseCatError/notMyShell/releases/tag/v0.22.0) — released 2026-10-21 through [PR #444](https://github.com/raiseCatError/notMyShell/pull/444); includes the never-published v0.21.0 candidate; physical QA partial and its remainder waived by the maintainer ([record](docs/testing/v022-physical-qa.md), [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 | +| **Previous release** | [v0.20.0 — Terminal workflow, first-run setup, background tasks & native modules](https://github.com/raiseCatError/notMyShell/releases/tag/v0.20.0) — released 2026-10-18 through [PR #392](https://github.com/raiseCatError/notMyShell/pull/392); includes the v0.19.0 candidate, never published separately (physical QA partial, [record](docs/testing/v020-physical-qa.md)) | +| **Earlier 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. | | **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.22.0. ## Native modules and Context Engine — released foundation, ongoing expansion @@ -158,6 +158,20 @@ 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 | +## Released — v0.22.0 Native viewing, images, a real browser, Vespyr Arcade, 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. It also ships the multiline paste modes, the graphics service and image viewing, OSC 66 headings, the Markdown/HTML workspace, `/browser` and Vespyr Arcade +(see the [ledger](docs/development/v022-ledger.md)). Released 2026-10-21 through [PR #444](https://github.com/raiseCatError/notMyShell/pull/444); the +physical QA that was not run is listed in the [QA record](docs/testing/v022-physical-qa.md). 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/SECURITY.md b/SECURITY.md index 56f9789a..fc3482ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ Security is critical for NMSh, as it executes and presents shell commands and ha ## Supported Versions -Security fixes target the latest stable release (currently v0.20.0, the `master` branch) and the active development branch. Older releases are not patched separately; update with `/update`. +Security fixes target the latest stable release (currently v0.22.0, the `master` branch) and the active development branch. Older releases are not patched separately; update with `/update`. ## Scope @@ -31,6 +31,9 @@ Reports that break any of these are in scope: - **External prompt providers** (Starship, Oh My Posh, Powerlevel10k) run as bounded, non-interactive helpers with argv only; their output is treated as untrusted display text. - **Context discovery is not execution.** Entering a directory never runs repository-controlled code, sources `.envrc`, runs kubeconfig `exec` plugins, calls the network or reads credential values. Versions come from install layouts and metadata; executables on PATH are never run for context, and one inside the current workspace is not even inspected. - **Context Packs are data.** A pack names capabilities NMSh core implements and describes presentation; it cannot contain commands, code, paths, templates, includes or hooks, gains no authority by being installed, and is never installed or enabled because a repository was opened. +- **Documents and images are data.** Opening a Markdown, HTML or image file never runs it: remote and `data:` image references are never fetched, image paths must stay inside the document's folder or project (checked before any file is opened and again on the real path), images are decoded with size, pixel and inflation limits, SVG is never rendered, and a converter for WebP runs without a shell in a private directory. +- **A real browser is a different trust class from a document.** `/browser` runs ordinary web pages in a sandboxed Chrome or Chromium of yours with no debugging port, no extensions, denied downloads and permissions, and only `http`, `https`, `file` and `about:blank` addresses; a document preview is served from memory on a loopback-only server behind an unguessable path with scripts off unless you trust the file, a restrictive CSP and no access beyond its own origin. NMSh never downloads a browser. +- **Nothing takes the keyboard of a running command.** The arcade and the browser panel do not open while a command runs and close if one starts. - **Terminal text is hostile.** Directory names, Git metadata, pack metadata and agent reports pass one display boundary before reaching the screen or a terminal title: no control characters, escape sequences or bidi formatting. ## Reporting a Vulnerability diff --git a/docs/architecture/image-surface.md b/docs/architecture/image-surface.md index 6cf2c111..decaa3a2 100644 --- a/docs/architecture/image-surface.md +++ b/docs/architecture/image-surface.md @@ -1,3 +1,5 @@ +> The About logo overlay described here is one use of the shared graphics service: see [graphics service](../design/graphics-service.md) and [typography](../design/typography.md). + # Image surface `src/presentation/ImageSurface.ts` is NMSh's one raster path. The terminal diff --git a/docs/design/arcade-dash.md b/docs/design/arcade-dash.md new file mode 100644 index 00000000..1c3c1028 --- /dev/null +++ b/docs/design/arcade-dash.md @@ -0,0 +1,92 @@ +# Vespyr Dash + +A rhythm platformer for the Vespyr Arcade (`/game dash`, alias `vespyr-dash`). Vespyr, the NMSh cat, rides a glowing board +across a scrolling course at a steady pace and jumps spikes, gaps and platforms in time. The mechanics are those of the +familiar one-button rhythm platformers; the look, the patterns and the course are original. + +## Controls and rules + +| Key | Effect | +|---|---| +| Space or Up | Start (from *ready*) and jump. A press while standing jumps. A press in the air does nothing now but is remembered for 6 steps and jumps on touchdown. | +| p | Pause (shared pause menu). Esc pauses a run, leaves a game not yet started. | + +There is no key release in a terminal, so jump height is fixed: one press, one full jump. Touching a spike's core, the side +of a platform or the far wall of a gap, or falling out of the world, ends the run. Landing on top of a platform is safe and +the cat rides along it, then drops off its end. Score is distance (1 point per 5 px, 1 m = 10 px) plus 10 per pattern +cleared, as a five digit counter (`typeset({role: 'counter'})`) that lights up every 100 points. A new best sets `result.record`. + +## Physics (`physics.ts`) + +Fixed step 1/60 s, semi-implicit Euler, world pixels (one column wide, half a row tall). `GRAVITY = 360`, `JUMP_V = 88`: +apex 10.03 px, 29 steps (0.48 s) in the air, 29 x speed/60 px of reach (29 px at 60 px/s, 51 px at 105 px/s). +`stepBody` is the single rule set, used by the game, the generator's proof and the tests. + +* Hitbox: 5 x 5 inside the 7 x 6 sprite (the ear tips do not hurt). +* Surfaces: the ground (everywhere except gaps) and blocks. A surface above the feet is a wall unless it is within 1 px + (`SNAP_PX`, a corner is forgiven) or the feet were above it a step earlier (a landing). Touching edges is clear. +* Spikes are drawn 6 x 4 and hurt in their 4 x 3 core. +* Speed rises in four levels, 60, 75, 90, 105 px/s, one per 1800 px, then stays at 105. Each is a multiple of a quarter pixel + per step, which makes the cat's step grid align with the whole-pixel course in a handful of ways only (see below). + +## Generation and the fairness proof (`course.ts`, `solver.ts`) + +The course is built from a seeded stream (`rng.fork('course')`, fixed draws per pattern). Templates: a row of 1 to 3 spikes +(optionally spaced), a gap (12 px up to `GAP_MAX`), a platform (4, 6 or 8 px), a pole, a staircase of two platforms, an island +(a platform standing in a gap) and a perch (a platform with spikes at its far end). Which appear, their size and their spare room +depend on distance (`difficultyAt`) and on the speed they will be met at (islands, stairs and perches are sized from the px per +step so there is room to land, react and take off again). + +Because the cat scrolls at a speed that depends only on distance, its position at step t is the same for every player. A +grounded state is therefore a step index, and `solve()` runs `stepBody` to find, for every step, where running on and +jumping lead (`next`, `jump`). Then, backwards: + +* a touchdown is *robust* when, after `REACT_STEPS = 12` more steps of running (landing plus reaction), the player is at a robust step; +* a step is robust when a jump at each of at least `WINDOW_STEPS = 12` consecutive steps (0.2 s) lands robustly, or running on reaches a robust step. + +A pattern is only placed (moved right pixel by pixel from the least safe position, plus random spare room that is itself +re-checked) when the whole stretch since the last rest is robust. The cat can meet the course at any of 4 to 7 alignments of its +step grid (every quarter pixel up to one step), and all of them are checked. A *rest*, a flat stretch of `REST_PX` after at most +5 patterns, lets any jump land and the reaction pass, so the proof restarts from a standing cat and the induction is exact. +Gaps never exceed `maxGapAt(speed)` (the width that still leaves a 13 step window, `AIR_STEPS + (PW - gap)/perStep`) less 4 px at +the cap; platform tops stay at least 1.5 px under the apex. Pattern `window` (the first jump's takeoff window in isolation) is kept per unit. + +## Presentation + +Half-block pixel scene over a themed gradient. Tints come from `ArcadePalette` roles (accent, success, warning, soft accent, +failure) blended with `mixRgb`: a new color phase every 80 m, blended in over 140 px. Platform tops glow where the cat lands (fades in 0.36 s). +A glowing trail (`Trail`), takeoff and landing sparks, an impact burst, flash and one-cell shake on death, floating score and +phase popups. A metronome runs on distance (one beat per 48 px): the sky and parallax layers pulse faintly and a tick in the HUD +alternates `•`/`·`. Two parallax layers of sharp triangles and pillars and, at speed, a few streaks. +Particles are capped at 120 and the trail at 20 points. + +## Accessibility + +* NO_COLOR or Safe Glyphs: the decorative backdrop, trail and particles are dropped; spikes become `▲` (`^` in Safe Glyphs), platforms + and ground are solid blocks with a textured row, gaps are empty, and the cat is the NMSh ASCII cat. +* Reduced motion: no pulses, flashes, shake, streaks, parallax drift, landing glow or popups; a shorter hold after a crash. +* Effects `off`: no particles and no trail; `subtle` halves counts. +* Canvases under 40 x 12 show `Needs at least 40x12` (the course is not shown narrower than the reaction distance needs). + +## Record (`arcade.json` -> `dash`) + +`{best: number (score), bestDistance: number (meters), attempts: number}`, each validated on read (finite, nonnegative, +floored, capped at 1e9, otherwise 0). A finished run updates all three; `persist()` of an unfinished run only raises `best`/`bestDistance`. +Hub summary: `Best 420 · 210 m`. Result details: distance, best, attempts. + +## Tests + +`tests/arcadeDashPhysics.test.ts`: jump arc numbers per speed, speed ramp and cap, platform landing vs side hit and corner +forgiveness, spike hitbox edges, gap falls and crossings, gap maths per speed, templates proven at every phase, thousands of +generated pieces across seeds and speeds (spacing, integer positions, whole-course proof at every phase, and an independent forward +search of all press sequences in `tests/support/dashCheck.ts`), rests, bots on the real world (`tests/support/dashBot.ts`) pressing +early, middle, late and at random in the computed windows for 20,000 steps at the speed cap and from the start (an idle or spamming player dies), +determinism and the jump buffer. `tests/arcadeDashGame.test.ts`: state machine, scoring, crash and result, records and corrupt data, +persistence, pause/restart through the session, color phases, sprites, effects, reduced motion, NO_COLOR/Safe Glyphs, resize 20x8 to 200x60. + +## Limits + +Automated tests are not gameplay verification: feel (jump length, speed steps, rest frequency) and the look in real terminals +and themes need hands-on play. The proof guarantees a window of 12 steps in context for a player who acts inside it; a human +who jumps at the geometric edge of a hazard's window may land where the next pattern is tighter. Generating a pattern takes a few +milliseconds at the moment it is needed. 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/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/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/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/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/docs/design/browser-engine.md b/docs/design/browser-engine.md new file mode 100644 index 00000000..9720d4ae --- /dev/null +++ b/docs/design/browser-engine.md @@ -0,0 +1,139 @@ +# Browser engine and preview server + +`src/browser/engine/*` implements the `BrowserEngine`, `BrowserPage` and `PreviewServer` contracts of `src/browser/types.ts` on an +installed Chromium. Nothing else in NMSh depends on Chromium. Nothing is downloaded: with no browser installed `available()` says what +to install or set and everything else keeps working. + +## What owns what + +| Piece | File | Responsibility | +|---|---|---| +| Locator | `chrome.ts` | Finds Chrome, Chromium or Edge and checks `--version` (major 110 or newer). `NMSH_BROWSER` (absolute, executable) wins and is never second-guessed. macOS: the three `/Applications` bundles. Linux: `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, `microsoft-edge` on `PATH`, in that order. | +| Protocol | `cdp.ts` | DevTools Protocol over `--remote-debugging-pipe`: UTF-8 JSON, one NUL after each message, written to the child's fd 3 and read from fd 4. Ids, per-request timeout (15 s), events, flat sessions. Buffering is bounded: one message over 32 MB tears the connection down. Closing the pipe rejects everything pending. | +| Process | `engine.ts` | One lazily started process tree, idle shutdown, `shutdown()`, relaunch after a crash. | +| Page | `page.ts` | One DevTools target plus NMSh's policy: URL rules, frames, input, find, zoom, dialogs, popups, downloads. | +| Preview | `preview.ts` | The loopback server behind a document preview. | +| Markdown | `markdownHtml.ts` | `markdownToHtml` and `htmlDocumentFor`. | + +## Process + +The engine starts on the first `open`, is shared by every page and stops by itself `idleMs` (default 60 s) after the last page closes +(a new `open` cancels the timer, which is unref'd). `shutdown()` sends `Browser.close`, waits 2 s, then SIGTERM, 2 s, then SIGKILL; an +`exit` hook on NMSh's own process kills the browser synchronously so none is left behind. If the browser dies by itself every page gets +`crashed` then `closed`, `stats()` reports it stopped and the next `open` launches a fresh one. + +The browser sees almost nothing of NMSh: the child environment is `PATH`, `LANG` and `TMPDIR` from `options.env` and `HOME` set to +the profile directory (created `0700`). Flags: `--headless=new --remote-debugging-pipe --user-data-dir=`, extensions, sync, +background networking, component updates, breakpad and default apps off, audio muted, two renderers at most, Translate / MediaRouter / +OptimizationHints off, a fixed colour profile, `--deny-permission-prompts`, and a mock keychain (macOS) or basic password store (Linux) +so the throwaway profile never touches the person's keychain. The sandbox stays on; `NMSH_BROWSER_NO_SANDBOX=1` adds `--no-sandbox` +and exists only for CI containers where the sandbox cannot start. Downloads are denied at the browser and at every preview context. + +Every new page target is auto-attached paused. A target that has an opener (a window a page opened) is closed before it requests +anything; the pages NMSh creates are resumed at once. So a popup never loads, and the opener's `Page.windowOpen` becomes a `popup` +event (web) or a `blocked` event (preview) for the panel to act on. Each page is opened in its own window (`newWindow`) so every page is +visible and renders and screenshots independently. + +## Pages + +- `web` pages use the default (persistent) context. Only `http:`, `https:`, `file:` and `about:blank` are navigated. The requested + address is checked before `Page.navigate`, and the committed main-frame URL after every navigation (a redirect or link to `blob:`, + `data:`, `chrome:`, `view-source:` and so on ends on `about:blank` with a `blocked` event). Chrome's own error page for a failed load is + allowed and shown under the address that failed. +- `preview` pages each get a throwaway browser context, disposed on close, so they share no cookies or storage with browsing. Network + requests are intercepted (`Fetch`): only the preview's own origin passes (plus `data:` images and fonts); everything else fails with + `BlockedByClient` and a `blocked` event. A refused main-frame navigation is aborted, so the preview stays where it was. Requests the + preview's CSP stops never reach the network layer; those are reported from Chrome's security log entries. + +### Frames + +The screencast starts with the first frame listener and stops with the last. A screencast frame is always in CSS pixels, so at 1:1 it is +used as is and otherwise (device scale, zoom) the moment is captured again with `Page.captureScreenshot` so every frame is +`viewport * scale` pixels, as the contract says. Delivery is one frame at a time: while a listener is still working, newer frames are +skipped, and the acknowledgement that asks Chrome for more is held until every listener has settled. A slow consumer therefore sees fewer +frames and nothing queues. The size reported in `Frame` is read from the PNG header. + +### Zoom + +The page is told a viewport of `viewport / zoom` CSS pixels with a device scale of `scale * zoom`, so the frame size is constant for +any zoom (0.25 to 5). Mouse coordinates and wheel deltas are divided by the zoom. + +### Input + +Mouse maps to `Input.dispatchMouseEvent` (move, press, release with a held-buttons mask, wheel). A key `down` of a printable character +is a `keyDown` with its text (which types it); other keys are `rawKeyDown` with the Windows virtual key code Chrome needs for scrolling +and editing; with Ctrl or Meta a few letters carry the editing command (`selectAll`, `copy`, `paste`...). `up` is `keyUp`. `char` sends a +`char` event: a caller sends either `down` with `text` or `char`, not both. `insertText` is `Input.insertText`. + +### Editable focus + +With scripts on, a script added to every new document (and a binding with a random name per page, removed from `window` once captured) +reports whether the focused element is a text field, a `textarea`, a `select` or content-editable. It becomes `editable` events, so the +panel knows keys belong to the page. Without scripts there is nothing to report and `editable` stays false. + +### Find + +`find()` runs in a DevTools isolated world (`Page.createIsolatedWorld` and `Runtime.evaluate`). This is not page script: it works on +previews with scripts disabled, which was verified against Chrome 156. It counts case-insensitive matches in visible text (hidden +elements, `script`, `style` and `textarea` are skipped), selects the next one (the selection is the highlight) and scrolls it to the +middle. The same query steps forward or (with `backwards`) back and wraps; a new query starts at the first (or last) match. `clearFind()` +clears the selection. The world is recreated after a navigation. + +### Titles + +`Target.targetInfoChanged` is not sent for every title change (a script setting `document.title` can go unreported), so the title is +also read with `Target.getTargetInfo` after each load and every 500 ms while the page is open. Titles and addresses shown to the person +have C0, C1 and bidi controls stripped. + +### Dialogs, popups, downloads + +`alert` is accepted; `confirm`, `prompt` and `beforeunload` are dismissed. Each emits `dialog` with the message. A download is denied and +emits `download-blocked` with its address. + +## Preview server + +One `node:http` server on `127.0.0.1` (port 0) for all handles. A handle's address is `http://127.0.0.1://` with a +16-byte random token; `dispose()` invalidates it, `close()` stops the server. Only `GET` and `HEAD`; `Host` must be `127.0.0.1:` +or `localhost:` (else 421, the DNS-rebinding defence); `Sec-Fetch-Site: cross-site` is refused; at most 32 connections; header and +request timeouts are set. + +`//` returns the current HTML (the editor's buffer, replaced by `update`). Everything else is an asset under the document's +folder: the path is decoded exactly once; NUL, backslash, empty, `.`, `..` and dot-prefixed segments are refused; the extension must be +on the allow-list (css, png, jpg, jpeg, gif, webp, avif, ico, svg, woff, woff2, ttf, otf, html, htm, and js, mjs, json only for a trusted +document); the path is resolved under `realpath(root)` and the real path must stay inside it (and keep an allowed extension and no +dot segment, so a link inside the folder cannot reach a hidden or disallowed file); the file is opened with `O_NONBLOCK | O_NOFOLLOW` +and the same descriptor is `fstat`ed: a regular file of at most 25 MiB. A FIFO, a directory or a device is refused without blocking. +Directories are never listed. + +Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` and +`Cross-Origin-Resource-Policy: same-origin`. HTML (the document and html assets) carries a CSP that allows only the page's own origin +and inline styles plus `data:` images and fonts, with scripts, connections, frames, objects, media, `base` and forms off; a trusted +document additionally gets `script-src 'self' 'unsafe-inline'; connect-src 'self'` and still nothing remote. SVG is served with +`Content-Security-Policy: sandbox` and `Content-Disposition: inline`. + +Refused and missing assets are recorded in `problems()` (the last 50, newest last, control characters stripped, 200 characters each) for +the interface to show. + +## Markdown + +`markdownToHtml(markdown, {title, colorScheme})` renders with `marked` (GFM on) into a full document with a built-in README-style +stylesheet (system fonts, readable measure, headings, code, tables, quotes, task lists, images at most 100% wide) and light or dark CSS +variables; no external resources. Raw HTML in the source is kept as written: the preview is served with a CSP that forbids scripts and +remote loads and runs with scripts disabled unless the project is trusted, so sanitising would only change what the author wrote. The +title is escaped. `htmlDocumentFor(source, kind, options)` is the one entry point: HTML is returned as is, Markdown is converted. +`markdownHtml.ts` imports `marked` at module load, so the integrator should import `src/browser/engine/markdownHtml.js` lazily, when a +preview opens. + +## Limits + +- Find counts occurrences within single text nodes; a match split across inline elements (`needle`) is not found. +- A page that never finishes loading does not delay `open`; `loading` simply stays true. +- The first launch on a fresh profile can take several seconds; `open` waits up to 30 s. +- Frames for a scale other than 1 (or a zoom other than 1) cost a second render per frame. + +## Tests + +`tests/browserEngineChrome.test.ts` drives the real browser (and skips with a reason when none can start). The rest run anywhere: +`browserEngineProtocol` (pipe framing and timeouts against a fake pipe, the locator against a fake filesystem, launch arguments and +environment, URL policy, key mapping), `browserPreviewServer` (traversal, symlinks, FIFOs, size, extensions, Host, cross-site, methods, +tokens, CSP, script gating, the problems log) and `browserPreviewMarkdown`. Shared helpers: `tests/support/browserFixtures.ts`. diff --git a/docs/design/browser-panel.md b/docs/design/browser-panel.md new file mode 100644 index 00000000..56c6e90b --- /dev/null +++ b/docs/design/browser-panel.md @@ -0,0 +1,41 @@ +# Browser panel + +`/browser` shows a real web page in a viewer-family panel: tab strip, address, page, one status line, controls. The page is a +Chromium frame (see `src/browser/types.ts`) drawn through the shared graphics service; nothing here depends on Chromium itself. + +## What owns what + +| Piece | File | Responsibility | +|---|---|---| +| Frames to rows | `graphics/stream.ts` (`FrameStream`, `GraphicsService.openStream`) | One live picture: Kitty placeholders under two alternating image ids (the previous id is deleted one update later, `settle()` removes the last), or character art. Identical frames skipped by hash; frames dropped, never queued; adaptive interval (66 ms, backs off); character art at most 8 fps. PNG over 16 MB skipped; box clamped to 297. | +| Tabs | `browser/session.ts` (`BrowserSession`) | Up to 12 tabs, one stream and latest frame per tab, popups as tabs, notices, debounced resize to the viewport (CSS px = columns x cell width / scale), zoom steps 25..500%, find, change notifications at most every 33 ms, crash/close shown as an error with Reload. | +| Panel | `browser/BrowserPanel.ts` | Focus model, key and mouse handling, drawing. | +| Addresses | `browser/address.ts` | `normalizeAddress`, `displayUrl`, `popupAddress`. | +| Keys | `browser/keymap.ts` | `Key` to `KeyEventInput`; `cellToViewport` for the mouse. | + +## Focus + +| Focus | Keys | +|---|---| +| `chrome` (start) | Browser commands only; none reaches the page. `j/k` arrows scroll, Space/`b` PageDown/PageUp page, `g/G` top/bottom, `+ - 0` zoom, `[ ]` or `H/L` back/forward, `r/R` reload/hard, `s` stop, `o` address, `t` new tab, `x` close tab, Tab/Shift+Tab/`1-9` tabs, `/` find, `n/N`, Enter or `i` page, `c` copy URL, Esc closes the browser. | +| `page` | Every key goes to the page except Esc (back to chrome). Paste is `insertText`. | +| `address` | Edit the URL; Enter navigates through `normalizeAddress` (a Markdown file is returned as `open-document`); Esc cancels. | +| `find` | Edit the query; Enter finds (again: next); Esc returns to chrome. | + +Ctrl+C closes the browser in every focus. A click on the page focuses and is forwarded; the wheel scrolls the page in any focus; a +click on a tab or the address row is a browser click. The terminal key decoder reports no Ctrl+L, so `o` opens the address bar. + +## Addresses + +Accepted: `http:`, `https:`, `file:` (regular files), `about:blank`, bare hosts with a dot (`https://`), `localhost`/loopback +(`http://`), existing files by relative, absolute or `~` path. Refused with a plain reason: every other scheme, text with spaces +(NMSh has no search engine and looks nothing up), single words, user:password addresses, control and bidi characters. +Displayed URLs are always the tab's real URL with control and bidi characters removed and any password hidden. + +## For the integrator + +- Build the session with `openStream: owner => service.openStream(owner)` and `cell: () => decision.cell`. +- Call `renderBrowserPanel` for every frame of the UI (it resizes the session to the page area) and `session.settle()` after the + rows were written. Subscribe `session.onChange` to schedule a render. +- When a program takes the screen and the graphics are released, call `session.forgetPictures()`. +- Handle the actions `close`, `copy` and `open-document`; dispose the session when the panel closes. diff --git a/docs/design/browser.md b/docs/design/browser.md new file mode 100644 index 00000000..7cfd0537 --- /dev/null +++ b/docs/design/browser.md @@ -0,0 +1,42 @@ +# Real browser (`/browser` and the rendered document preview) + +NMSh does not reimplement CSS. When you ask for a real browser surface it drives a Chrome or Chromium that is **already installed**, over the DevTools Protocol on a pipe, and shows the frames +it produces through the shared graphics service. Prior art studied (all MIT; nothing was copied): [casty](https://github.com/sanohiro/casty) (CDP screencast to Kitty graphics), +[zenbu-labs/terminal-browser](https://github.com/zenbu-labs/terminal-browser) (Electron offscreen rendering with damage tracking), [konaito/terminal-browser](https://github.com/konaito/terminal-browser) +(TypeScript, screencast frames alternating two image ids, input mapping). NMSh keeps their useful ideas (screencast, two alternating image ids, backpressure by skipping frames) with a smaller dependency set: +no Puppeteer, no Electron, no WebSocket client, no bundled browser. + +## Architecture + +| Layer | Files | Responsibility | +|---|---|---| +| Boundary | `src/browser/types.ts` | `BrowserEngine`, `BrowserPage`, `PreviewServer`: everything above this file runs against a fake in tests | +| Engine | `src/browser/engine/` ([design](browser-engine.md)) | Locate a browser, launch one process tree lazily with a private profile, CDP over a pipe, pages, input, find, zoom, screencast with acknowledgement-based backpressure, idle shutdown; the loopback preview server; Markdown to HTML | +| Host | `src/browser/host.ts` | Created on first use; loads the engine module (and `marked`) with a dynamic import; nothing is imported, started or listening until a surface opens | +| Presentation | `src/browser/session.ts`, `BrowserPanel.ts`, `documentPreview.ts`, `graphics/stream.ts` ([design](browser-panel.md)) | Tabs, focus model, keys and mouse, resize to the viewport, frames to Kitty placeholders or character art | +| Document workspace | `src/viewer/DocumentWorkspace.ts` ([design](document-workspace.md)) | The Chromium preview of the editor's buffer | + +## Installation and resources + +- A Chrome or Chromium (110+) is found in the usual places (`/Applications/Google Chrome.app`, Chromium, Edge on macOS; `google-chrome`, `chromium`, `microsoft-edge` on PATH); `NMSH_BROWSER` names another. **NMSh never downloads a browser.** Without one, `/browser` says so and the document preview uses NMSh's own renderer; the rest of NMSh does not notice. +- Nothing runs at startup. One browser process tree exists at a time, started by the first browser surface and stopped 60 s after the last page closes, on exit, or by turning Settings → Real browser Off. Pages of the document preview and `/browser` tabs share it; each preview has its own throwaway browser context. +- The profile is a throwaway directory in NMSh's cache folder (isolated per `XDG_CONFIG_HOME`, so QA never touches a real profile). Idle cost is nil; while a page is shown, expect a Chromium renderer's memory. + +## Security + +- Sandbox on (the only exception, `NMSH_BROWSER_NO_SANDBOX=1`, is for containers). No remote debugging port, no extensions, downloads and permission prompts denied, no sync or metrics, minimal environment with no NMSh tokens. +- `/browser` addresses: `http`, `https`, `file` (a regular file you named), `about:blank`. Everything else (`javascript:`, `data:`, `chrome:`, `view-source:`, …) is refused before navigation, and again at the engine for redirects and links. Typed addresses with credentials are refused; the address shown is always the tab's real URL. +- Local previews are a different trust class from browsing: scripts off unless trusted, a CSP, loopback-only server with an unguessable path and Host check, local files only inside the document's folder, remote requests refused and reported, no shared cookies. +- Keyboard ownership: the panel starts in `chrome` focus where keys are browser commands and none reaches the page; `page` focus forwards every key except `Esc`; typing never reaches a command, and the browser closes if a command starts. + +## Rendering paths + +| Terminal | Frames | Status | +|---|---|---| +| Ghostty, Kitty (Kitty graphics verified by reply) | PNG frames uploaded under two alternating image ids, drawn as Unicode placeholders; skipped when identical, dropped under backpressure | NOT RUN | +| Zed, Terminal.app, SSH, tmux, `NO_COLOR` | Frames decoded and drawn as quadrant-block character art at up to 8 frames per second | NOT RUN. Lower resolution, readable layout; **not** visually equivalent to the real pixels | +| iTerm2, WezTerm, Alacritty | Not specifically handled; follow the graphics decision | NOT RUN | + +## Verified by + +`tests/browserEngineChrome.test.ts` (a real Chrome 156 here: lazy launch and clean shutdown, idle stop and relaunch, URL policy, redirects, screencast, CSS animation advancing between frames, zoom, input into a form and a link click, wheel, find with scripts off, scripts toggle, popups/dialogs/downloads, remote refusal, cookie isolation, closing), `tests/browserPreviewServer.test.ts`, `tests/browserEngineProtocol.test.ts`, `tests/browserPanel.test.ts`, `tests/browserSurface.test.ts`, `tests/documentPreview.test.ts`. Chrome-dependent tests skip when no browser can start. diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md new file mode 100644 index 00000000..c8c20654 --- /dev/null +++ b/docs/design/document-workspace.md @@ -0,0 +1,39 @@ +# Document workspace + +`/view` and `/edit` on a Markdown or HTML file open one workspace (`src/viewer/DocumentWorkspace.ts`): the file's source, the native editor and a rendered +preview of **one buffer**. Switching between them reopens nothing and loses nothing. `/edit` and `/view` remain separate commands: `/edit file.md` starts in the +editor, `/view file.md` starts in the source. Files of other kinds, and Markdown or HTML the editor refuses (over 1 MiB, read-only, mixed line endings, not yours), keep +the read-only viewer, with the reason shown ("Read-only: …"). + +## Modes and keys + +| Mode | What it is | Keys | +|---|---|---| +| **source** (navigation) | The buffer's text, read-only, with line numbers (the viewer) | `↑↓`/`j k` scroll, `/` find, `:` line, **`h` preview**, **`e` edit**, `r` refresh the preview, `R` reload from disk (asks first when edited), `Ctrl+S` save, `Esc`/`q` close (asks first when edited) | +| **edit** (typing) | The native editor on the same buffer | Every printable key is text, `h`, `e` and `r` included. **`Ctrl+O`** shows the preview and returns to where you were; `Esc` goes to source without closing or discarding; `Ctrl+S` saves; `Ctrl+Z`/`Ctrl+Y` undo and redo | +| **preview** | The rendered current buffer | `h` source, `e` edit, `r` refresh, `T` scripts on/off for this file, scroll/zoom/find/links as below, `Esc` source | + +`Ctrl+O` is the switch that works while typing because a terminal can tell it apart from text and the editor does not use it. Nothing relies on keys a terminal cannot distinguish. + +## One buffer + +There is one `EditorState` (text buffer, undo history, caret, selection, file identity, save and conflict dialogs). The read-only views are re-synced from its lines whenever they are shown, so the source and +the preview always show what is in the buffer, including unsaved edits, and nothing is a second copy that could drift. Showing the preview never writes to the file. Scroll positions are kept per mode. + +- **Save** is the editor's atomic save: a new file beside the original, the original's mode, group, line endings, BOM and final newline kept; if the file changed, was replaced or was deleted since it was opened, nothing is written until you choose Overwrite or Reload. +- **Reload from disk** (`R`) is explicit and, when the buffer has unsaved changes, asks first ("Reload, discard mine"). **Refresh** (`r`) only re-renders the buffer. +- Closing with unsaved changes asks: Save, Discard changes or Keep editing. + +## The preview + +- **A browser installed** (a Chrome or Chromium, Settings → Real browser not Off): the preview is Chromium's rendering of the buffer. The page is served from memory on a loopback-only server, so unsaved text previews without being saved and the document's relative stylesheets, images and fonts load from its own folder (the server enforces containment, file types and size limits). Scripts are off (and the page is served with a CSP that forbids them) unless you trust the file with `T`; CSS animations and transitions run either way. A preview can reach only its own origin: a clicked link is not followed, the status line says so, and `o` opens it in `/browser`. Missing resources are counted in the status line. Refresh swaps the served HTML and reloads the page (Chromium keeps the scroll position of a reload). Leaving the preview closes the page; the engine stays warm for 60 s and then stops. +- **No browser, or Real browser Off:** NMSh's own renderer draws the preview (headings, lists, tables, code, local images through the graphics service, sized headings where the terminal proved text sizing). The two previews share the keys above; the Chromium one adds zoom (`+`/`-`/`0`) and Chromium's find. +- The rendered Markdown is `marked` (GFM) with the document's raw HTML passed through to the sandboxed page and a built-in stylesheet that follows the theme's light or dark. + +## Not provided + +No split view, so no automatic refresh while typing; the preview refreshes on request. There is no "open in an external browser" action. + +## Verified by + +`tests/documentWorkspace.test.ts` (modes, keys, ownership of `h e r`, undo history across switches, unsaved preview, dialogs, per-mode scroll), `tests/documentWorkspaceApp.test.ts` (real files: atomic save with BOM and CRLF, conflicts, reload, read-only fallbacks, images of the unsaved buffer), `tests/documentPreview.test.ts` (the Chromium view against a fake engine: serving, refresh, trust, find, mouse, links, ordering of trust and refresh), `tests/browserApp.test.ts` (fallbacks) and `tests/browserEngineChrome.test.ts` against a real Chrome. Automated and headless: how it looks and feels in Ghostty or Zed is NOT RUN. 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/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/docs/design/graphics-service.md b/docs/design/graphics-service.md new file mode 100644 index 00000000..c8af1803 --- /dev/null +++ b/docs/design/graphics-service.md @@ -0,0 +1,81 @@ +# Graphics service + +Every actual image NMSh draws goes through one service, `src/graphics/` (`GraphicsService`). The standalone image viewer, Markdown and HTML +documents, the document preview fallback, the browser's page frames and any future image surface ask it; none of them decodes, scales, +encodes, numbers, uploads, positions, resizes, caches or deletes an image itself. Menus, text, selection controls and chrome stay terminal text: +graphics protocols are for real images, never a replacement for NMSh's text UI (see AGENTS.md, "Graphics and typography"). + +## Architecture decision + +| Option | Verdict | +|---|---| +| **Chafa as an optional external renderer** (LGPLv3+) | Best character-art quality and every input format, but absent on most machines, so a stock Homebrew install would need a second package or a silent downgrade; detection of Kitty graphics by terminal name is weaker than a query; a process per frame is too slow for live frames. Not required. | +| Chafa as a library or WASM (third-party bindings) | Pulls a multi-megabyte binary through an npm package NMSh would have to vet and keep current. Rejected for v0.22. | +| A small **Rust sidecar around viuer** (MIT) | A second toolchain and a binary to build, sign and package for macOS and Linux; viuer decides its protocol by environment, not by query. Rejected. | +| **timg** (GPL-2.0-only) | Not compatible with NMSh's GPL-3.0-only for code reuse or linking, and a heavy dependency set (GraphicsMagick, FFmpeg). Not used; shown here only as prior art. | +| **Native in-process hybrid** | **Chosen.** Small TypeScript modules: safe decoders (PNG, GIF first frame, JPEG through `jpeg-js` 0.4.4 BSD-3, WebP through an installed converter), a box-filter scaler, Kitty graphics with Unicode placeholders, iTerm2 inline images, Sixel, and a quadrant-block character-art renderer as the reliable fallback. Capabilities come from terminal replies, not names. | + +Licenses: `jpeg-js` is BSD-3-Clause (no dependencies, ~75 KB). Nothing from timg, viu, viuer or Chafa is copied or linked; the protocols are implemented from their specifications. Chafa is +not invoked. A future optional Chafa backend would be an external executable (LGPLv3+), not linked. + +## Pipeline + +``` +file ──readImageFile (regular file, ≤32 MB) ──identify (header only) ──decode (PNG/GIF/JPEG/WebP) ──store (≤1920 px long side) ── LoadedImage (cached, ≤6 / 48 MB) + │ +GraphicsHost.decision() ── decideGraphics(capabilities, env, setting) ───────────────────────────────────────────────────────────┤ + ▼ +place(image, box, owner, surface) ─► kitty-placeholder rows │ character-art rows │ overlay (iTerm2, Sixel, direct Kitty) │ text placeholder +release*(owner) / releaseAll / keepOnly ─► deletes terminal-side uploads +``` + +## Backend choice (`decideGraphics`) + +Evidence, strongest first: a reply to the terminal's own query (Kitty graphics `a=q`, DA1 attribute 4 for Sixel); a known-host record where +the protocol has no query (iTerm2 inline images, labelled "known host"); an explicit person's choice in Settings (labelled "chosen, not +confirmed" unless a query also confirmed it). A terminal's name never turns a protocol on, except to refine an already verified one: Unicode +placeholders are used only on hosts known to implement them (Kitty 0.28+, Ghostty), after Kitty graphics was verified. + +| Setting (Settings → General → Images) | Behavior | +|---|---| +| Auto (default) | Kitty graphics if verified, else iTerm2 (known host), else Sixel if DA1 listed it, else Unicode character art. | +| Kitty graphics / iTerm2 inline / Sixel | Explicit choice; used even if the terminal did not confirm it, and `/doctor` says so. Never inside tmux, screen or Zellij. | +| Unicode character art | Always available. Quadrant blocks in truecolor/256, half blocks in 16 colors, ASCII with NO_COLOR or Safe Glyphs. | +| Off | A placeholder line names each image. | + +Always character art: tmux, screen and Zellij (the real terminal is hidden, and graphics would need passthrough, which NMSh does not use), +`TERM=dumb`, `NMSH_IMAGES=0`. NO_COLOR keeps the standalone surface on pixels where verified, but documents use character art because Kitty placeholders carry the image id in a text color. + +`/doctor` (Host terminal) shows the chosen backend, its evidence, what documents use, the character-art style and the cell size with its source. + +## Surfaces + +- **Inline in a document (Markdown, HTML):** an image alone in its paragraph is drawn as rows when it can be shown, left aligned, at most 16 rows (`maxImageRows`) and the text width. Kitty + placeholders make the image behave like text, so it scrolls, clips, wraps and repaints with the rows around it; the diff renderer needs no knowledge of it. Text around images keeps `[image: alt]` + with the reason: "(remote images are not fetched)", "(outside this folder, not shown)", "(file not found)", "(SVG is not drawn)", "(loading…)". `i` in the viewer opens the image nearest the top in the image viewer; Esc returns to the same place. +- **Standalone (`/view image.png`):** the viewer's own frame, header and controls. Fit (aspect kept), zoom 150–400% with pan, `n`/`p` through the folder's images (regular files only, symlinks not followed, ≤500), `c` copies the path. iTerm2 and Sixel are placed as overlays by the renderer (re-drawn when their rows repaint, removed with the panel); Kitty uses placeholders. +- **Not images:** menus, tabs, selection bands and controls are always text. + +## Security and limits + +- Local files only. Remote (`http:`, `https:`), `data:` and `file:` references are never fetched; absolute and relative paths must resolve inside the document's folder or its git project (checked lexically before any filesystem call, then on the real path, so a symlink out is refused and nothing is learned about paths outside). +- Files: regular files only (descriptor checked, FIFOs never block), ≤32 MB, ≤24 megapixels, ≤16384 px per side, inflation capped by the header's implied size and 160 MB. Malformed, truncated or hostile files produce an `ImageError`, never an exception or a hang (fuzzed in tests). +- SVG is never rendered (scripts, external references); `/view icon.svg` shows its source as text. +- WebP is converted by a tool the machine already has (`sips` on macOS, `dwebp`, ImageMagick, `ffmpeg`) with `execFile`, an args array, relative file names in a private 0700 temp directory, an 8 s timeout and capped output. Without one the image shows its placeholder and says what to install. +- File names are drawn with control characters replaced; escape sequences from a file never reach the terminal. GIF/WebP animation is not played: the first frame is shown and the header says so. +- Memory: decoded cache ≤48 MB / 6 images, stored images ≤1920 px, uploads ≤32 MB (oldest deleted first), per-placement upload ≤2048 px; nothing runs when no image is open. All terminal-side images are deleted when a surface closes, when a foreground program takes the screen and on exit. + +## What the automated tests establish, and what they do not + +Tests cover the decision matrix, the Kitty placeholder encoding and lifecycle (upload once, share between owners, delete when the last owner leaves, cap), character-art output (widths, color levels, NO_COLOR, Safe Glyphs, no graphics bytes), overlays for iTerm2/Sixel, reference safety (traversal, symlinks, remote, UNC-style), hostile files, document integration (placeholders, linked images, row cap, `src` bounds) and the image viewer in a headless frame. They do **not** prove how any of it looks in a real terminal. + +| Terminal | Backend | Physical verification | +|---|---|---| +| Ghostty | Kitty graphics + Unicode placeholders | NOT RUN | +| Kitty | Kitty graphics + Unicode placeholders | NOT RUN | +| Zed | Character art (no protocol NMSh can verify; Zed's terminal is Alacritty-based) | NOT RUN | +| Terminal.app | Character art | NOT RUN | +| iTerm2 | iTerm2 inline images (known host) | NOT RUN | +| WezTerm | Kitty graphics when it answers; documents use character art | NOT RUN | +| Alacritty | Character art | NOT RUN | +| tmux / screen / SSH without graphics | Character art | NOT RUN | diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 1f7f529a..eb8f0ba5 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. @@ -65,10 +72,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, 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 -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 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/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/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c04e6051..36d910ac 100644 --- a/docs/design/native-module-coverage.md +++ b/docs/design/native-module-coverage.md @@ -26,9 +26,9 @@ module behaves identically to its upstream namesake. | Git | `git_branch`, `git_status`, `git_commit`, `git_state`, `git_metrics` / `git` | Native: `gitBranch`, `gitStatus`; pack `nmsh.vcs` (`stash`, `upstream`) | Covered; commit/state/metrics are candidates only if requested | | Other VCS | `hg_branch`, `fossil_*`, `pijul_channel`, `vcsh` / `mercurial`, `fossil`, `jujutsu`, `sapling`, `svn`, `plastic`, `dvc` | Pack `nmsh.vcs`: `hg` (bookmark/branch), `jj` (repository state) | **Done: Jujutsu and Mercurial** ([#374](https://github.com/raiseCatError/notMyShell/pull/374)), from declarative `.jj` / `.hg` metadata; the rest defer to a provider | | Core runtimes | `nodejs`, `python`, `golang`, `rust`, `java`, `package` / `node`, `python`, `golang`, `rust`, `java`, `project` | Native: pack `nmsh.project` (`package`, `node`, `python`, `go`, `rust`, `java`) with requested vs. active versions | Covered | -| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)). Remaining candidates: Bun, Kotlin/Scala, Lua, Haskell.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | +| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP, .NET, Swift, Zig, Deno ([#370](https://github.com/raiseCatError/notMyShell/pull/370)); Elixir, Dart, Flutter ([#372](https://github.com/raiseCatError/notMyShell/pull/372)); Bun (v0.21: `.bun-version`, `package.json` `packageManager`/`engines.bun`, the install location). Remaining candidates: Kotlin/Scala, Lua, Haskell.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | | Build systems | `cmake`, `meson`, `gradle`, `maven`, `buf`, `xmake`, `bazel` / `cmake`, `gradle`, `mvn`, `bazel`, `nx` | Gradle/Maven appear as the `java` module's build tool; others only as discovered tools | Covered for Java; **candidate: a single `build` module**: done ([#373](https://github.com/raiseCatError/notMyShell/pull/373): Bazel, Gradle, Maven, CMake, Meson, Make) rather than one module each | -| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; **candidates: mise, Nix shell, devcontainer/container** | +| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; mise through `tools`. **Done in v0.21: Nix shell** (`IN_NIX_SHELL`, pure/impure) **and container** (Docker/Podman marker files, Codespaces and dev container host variables; pack `nmsh.system`) | | Cloud and infrastructure | `aws`, `gcloud`, `azure`, `kubernetes`, `terraform`, `helm`, `pulumi`, `docker_context`, `openstack`, `nats`, `opa` / `aws`, `az`, `gcp`, `kubectl`, `terraform`, `helm`, `pulumi`, `docker`, `argocd`, `cf`, `firebase` | Native: built-ins `kubeContext`, `dockerContext`; packs `nmsh.cloud` (`aws`, `gcp`, `azure`), `nmsh.infrastructure` (`terraform`, `helm`, `pulumi`) | Covered for the common set; ArgoCD, Firebase, Cloud Foundry, OpenStack, NATS, OPA: provider | | Frameworks and app tooling | `angular`, `react`, `svelte`, `tauri`, `flutter`, `unity`, `umbraco` (Oh My Posh) | None | **Do not duplicate.** These are project-type labels derived from the same files the runtimes read; they add chrome, not information | | Package managers | `npm`, `pnpm`, `yarn`, `bun` (OMP) | `package` module reports the package and its manager | Covered | @@ -55,3 +55,16 @@ directories (including hostile and symlinked files), and the explain/timings vie Starship, Oh My Posh and Powerlevel10k keep working as external prompt providers; nothing here replaces them. Importing their *visual* configuration into NMSh themes is a separate problem owned by [#304](https://github.com/raiseCatError/notMyShell/issues/304); translating their segment *logic* is not attempted. + +## v0.21 review (2026-10-18) + +Bun, Nix shell and container were added with the same rules (declarative files, allowlisted variables and marker-file +presence only; nothing run). The `requires` bound of a Context Pack rose from 16 to 24 so a pack can name as many +capabilities as it may hold modules. Kotlin/Scala, Lua and Haskell stay candidates: each needs a declarative version +source that is reliable enough to show (a Gradle Kotlin plugin version, a rockspec, a Stack resolver) and was not judged +worth a module per language in this release. Diagnostics (#365): `/modules explain` and `/modules timings` give a +plain-text report of routing, visibility reasons, source states and the slowest last collections, from the engine's +snapshots only. The Context Rail and Status Strip record how their width fitting treated each routed module in the last +live frame (left out, or shortened), and explain says so; previews never overwrite that record. The Main Prompt and +Right Context truncate their text rather than drop modules, so they have nothing to report; a per-segment truncation +trace there remains future work. diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 95ebf25b..6dbe2bd9 100644 --- a/docs/design/theme-families.md +++ b/docs/design/theme-families.md @@ -27,6 +27,11 @@ are derived, so older configurations load unchanged: | Nord | Nord | | | Solarized | Dark, Light | | | One Dark | One Dark, One Light | | +| Ayu | Dark, Mirage, Light | v0.21; Mirage is the family default | +| Kanagawa | Wave, Dragon, Lotus | v0.21 | +| Everforest | Dark, Light | v0.21; medium contrast (soft/hard change only the editor background) | +| Flexoki | Dark, Light | v0.21 | +| Night Owl | Night Owl, Light Owl | v0.21 | | Custom | the user's theme (`customTheme`) | NMSh Theme JSON | Settings shows `Theme family`, then nested `Variant` (hidden for one-variant @@ -54,6 +59,30 @@ NMSh never fetches palettes at runtime. | Nord | github.com/nordtheme/nord `src/nord.css` | MIT | Copyright (c) 2016-present Sven Greb | | Solarized | github.com/altercation/solarized (README) | MIT | Copyright (c) 2011 Ethan Schoonover | | One Dark / One Light | github.com/atom/one-dark-syntax, one-light-syntax `styles/colors.less` (HSL converted to hex) | MIT | Copyright (c) 2016 GitHub Inc. | +| Ayu | github.com/ayu-theme/ayu-colors `themes/dark.yaml`, `mirage.yaml`, `light.yaml` (d461f029) | MIT | Copyright (c) Konstantin Pschera (LICENSE gives no year) | +| Kanagawa | github.com/rebelot/kanagawa.nvim `lua/kanagawa/colors.lua`, `themes.lua` (bb85e4bf) | MIT | Copyright (c) 2021 Tommaso Laurenzi | +| Everforest | github.com/sainnhe/everforest `palette.md` (85a86eb6) | MIT | Copyright (c) 2019 sainnhe | +| Flexoki | github.com/kepano/flexoki `README.md` palette tables (8d723bac) | MIT | Copyright (c) 2023 Steph Ango | +| Night Owl | github.com/sdras/night-owl-vscode-theme `themes/Night Owl-color-theme.json`, `Night Owl-Light-color-theme.json` (cc291eba) | MIT | Copyright (c) 2018 Sarah Drasner | + +v0.21 mapping notes. Values are used exactly as each source writes them; the mapping to NMSh roles follows the +theme's own semantics where it has them: + +- **Ayu**: the YAML's base palette colors (not the generated syntax tones), `editor.fg`, `ui.fg`, `ui.panel.bg`, + `common.accent.tint` and `common.error`. Ayu defines one text color, so primary and secondary are the same. +- **Kanagawa**: each theme's roles from `themes.lua`: project = `fun`, path = `bg_p2`, branch = `keyword`, + Node = `string`, Go = `type`, Python = `identifier`, separator = `nontext`, subtle = `comment`, selection = + `bg_visual`, status = `ok`/`error`. +- **Everforest**: backgrounds by their position in `palette.md` (bg_dim, bg0…bg5), the seven accents and three greys. +- **Flexoki**: dark uses the 400 accents and base tiers (tx base-200, tx-2 base-500, tx-3 base-700, ui-2 base-850, + ui-3 base-800); light uses the 600 accents, as Flexoki documents its standard palette. +- **Night Owl**: editor, selection, line-number and terminal ANSI colors, and the comment token color. + +Considered for v0.21 and not bundled: **Oxocarbon** (its grays are computed at runtime in HSLuv by the theme's own +code; reproducing them would need running that code or re-implementing it, and the issue asks for no +approximations), **GitHub Primer** (values come from a built `@primer/primitives` release, not the theme +repository), **Material/Palenight** and **classic Monokai** (licensing and canonical source not verified), and +Tier 2 families. The Built-in list in Theme Studio gains `/` to filter by name, family, or dark/light. Only color values are used; no code, assets or names beyond the theme names for identification. Random gallery or theme-site copies were not used. diff --git a/docs/design/typography.md b/docs/design/typography.md new file mode 100644 index 00000000..b4ed6d8d --- /dev/null +++ b/docs/design/typography.md @@ -0,0 +1,47 @@ +# Typography and text sizing + +Components ask for a semantic role, never for escape sequences: `typeset({role, text, maxColumns, maxRows, level?, segments?})` in +`src/ui/typography.ts` returns a block with the cells it really occupies, its ordinary text form, and (only when allowed) a sized form. +Roles: `title`, `heading`, `subheading`, `body`, `caption`, `counter`, `celebration`. + +## OSC 66 + +Kitty's text sizing protocol (https://sw.kovidgoyal.net/kitty/text-sizing-protocol/): `OSC 66 ; s=<1-7>:n=:d=:v=:h=:w= ; text ST`. It scales terminal cells (an `s` block is `s*w` cells wide and `s` rows high); +`n/d` only shrinks the glyph inside those cells. It is relative terminal scaling, not a font engine. + +- **Detection** (`host/probe.ts`, the specification's method): on a scratch alternate screen, report the cursor, draw `w=2` and a space, report, draw `s=2` and a space, report. Equal positions mean unsupported; + a step of two means width works; two more means scale works. Scale and width are separate facts. Bounded by the probe's 80 ms, replies consumed so they never reach the composer, and the alternate screen is left again. +- **Policy** (`ui/textSizing.ts`): scaling is used only when the setting is not Off, the probe proved `scale`, no multiplexer is in between, `TERM` is not `dumb` and `NMSH_TEXT_SIZING` is not `0`. + Hosts known to parse the sequence without drawing scaled glyphs (Ghostty) stay off in Auto; **Enhanced** is the deliberate override there. Enhanced never forces an unproven terminal. +- **Safety:** only text of certain width is sized (printable ASCII, Latin, Greek, Cyrillic, general punctuation); wide characters, emoji, combining marks and anything unusual are drawn plain. The text is escape-safe (control characters refused) and under 4096 bytes. +- **Placement:** a multicell block is erased when any one of its cells is overwritten, so the renderer writes every row first and draws the blocks last (`TerminalRenderer.setTextBlocks`), and again whenever a row they cover repaints; a block that goes away forces its rows to repaint. Blocks that are not wholly visible are never drawn: the heading's ordinary text shows instead. +- **Never persisted:** sized text is a drawing concern. Transcripts, saved documents, copy and the clipboard hold the words only. + +## The scale + +| Level | Role | Size (when proven) | Otherwise | +|---|---|---|---| +| H1 | title | 3× (60+ columns), 2× (36+) | bold accent | +| H2 | heading | 2× (36+) | bold primary | +| H3 | subheading | 1.5× in a 2×2 box (36+) | bold secondary | +| H4 | caption | ordinary size | italic secondary | +| H5, H6 | body | ordinary size | italic secondary | +| arcade counters | counter | 2× | bold | +| game over, new record | celebration | 3× / 2× | bold | + +Markdown `#` headings and HTML `

`–`

` map onto this one scale. A heading keeps its inline styling piece by piece (bold, italic, code, links each get their own sequence at the same scale). Body text, code, tables and lists are never enlarged. Only top-level +headings are sized (a block cannot sit behind a list marker or quote bar). A heading takes one line: if it does not fit at any size it is drawn as ordinary wrapped text. A search match in a heading shows the plain text so the highlight is visible. + +## Settings + +Settings → General → Text sizing: **Auto** (default), **Off**, **Enhanced**. `/doctor` says which state applies and why. + +## Support status + +Detection and placement are covered by automated tests with simulated terminal replies. No terminal has been physically verified. + +| Terminal | Expected | Physical verification | +|---|---|---| +| Kitty (0.40+) | Scale and width | NOT RUN | +| Ghostty | Auto stays off (parses without scaled glyphs); Enhanced available | NOT RUN | +| Zed, Terminal.app, iTerm2, WezTerm, Alacritty | Not expected; the probe decides | NOT RUN | diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md new file mode 100644 index 00000000..d62f5e55 --- /dev/null +++ b/docs/design/v021-terminal-intelligence.md @@ -0,0 +1,189 @@ +# v0.21 — native viewing, terminal intelligence and workflow polish + +Design and acceptance for the v0.21 candidate ([#410](https://github.com/raiseCatError/notMyShell/issues/410)). It +reconciles the candidate issues against the code on `dev` at `6af5cbab` (identical to `master`, after v0.20.0 at +`65e4de55`), names what each slice reuses and owns, and lists the tests that prove it. Progress, commits and open risks +are recorded in the [v0.21 ledger](../development/v021-ledger.md); this document changes only when a decision does. + +v0.21 makes the existing terminal better. NMSh stays a frontend over a real shell and PTY: the shell parses and runs +commands, programs own their bytes, and NMSh owns the composer and its own presentation. Nothing here is a shell +course, an IDE, a multiplexer or an agent harness. Learn Mode (#398) is deferred and unscheduled. + +## Invariants every slice keeps + +- **One input owner at a time.** A panel, viewer or editor owns keys only while it is open; a running program's + foreground read, passthrough and the queue keep their current rules. Nothing new types into the shell. +- **Raw output is authoritative.** Derived views (overviews, track snapshots, viewer rendering) are presentation and + are never written into the stored transcript lines, the journal's raw events, `/copy`'s raw payload or the shell. +- **One geometry plan.** New chrome is planned by `src/app/screenPlan.ts` or drawn as a full panel through the existing + panel slot; no subsystem computes its own rows. +- **Hostile text is inert.** File contents, command output and provider results are data: control and format + characters are escaped or removed before display (`displaySafe`, `stripTerminalControls`), links are only the + existing allowed forms, and nothing is executed because it was opened or recognized. +- **Bounded.** Every new read, buffer, cache, timer and process has a stated limit sized for an 8 GB Mac. +- **Honest degradation.** Unsupported input says so and keeps the existing behavior; nothing is guessed. + +## Slices + +Order follows dependencies: reliability first (later slices run more tests over the same paths), then the viewer +(the editor and Semantic Output reuse its document model), then `/track`, modules, themes and controls. + +### 1. Reliability gates + +**#403 Fish teardown race.** Cause confirmed: an interactive Fish whose generated-completions directory is missing +starts `fish_update_completions` detached (Fish 4.x checks `$__fish_cache_dir/generated_completions`, Fish 3.x +`$__fish_user_data_dir/generated_completions`); the orphaned Python process keeps writing about 1,400 files into the +test home after the shell exits, racing `rmSync`. Production is unaffected: NMSh runs Fish with the person's own +directories, where this one-time generation is Fish's normal behavior. The harness, however, did not isolate +`XDG_DATA_HOME`/`XDG_CACHE_HOME`, so test shells also wrote into ambient caches (observed under `t7run`). +Fix: Fish's "already generated" directories exist in every test home (`scripts/test-shell-home.mjs`, used by the +canonical runner, `LiveSandbox` and the fixtures that start Fish with their own home), and the runner no longer passes +ambient `XDG_DATA_HOME`/`XDG_CACHE_HOME`/`XDG_STATE_HOME` to tests, so local runs fall back to the private home as CI +does. Test: a Fish started in a prepared home spawns no generator (and the same test fails without the marker). + +**#408 queued highlight.** Cause confirmed in production code, not only the test: every prompt marker's shell +knowledge clears `SemanticService`'s cache and resolves in-flight classifications as `unknown`, so a stored command +whose lookup was in flight when the next prompt arrived (a fast queue) keeps the unclassified color permanently. +Fix: a lookup superseded by newer knowledge is asked again against that knowledge (bounded), and a stale answer is +still never cached. The test waits for the shell's startup knowledge and for the classification promises it started +instead of one `setImmediate`. Tests: superseded lookup resolves from the new knowledge; queued and composer paths end +with the same colors; repeated runs are stable. + +**#388 multi-line progress redraws.** `AnsiOutputParser` implements `\r`, `CSI K/G/C/D` and full clears, but ignores +row movement, so every frame of a multi-row progress display (`CSI A` + `CSI 2K` + text + `\n`) becomes new permanent +lines. Fix: a bounded **live region**: the cursor may move up and down only within the running program's own rows, +never above the most recent NMSh-authored line or the command's start, and never more than the PTY's screen height +(a terminal cannot address scrollback either). Supported: `CSI A/B/E/F` (cursor up/down/next/previous line), +`ESC M`, `ESC D`, `ESC E`, save/restore (`ESC 7/8`, `CSI s/u`), `CSI J` 0/1 within the region, `CSI K` 0/1/2. +**Rows are the terminal's rows** (stabilization): once the frontend reports the PTY's width, a stored line wider than +it occupies several rows, so a program that counts the rows it drew (as progress libraries measure wrapping) moves +over exactly those rows. Moving by stored lines instead climbed past a wrapped line into the program's own earlier +output and erased it at narrow widths (reproduced, and in a real PTY at 40 columns). Carriage return, backspace, +`CSI G/C/D` and `CSI K` act on the cursor's row within its line; a resize re-wraps stored lines at the new width, as +a reflowing terminal does. The contract stays deliberately limited: absolute row addressing (`CSI H/f/d` with a row) +is not mapped, because inline programs cannot know where their rows are (cursor position reports are not answered), +so the cursor stays where it is; scroll regions (`CSI r`) are not emulated, except that text a program draws below a +region it set (a reserved status row, such as an installer's progress line) is not stored, so it cannot leak into the +log rows. Copy and search see what the terminal would show at the end; the +intermediate frames are not kept, as a terminal's scrollback does not keep them. Raw events in the session spool are +unchanged and replay through the same parser. Tests: single and multi-row redraws, interleaved stdout, split +sequences, clamping at the fence, NMSh lines inserted mid-redraw, metadata never edited, a real-PTY run of the +fixture from #388, and a bounded-time run of 10k frames. + +### 2. Native `/view` (#397, viewer phase of #402) + +A read-only full-height panel over one **document**: lines of text plus where they came from. + +- Sources: `/view `, `/view N` (completed output N, numbered as `/copy` numbers them), and **View output** in + block Actions. Output documents read the record's stored text; no second capture, no shell involvement. +- Files: `lstat` then `open(O_RDONLY | O_NONBLOCK)` and `fstat` on the descriptor; only regular files are read + (FIFOs, devices and directories are refused with the reason). At most 8 MiB is read; longer files show the first + part with an explicit truncation line. NUL bytes or a high control-character ratio in the first 8 KiB mean binary + and are refused. Invalid UTF-8 is shown with replacement characters and a note. Symlinks are followed (as `less` + does) and the header names the target. Reading is asynchronous and cancellable; nothing is written. +- Modes: **Text** (line numbers, wrap or horizontal scroll), **Markdown** (the existing `src/markdown` renderer for + `.md`/`.markdown` files only; output is never implicitly Markdown; `m` toggles Rendered/Source), **Diff** (unified + diff roles for `.diff`/`.patch` and output whose command is `git diff`/`git show`/`diff -u`), and syntax roles for + source files through the existing `highlightCode` when the extension is known. +- Navigation: arrows, PageUp/PageDown, Home/End, `g`/`G`, `:` go to line, `/` search with `n`/`N`, `w` wrap, + `l` line numbers, `c` copy the whole document's text (raw source), `Esc`/`q` close. Mouse wheel scrolls. +- Safety: every displayed line goes through `displaySafe`; Markdown links stay text unless the target passes + `authoredTargetAllowed`; nothing is executed, fetched or followed. Large files keep only the decoded lines in + memory (bounded by the read cap). + +The existing **Open in pager** action stays for people who prefer `less`; `/view` does not replace `less`, `bat` or +Glow, and running those commands is unchanged. + +### 3. Small native editor (`/edit`, editor phase of #402) + +Opt-in convenience for small text files, built on the viewer's document model and file checks. + +- Opens regular UTF-8 text files up to 1 MiB that the person owns, with one hard link; others open read-only in + `/view` with the reason. Line endings (LF/CRLF), a BOM and the final newline are preserved. +- Editing: insert/delete, multi-line, caret movement by character/word/line/page, Shift selection, cut/copy/paste + through NMSh's clipboard, undo/redo (bounded history), search, and a dirty marker. +- Save (`Ctrl+S`): write a temporary file in the same directory (exclusive create, original mode), `fsync`, then + check the original is still the file loaded (device, inode, size, mtime and content hash) and `rename` over the + resolved real path. A changed or replaced original is a conflict: **Overwrite**, **Keep editing** or **Discard + mine**, nothing silent. Errors (permission, full disk, read-only filesystem) are reported in the editor and the + buffer is kept. Extended attributes and ACLs are not carried over by the rename; documented. +- `Esc` with unsaved changes asks; the composer draft and shell are untouched throughout. Not an IDE: no LSP, + multi-cursor, project tree, plugins or Vim emulation. `vim`, `nano` and other editors run unchanged. + +### 4. Semantic Output (#386, first phase) + +Deterministic, post-completion, optional. One recognizer table, each entry a pure function from +`(command, stored output, exit code)` to a typed result with a role (`overview` or `detail`), the facts it read and a +completeness flag. First recognizers, chosen for usefulness and testability: + +- **Test runs**: Node's test runner/TAP (`# pass`/`# fail`), Jest/Vitest summaries, pytest's final line, `cargo test` + `test result:` lines, Go's `ok`/`FAIL` package lines. Overview: counts as the runner printed them. +- **Compiler and type-checker diagnostics**: `tsc`, GCC/Clang, `rustc`/`cargo`, Go: overview counts plus a detail list + of `file:line` locations and messages taken verbatim from the output. + +Presentation is derived rows in `TranscriptPresenter` (like fold hints), placed after the command header and before +output; they never enter stored lines. The overview appears only when it adds information (long output, or failures +whose locations are listed), never for short readable output. Raw output keeps its existing fold policy; a failure is +never folded away by a semantic view. A late result never moves rows above the reader: it is computed synchronously at +completion from bounded input (first 2 MiB), so it exists before the block is drawn. +`/copy` gains `--raw` (default and current behavior), `--semantic` (the overview and details as text, raw with a note +when there are none) and `--both` (labeled raw then semantic), mutually exclusive, combinable with existing selectors. +Settings → Transcript → Semantic Output: On (default) / Off. External providers (`jc`, `delta`) are not part of +this phase, nor are a fully collapsed raw section (raw keeps its existing fold policy) and a separate semantic search +scope (derived rows are excluded from find and selection). Details open by default when the raw output is folded +(they are what the fold hides) and closed when it is expanded; the reader's toggle is kept per block. + +### 5. `/track` (#393, reconciling draft PR #394) + +`/track` is the `/watch` scheduler (`src/tasks/WatchTasks.ts`) with PR #394's hardening carried over by hand (strict +cadence parsing, refusing substitutions, background operators and heredocs, bounded UTF-8 capture, timeouts, a stop +grace period, at most eight active trackers, copy of the latest snapshot), plus one **live transcript block**: a +header line stored once, and the latest completed snapshot drawn as derived rows that are replaced in place on every +completed sample. One current and one previous result are kept; nothing accumulates per refresh. + +- Samples run as ` -c ` in their own process group with the starting directory captured at creation, + pipes and `NO_COLOR`; they do not see the live shell's aliases, functions or unexported variables, and the block + says so. No overlap: a refresh while a sample runs is refused with the reason. +- States shown truthfully: first run pending, running, last completed time, exit status, timed out, spawn error, + stale (no completed sample in two intervals), paused, stopped. +- Ownership: trackers belong to the window, as `/watch` always has. Detaching or closing the window stops them and + their processes; the block then reads *stopped with this window*. They are not silently restored by reattach. The + service-owned supervisor (#382) is not duplicated; moving scheduling into the session service is a later step. +- Copy and search: the stored header plus, while live, the derived snapshot rows are searchable; `/copy` of a track + block and **C** in the panel copy the latest completed snapshot as plain text, never earlier samples. Stopping a + track writes its final snapshot into the transcript once, so history keeps the last result. +- `/watch` keeps its syntax and safety gate; `/track` and `/watch` manage the same trackers. + +### 6. Native modules and diagnostics (#364, #365) + +Add the matrix's remaining safe candidates with the existing declarative-file pattern: **Bun** (requested version from +`package.json` `packageManager`/`engines.bun`, `.bun-version`, `.tool-versions`/`mise.toml`; active from the install +path), **Nix shell** (`IN_NIX_SHELL` and `name` from the allowlisted live environment) and **container** (`/.dockerenv`, +`/run/.containerenv`, `container` in the environment). Each gets hostile-workspace fixtures; none runs a tool. Update +the coverage matrix with the date and decisions. For #365, close the remaining diagnostic gaps the issue lists after +auditing what v0.20 shipped: per-module cache lifetime and key scope, a slowest-collectors list, and a plain-text +explain export, reading snapshots only (opening diagnostics never starts collection). + +### 7. Theme families (#401) and action controls (#336) + +Add Tier 1 families from their canonical upstream palettes with source, revision and license recorded in +`theme-families.md`: Ayu, Kanagawa, Everforest, Flexoki, Night Owl, GitHub Primer, Oxocarbon, and Material +Palenight and a classic Monokai where licensing is verified. Existing ids and stored preferences are unchanged; +Lavender stays the default. Picker grouping and dark/light filtering are added only if the catalog makes the current +list hard to use. Contrast is checked against each variant's own background with the existing contrast helpers. +Confirmations added in this release (editor save conflicts, `/track` confirmation) use the existing +`src/ui/actionControls.ts` renderer; no new widget layer. + +## Deferred (not in v0.21) + +#398 Learn Mode; #339/#340 managed Codex/OpenCode and remote control; #337/#338 broad worktree and GitHub writes; +#330–#332 workspace expansion; #333 tmux Studio; #334 Setup Cat profiles; #335 keymap remapping; #399 host terminal +configuration; #304 Theme Bridge expansion; #341 executable mods; #296 hosted demo; #389 (no reproduction); #356 +(separate Fish typeahead race); #366 external editor provider registry. Semantic Output providers (`jc`, `delta`), +service-owned `/track` scheduling, viewer follow/tail mode and Markdown editing preview are later phases. + +## Verification + +Focused tests per slice; `npm run verify` before each PR; exact-head CI (macOS and Ubuntu, Node 22 and 26, Fedora) +before merge; `verify:release` and the timing smoke at the integration checkpoint. Physical Ghostty and Terminal.app +checks are listed per slice in the ledger and are not claimed from automated runs. diff --git a/docs/design/v022-daily-use.md b/docs/design/v022-daily-use.md new file mode 100644 index 00000000..0a52ad3b --- /dev/null +++ b/docs/design/v022-daily-use.md @@ -0,0 +1,138 @@ +# 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` 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 + (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 | Draft PR #434 | + +### Optional, decided by the maintainer (2026-10-18) + +| 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. **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) + +#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 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 ` 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 (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 +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 (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 +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. + +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 +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..d829dba6 --- /dev/null +++ b/docs/design/v022-natural-language-commands.md @@ -0,0 +1,138 @@ +# Natural-language command suggestions + +**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 + +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 ` 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 } + ``` + +- 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 + +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. + +## 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/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..7e5c89e8 --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,77 @@ +# Live command tracking (`/track`) + +`/track` (#393) keeps **one live result** of a finite command in the transcript, refreshed in place, while the shell +stays free for other commands. It runs on the existing `/watch` scheduler (`src/tasks/WatchTasks.ts`), whose hardening +was first written in draft PR #394 and carried into v0.21; `/watch` keeps its own behavior (its manager panel and +what-changed view) and both names manage the same trackers. + +## Usage + +```sh +/track df -h /Volumes/T7Projects +/track --every 5s -- docker system df +/track # the manager (same as /watch) +/track refresh [N] # one refresh now (never a second, overlapping one) +/track pause [N] · /track resume [N] +/track copy [N] # copy the latest completed result, as plain text +/track stop [N] · /track stop all +``` + +`N` is the tracker's position in the manager (the newest when omitted). The `--` after the cadence is optional but +keeps a command's own flags from reading as NMSh's. + +## What the transcript shows + +- The command line and one note, stored once like any NMSh block: what is tracked, where, and how it runs. +- Under them, drawn live and **never stored**: a status row and the latest completed result. The status says only what + is known: first run pending, `live · every 5s · updated 13:49:25 · exit 0 · 12 runs`, a failing exit code, a timeout, + a spawn error, `paused`, `no new result for 1m 20s` (stale), `refreshing…` while a sample runs. +- The result keeps a stable height (the largest seen, at most 12 rows) so a shorter refresh never moves what is below; + a longer one says how many lines are not shown. `/track copy` copies all of it. +- Live rows are presentation: find, selection and `/copy` never include them, and nothing accumulates per refresh. +- Stopping a tracker (`/track stop`, or S in the manager) writes its **last result once** into the transcript, so + history keeps it. A tracker whose window closes leaves only its stored lines. +- Above the composer, each active tracker has a compact row; with more than two, one summary row (`4 trackers · 1 + failing · /track opens the manager`) replaces them. `clear` removes the block from the transcript; the tracker keeps + running and stays in the manager. +- Safe glyphs use ASCII state marks (`*` live, `=` paused, `o` pending, `#` stopped). + +## Execution and safety + +- Every sample is `sh -c ` in its own process group, from the directory the tracker started in, with NMSh's + environment and the live shell's PATH. It does not see the shell's aliases, functions, unexported variables or + options, and it does not follow later `cd`s; the note in the block says so. +- Default interval 5 s; at least 2 s locally and 15 s for remote network targets; at most 60 minutes. Invalid + intervals fail before anything runs. At most 8 active trackers. A sample never overlaps another of the same tracker; + a refresh while one runs is refused with the reason. +- Repeated commands can repeat side effects, so repetition has its own gate (`src/tasks/repeatSafety.ts`) on top of + the shared command classifier, and it asks when unsure: + - **Start at once:** read-only commands, test and check runners, the exact `docker system df`, and read-only network + probes: `ping`, `dig`, `nslookup`, `host`, `traceroute`, `nc -z`, a curl GET or HEAD to standard output (or + `-o /dev/null`), `wget -O -` or `--spider`, and package queries (`npm view`, `outdated`, `audit`). + - **Ask first (an explicit Yes):** unknown commands; `NAME=value` prefixes and `env ` (they change or run + another command); git's `-c`, `--config-env`, `--exec-path` and `--ext-diff`; `git fetch`; anything that sends + data, saves files, signs in, publishes or runs a downloaded package (`curl -X POST`, `-d`, `-o file`, non-HTTP + URLs, plain `wget`, `ssh`, `sftp`, HTTPie, `npm publish`, `npm login`, `npm x`, `pnpm dlx`, `npm audit fix`). + Network targets other than this computer also ask, and their minimum interval is 15 s. + - **Refused:** installing, modifying, destroying, privileged, downloaded-code and `cd` commands; substitutions, + background `&` and heredocs; any output redirection into a file (judged per redirection, so `2>/dev/null` never + excuses `> file`; `2>&1` is fine); options that write or update files (`git … --output`, `sort -o`, `find + -fprint`/`-fls`, `tree -o`, `yq -i`, `--fix`, `--write`, `jest -u`, snapshot updates); `scp` and `rsync` (except + a dry run); `--watch` modes that never finish; shutdown, reboot, halt and power-off. +- Each sample keeps at most 200 kB of stdout and stderr (decoded as UTF-8 across chunk boundaries), says when it was + cut, and has its terminal control sequences removed. A sample is stopped after the lesser of 60 s or three + intervals and shown as timed out. Stopping signals the owned process group (TERM, then KILL after a grace period). + When a sample ends, anything still in its process group (a child that ignored TERM, or one that closed its output and + kept running) gets TERM and then KILL, so trackers never accumulate processes; a closing window waits for that + bounded grace period. Processes that deliberately leave the group (`setsid`, daemons) are not followed. +- A tracker whose directory disappears says so (`the directory … is no longer available`) and keeps trying at its + interval, so a remounted volume resumes on its own. + +## Ownership and limits + +Trackers belong to the window that started them, as `/watch` always has: detaching or closing the window stops them +and their processes. They are not restarted on reattach; the stored lines remain. Service-owned tracking that survives +detach would reuse the session's background-task supervisor (#382) and is not part of v0.21. + +Physical terminal checks are listed in `docs/testing/v021-physical-qa.md`. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md new file mode 100644 index 00000000..f15d0886 --- /dev/null +++ b/docs/development/v021-ledger.md @@ -0,0 +1,296 @@ +# 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. + +## Baseline (verified 2026-10-18) + +- `master` = `dev` = `6af5cbab`; latest release v0.20.0 (`65e4de55`); CI green on both. +- Open PRs: draft #394 (`/track` first slice, based on an older line; reconciled by hand in slice 5, not merged; + closed as superseded by #416 in the release audit). +- Work happens in disposable worktrees under `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/`; scratch on the T7. + +## Slices + +| # | Slice | Issues | Branch / PR | Merged commit | State | +| --- | --- | --- | --- | --- | --- | +| 0 | Design and ledger | #410 | `docs/v021-design` / [#411](https://github.com/raiseCatError/notMyShell/pull/411) → `dev` | `a412fe53` | Merged into `dev` | +| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` / [#412](https://github.com/raiseCatError/notMyShell/pull/412) → #411 | `3282ad40` | Merged into `dev` | +| 2 | `/view` | #397, #402 | `feature/v021-view` / [#413](https://github.com/raiseCatError/notMyShell/pull/413) → #412 | `e945c75a` | Merged into `dev` | +| 3 | `/edit` | #402 | `feature/v021-edit` / [#414](https://github.com/raiseCatError/notMyShell/pull/414) → #413 | `934ba7ce` | Merged into `dev` | +| 4 | Semantic Output | #386 | `feature/v021-semantic` / [#415](https://github.com/raiseCatError/notMyShell/pull/415) → #414 | `a97b3f77` | Merged into `dev` (one rerun) | +| 5 | `/track` | #393 (#394) | `feature/v021-track` / [#416](https://github.com/raiseCatError/notMyShell/pull/416) → #415 | `a5f2fd69` | Merged into `dev` | +| 6 | Modules and diagnostics | #364, #365 | `feature/v021-modules` / [#417](https://github.com/raiseCatError/notMyShell/pull/417) → #416 | `ac543bd0` | Merged into `dev` (one rerun) | +| 7 | Themes and controls | #401, #336 | `feature/v021-themes` / [#418](https://github.com/raiseCatError/notMyShell/pull/418) → #417 | `0ea7ca39` | Merged into `dev` | +| 8 | Checkpoint ledger | #410 | `docs/v021-checkpoint` / [#419](https://github.com/raiseCatError/notMyShell/pull/419) → #418 | `f8e53541` | Merged into `dev` | +| 9 | `/track` refresh and finalization lifecycle | #393 | `fix/v021-track-review-hardening` / [#420](https://github.com/raiseCatError/notMyShell/pull/420) → #419 | `d612f120` | Merged into `dev` | +| 10 | `/track` review: repetition safety gate, process cleanup, presentation | #393 | `fix/v021-stabilize` / [#421](https://github.com/raiseCatError/notMyShell/pull/421) → #420 | `0eca52d6` | Merged into `dev` | +| 11 | Flaky CI at the cause | #410 | `fix/v021-flaky-tests` / [#422](https://github.com/raiseCatError/notMyShell/pull/422) → #421 | `ac0f780a` | Merged into `dev` | +| 12 | Bugs: #388 wrapped rows, command colors after a slow zshrc, #365 width | #388, #408, #365 | `fix/v021-bugs` / [#423](https://github.com/raiseCatError/notMyShell/pull/423) → #422 | `7cb857f4` | Merged into `dev` | +| 13 | Stabilization ledger | #410 | `docs/v021-stabilization` / [#424](https://github.com/raiseCatError/notMyShell/pull/424) → #423 | `433af8fd` | Merged into `dev` | + +## Decisions + +- #403 is a test-fixture race (Fish's detached completion generator), not a product leak; the fix makes test homes + hermetic rather than adding retries. +- #408 is a production race in `SemanticService` (superseded lookups resolved `unknown`), plus a test that raced the + real shell's startup knowledge. +- `/track` stays window-owned like `/watch` in v0.21; service-owned scheduling is deferred and documented. +- PR #394's `WatchTasks.ts`/`WatchPanel.ts` were adopted as written (neither file changed on `dev` since its base) and + its tests kept; its other edits were re-applied by hand. #394 itself is superseded by the slice 5 PR and was not merged. +- #336: the editor's unsaved-changes and conflict dialogs use the existing `src/ui/actionControls.ts` renderer (slice 3); + no other v0.21 surface needed a new control, so no further consolidation was made. +- #401: Oxocarbon, GitHub Primer, Material/Palenight and Monokai were deferred (see theme-families.md for why). +- `/track` samples run `sh -c` (as `/watch` always has) with the live shell's PATH, not the session shell, so `/watch` + semantics stay the same for Fish users. + +## Physical QA still required + +Checklist: [v0.21 physical QA](../testing/v021-physical-qa.md). Slice 1: section 1 (multi-row progress in Ghostty +and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). Slice 6: section 6 (Bun, Nix, container, reports). Slice 7: section 7 (theme families, filter, contrast, host untouched). + +## Verification notes + +- Slice 1 evidence: each new regression fails on the unfixed code and passes on the fix (Fish generator present; + superseded lookup settled `unknown` and the stored line never recolored; 9 of 10 redraw tests fail on the old parser, + the tenth being the documented unchanged absolute-positioning case). #408 tests passed 16/16 in two concurrent loops. +- Local full `npm run verify` on this 8 GB Mac: run with `NMSH_TEST_TMPDIR` at the T7 storage root + (`/Volumes/T7Tmp/ai-dev`), never a deeper directory: the runner requires test roots under 32 characters for Unix + sockets, and a longer root fails the runner self-test, tmux and session-socket tests for environmental reasons. + Live PTY tests can time out under full-suite load here and pass alone; exact-head CI is the independent gate. + +## Open risks + +- Merging into `dev`: done (see "Ordered integration" below). +- #426 (pre-existing): under heavy load, a reply typed right after a prompt-less read starts can be queued. Its live + test can fail on a loaded host; the test's own race is fixed in #427. +- #388: absolute rows (`CSI H/f/d`) are deliberately not mapped and scroll regions are not emulated (a program's + status row below its region is not stored); the contract is explicit and tested (stabilization). +- Nothing in v0.21 has had a physical-terminal pass yet. Every slice issue stays open until its pass is recorded. +- The three CI flakes seen during the stack were fixed at their cause in #422 (two were product defects). + +## Integration verification (stack head, 2026-10-18) + +Run on `feature/v021-themes` (all eight slices) in a T7 worktree, `NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. + +- `npm run verify:release`: 3,118 tests, 3,115 pass, 2 skipped, 1 fail. The failure, + `nativeCaptureLifecycle` "native cancellation reaps a PTY before it can process the initialization command", timed + out waiting for the inner shell under full-suite load; the file passes 3 of 3 alone. v0.21 does not touch native + capture. +- `npm run typecheck:bench` passes; `npm run bench:smoke` passes (see "Performance" below). +- CI on each stacked PR (Ubuntu and macOS shards, Node 22 compatibility, Quality): green on #411–#419 at their current + heads. Flakes seen once each, all in code v0.21 does not change and all passing on rerun: #415's Ubuntu shard hit + the 10-minute job limit with output stalled in `tests/shellFrameworks.test.ts` (an orphaned Fish process); #417's + macOS shard failed `tests/backgroundSession.test.ts:92` (task removal timing); #417's Ubuntu shard failed + `tests/configuredCompletion.test.ts:89` (the helper's private root still present just after a startup timeout). + Each is worth its own issue. + +## Performance + +- `npm run bench:smoke` at the stack head (`8bb85d5e`) passed every budget: pty-ready p50 23 ms, service-ready p50 + 233 ms, cold context collection p50 48 ms, every-module render p50 39 ms, configured completion warm p50 10 ms, + `transcript/wrap-present-10000` p50 48 ms, `transcript/wrap-present-100000` p50 467 ms. +- `transcript/wrap-present-100000` is noisy on this 8 GB Mac: a `dev` baseline worktree measured p50 501–606 ms + across runs, and earlier stack runs 484–1,161 ms with the slow ones not repeating. No consistent regression was + found. The one per-row cost v0.21 adds, the `/track` live-row lookup, is now skipped entirely when the transcript + has no trackers (#416). +- Bounded by design and covered by tests: `/view` layout per line (a 1 MB single line no longer takes 17 s), file + loads (8 MiB / 200,000 lines), `/edit` (1 MiB), Semantic Output recognizers (linear, 2 MiB scanned, 20 items), + `/track` samples (200 kB, 12 live rows, at most 8 trackers). + +## Stabilization (2026-10-18) + +A second pass after the stack: every open `type:bug` issue, a joint review of `/track` (#416 and #420), the CI +flakes, and the v0.21 integration. Linux evidence comes from a Debian container (Node 26.12, zsh 5.9, Fish 3.6, +Bash 5.2) whose disk is on the T7 (Colima, `/Volumes/T7Projects/.colima-suma`); macOS from this Mac (Node 26.10, +Fish 4.9.3). Every new regression test was run against the unfixed code first and failed there. + +**`/track` review (#421).** Commands started at once and repeated without asking: `echo x > ~/.zshrc 2>/dev/null` +(the shared classifier excused every redirect once `>/dev/null` appeared; paste review called it read-only), +`env rm …`, `npm publish`/`login`/`audit fix`, `npm x`/`pnpm dlx`/`bun x`, `ssh host rm …`, `scp`, `rsync`, +`curl -X POST`/`-d`/`-o`, plain `wget`, `git -c core.fsmonitor=… status`, `git log --output=…`; `npm test 2>&1` was +wrongly refused. A repetition gate (`src/tasks/repeatSafety.ts`) now sits on top of the classifier, and `&>` stays +refused (dash reads it as a background job). A TERM-ignoring child survived `/track stop` because the close handler +cancelled the KILL fallback; leftovers in a sample's process group are now ended. #420's refresh event fires after +the child is wired; a vanished directory is named; many trackers share one summary row; Safe glyphs; result rows +cached per result (1–3 ms to ~0.5 ms per frame with eight large trackers). #420 itself: no re-entrancy, stale +reference or duplicate finalization found. + +**Flaky CI (#422).** `backgroundSession.test.ts:92` was a product defect: a cleanly exiting background task showed +"exited, processes left" (and Delete was refused) until its guard confirmed the group empty; the supervisor now +finishes on the guard's verdict. `configuredCompletion.test.ts:89` was a product defect: removing the helper's +private root raced the helper's own `rm -rf` and late pid writes (rmSync threw ENOTEMPTY on macOS, returned with the +directory present on Linux); the root is renamed away first, then removed entry by entry. The 6-minute Ubuntu hang +was a test that stalled past its timeout with a live shell PTY: the runner now preloads a file teardown that ends, +and names, what a file left running. `nativeCaptureLifecycle`: its tight bound no longer covers a cold `tsx` start. A fourth +flake appeared once on `dev` itself (dispatched CI): `codexAdapter.test.ts:84` read the fake Codex server's log when +the turn settled, before the server had read the client's last response; the fixture now forces that order every run +and the helper waits for what the test asserts. + +**Bugs (#423).** #388: the live region moved by stored lines while programs count terminal rows, so a redraw over a +wrapped line erased the program's earlier output at narrow widths (parser, real PTY at 40 columns, and through the +frontend); rows are now the terminal's rows. Found verifying #408: a zsh configuration slower than 1.5 s made the +first semantic lookup time out and end the helper, so commands lost their known/unknown color for the session; +lookups are now timed from when the helper reads them. #365: `/modules explain` says when the Rail or Strip left a +module out, or shortened it, for width in the last frame. + +**Investigated, not reproducible.** #356 (Fish typeahead after a read): 40/40 on macOS Fish 4.9.3 under 8× parallel +load; 22/22 Linux Fish 3.6 runs that got past startup under 2× load (the other 2 stalled on VM I/O before drawing). +#403: every interactive Fish in tests has a home marked generated; non-interactive uses never start the generator. +#408's generation/re-ask synchronization holds. + +**Also checked.** Semantic Output: 99 adversarial input/recognizer combinations up to a 2 MB single line, none over +150 ms. Docker-specific `/track` checks were not run against a Docker daemon of NMSh's own (the only one available +runs the maintainer's containers); `docker system df` stays the one Docker verb allowed at once, by unit test. + +**Verification (stabilization).** +- CI on each new PR (#420–#423: macOS and Ubuntu shards, Node 22 compatibility, Quality): green at their heads. +- Full CI dispatched on the stack head, which also runs the Fedora portability subset and both timing smokes: + `bb210ce5` and the final head `e8411e1a` (test-only changes since), all jobs green on both. +- Timing smoke against `dev` in the same window (dispatched pair, CI runners): Ubuntu `pty-ready` p50 415 vs 416 ms, + `service-ready` 580 vs 580 ms, `transcript/wrap-present-100000` 811 vs 848 ms; macOS `pty-ready` 28 vs 33 ms, + `transcript/wrap-present-100000` 1,132 vs 1,165 ms. No consistent regression; single runs vary by more than that. +- The paired `dev` run failed a fourth flake (`codexAdapter.test.ts:84`), fixed in #422 (above). +- Local `npm run verify:release` on this 8 GB Mac could not give a clean full-suite result during the session: the + machine was shared with other workloads (two container queue workers at about 120% CPU each in the Colima VM, other + agent sessions, a browser), with load averages of 35–167 and 4.6 GB of swap in use. One run (`bb210ce5`, load 35–67) + had 3,127 of 3,142 passing, 2 skipped and 13 failures, all live tests timing out (tmux, screen, service lifecycle, + per-frame budgets) plus two capture tests whose windows included the owner's cold `tsx` start (fixed in #422). All + seven failing files then passed 69/69 three times in a row at normal load. A second run on `e8411e1a` (load + 3–13): 3,138 of 3,142 passing, 2 skipped, 2 failed, both timing bounds in code v0.21 does not change that pass + alone 5/5 (a Node stand-in for zoxide whose cold start exceeded the 1.5 s query limit, and a 4 s startup budget + for a 5,000-candidate compinit); both tests were hardened in #422. Typecheck of the benchmarks and the timing smoke + ran only in CI (verify stops at the first failing step). A clean local run on `dev` after integration remains a + gate. + +## Ordered integration + +Done on 2026-10-18. #411 to #424 were merged into `dev` in order with regular merge commits (table above). `dev` is +`433af8fd`, whose tree is identical to #424's head `06bc2034`. Every PR head is an ancestor of `dev`, and so is +`master` (`6af5cbab`), so the release needs no conflict resolution. + +## Release audit (2026-10-18) + +An independent pass over the integrated `dev` at `433af8fd`, from a clean detached worktree on the T7 (`npm ci`: 25 +packages, 0 vulnerabilities). Machine: macOS 27 arm64, 8 GB, Node 26.10.0, zsh 5.9, Bash 5.3.20, Fish 4.9.3, with +`NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev`. It was shared throughout (a browser, ten Docker containers in a VM, another +agent session), with 5.4–6.7 GB of swap in use. + +**CI on the exact commit.** Push run [39135227830](https://github.com/raiseCatError/notMyShell/actions/runs/39135227830) +on `433af8fd`: all 13 jobs green. That covers Quality (Node 26: build, benchmark typecheck, fast suite, diff check), +the full suite on macOS and Ubuntu (Node 26, two shards each), Node 22 compatibility, the Fedora portability subset, +timing smoke on macOS and Ubuntu, and the `CI` and `Verify (22.x, 26.x)` aggregates. + +**Local gates on `433af8fd`.** + +| Gate | Result | +| --- | --- | +| `npm ci` | PASS, 0 vulnerabilities | +| `npm run build` | PASS | +| `npm test` (canonical suite, 4 workers) | FAIL: 3,142 tests, 3,139 pass, 2 skipped, 1 fail, 553 s, load 3 rising to 16 | +| `npm run typecheck:bench` | PASS (run on its own: `verify:release` stops at its first failing step) | +| `npm run bench:smoke` | PASS, every budget, at load 4–5 | +| `git diff --check` | PASS | + +The failure is `inputOwnershipLive` → `live zsh: a waiting read gets the answer typed at once …`. The answer to a +prompt-less `read` was queued, so the read never got it. The file alone passed 3/3 at load 5–8. Four copies at once +(load rising from 8 to 27–33) failed in 2 of 4 copies on `dev` and 3 of 4 on `master` `6af5cbab`, so this is +pre-existing, not from v0.21. Two mechanisms, told apart by a diagnostic run that recorded the queue entries: + +- **Product, pre-existing, open as #426.** A key typed before NMSh has evidence that the command reads input is + held for at most 800 ms while the session looks. On macOS a prompt-less read needs two process-table samples, and + when the look misses the deadline, Enter queues. This was the full-suite failure, and it reappeared at load 20–33 + after the test fix. It was not seen at ordinary load or in CI. +- **Test race, fixed in #427.** The test typed its follow-up `echo BACK-…` as soon as the answer's output appeared + and checked the queue at once. Before the shell's prompt for the answered command arrives, Enter queues, as designed. + This was the CI failure on #425 (Ubuntu) and most of the parallel failures; the recorded entry was the test's own + follow-up. + +**Benchmarks** (`bench:smoke` at `433af8fd`, load 4–5), p50: pty-ready 24 ms, service-ready 233 ms, cold context +collection 50 ms, every-module render 40 ms, configured completion warm 8.8 ms, `transcript/wrap-present-10000` 41 ms, +`transcript/wrap-present-100000` 444 ms. Every one is within budget and within noise of the stack-head numbers above. + +**Code review,** highest impact first: `/edit` saving, `/view` loading, the `/track` lifecycle (`WatchTasks`, +`repeatSafety`, `trackBlock` and the `TerminalApp` wiring), the parser's live region, Semantic Output bounds and caching, +`SemanticService` startup timing, the new module capabilities, and what the Homebrew formula installs. + +- Fixed in #425: a new file in a folder reached through a symlink (macOS `/tmp`, `/var/folders`) hit a false "was + replaced on disk" conflict on its second save. The new file kept its unresolved path as its real path. The + regression fails on `dev`. Physical check 3.9. +- Fixed in #427 (test only): the race above. +- Filed as #426: the product behavior above. +- Noted, not changed: + - The reattach notice says `zsh pid` for every shell (`TerminalApp.finishReplay`, since `b0bd6878`, before shell + backends). Cosmetic. + - `clear` removes `/track` blocks with every other block. The tracker keeps running and its summary row stays, and + `/track stop` writes the final result, which matches `clear`'s contract. + - Stopped trackers keep their last two results until 128 records accumulate. That is bounded, but not small. +- Checked, no defect found: + - `/view` refuses FIFOs and devices before anything blocks, reads in bounded chunks and cuts UTF-8 on a character + boundary. + - `/edit` uses an exclusive temporary file, syncs it, checks the original's identity before renaming, refuses hard + links and keeps symlinks. + - `/track` runs each sample in its own process group, escalates TERM to KILL, ends stragglers after every sample, + refuses overlapping refreshes and ends everything when the window closes. + - `SemanticService` resolves pending lookups when it is killed during startup. + - Semantic readings are cached per record in a `WeakMap`. + - The built-in context packs are copied into `dist`, and the Homebrew formula's install list still covers every + file v0.21 reads at runtime: all new code compiles into `dist`, and no new assets were added. + +**Issues and PRs.** + +- Closed: #403 (a test fixture; verified on `dev` and in CI). Draft PR #394, as superseded by #416 (its scheduler, + panel and all 8 tests are on `dev`). +- Open until their physical passes are recorded: #388, #393, #397, #401, #402, #408 (pass 1.7), #364, #365, #386 and + #336. The work still out of scope stays on each issue: + - #386: external providers, a fully collapsed raw view and a separate semantic search scope. + - #401: four families deferred. + - #402: the provider registry (#366). + - #364: Kotlin/Scala, Lua and Haskell as candidates. + - #365: no per-segment truncation trace for the Main Prompt. + - #336: no other surface needed a new control. +- #356 stays open: it was not reproduced, and passing its reproducer does not close it. +- #410 tracks the release. + +## 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 + +**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. +2. **Release candidate:** a `release/v0.21.0` branch from `dev` with one commit holding the version (`0.21.0`), the + changelog's `[0.21.0]` candidate section and the status pointers, opened as a draft PR to `master`, as v0.20 did + (#392). Then dispatched CI on its exact head (Fedora and both timing smokes) and a local `verify:release` on it. +3. **Physical QA, mandatory and not run:** every pass in the [checklist](../testing/v021-physical-qa.md), on the + candidate build, recorded in its results table. Headless PTY tests and CI do not count. +4. Then, with the maintainer's go-ahead: date the changelog section, merge the release PR into `master`, then the tag, + GitHub release, Homebrew tap and website, as in the [release runbook](v021-release-runbook.md). Close the issues + whose acceptance is met. + +Known limitations shipped deliberately: `/track` is window-owned (it stops on detach); Semantic Output is its first +phase (no `jc`/`delta` providers); inline absolute cursor positioning is not mapped and scroll regions are not +emulated (#388 contract); the Main Prompt has no per-segment truncation trace (#365); four theme families are deferred +(#401); type-ahead *during* a Fish read stays subject to Fish's own editor queries (#356); on a heavily loaded machine +a reply typed within about a second of starting a prompt-less read can be queued (#426, pre-existing). diff --git a/docs/development/v021-release-runbook.md b/docs/development/v021-release-runbook.md new file mode 100644 index 00000000..5a95fd5a --- /dev/null +++ b/docs/development/v021-release-runbook.md @@ -0,0 +1,129 @@ +# 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 +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/development/v022-ledger.md b/docs/development/v022-ledger.md new file mode 100644 index 00000000..2896f640 --- /dev/null +++ b/docs/development/v022-ledger.md @@ -0,0 +1,432 @@ +# 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`) 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. +- 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` / #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, 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 | +| 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 + +- **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. +- **#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 + +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 | +| #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. + - #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. + +- **#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. + +## 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. + +**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. + +## 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. +- **#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 + +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 + +- 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. +- #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. + +## 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) | +| `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) | + +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). + +### 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. + +## Final scope (2026-10-20): multiline modes, graphics, typography, workspace, browser, arcade + +Added to the candidate after the hardening pass. Everything below is automated evidence; none of it is physical QA (the F1-F7 rows in +[physical QA](../testing/v022-physical-qa.md) are NOT RUN). + +| Piece | Branch / PR | Design | Key tests | +|---|---|---|---| +| Multiline modes (One at a time, Queue all, Run as block), shared panel components, UI consistency rule | `feature/v022-multiline-modes` (#458) | [multiline-execution-modes](../design/multiline-execution-modes.md) | `batchSequence`, `pasteBatch*`, `pasteSlashCommands`, live paste and clip tests (Queue all chosen with `q`, confirmed with Enter) | +| Graphics service, decoders, encoders, image viewer, document images, OSC 66 typography, arcade kernel | `feature/v022-graphics` (#459) | [graphics-service](../design/graphics-service.md), [typography](../design/typography.md), [arcade](../design/arcade.md) | `graphicsCapabilities`, `graphicsDecode*`, `graphicsEncode*`, `graphicsService`, `hostProbeExtended`, `textSizing`, `documentTypography`, `viewImages` | +| Five games | `feature/v022-game-{run,flight,dash,sudoku,doku}` → `feature/v022-arcade-games` | `arcade-{run,flight,dash,sudoku,doku}` | `arcade*` (150+ tests; fairness proofs by simulation for the action games, uniqueness and logical solvability for the puzzles) | +| Browser engine, preview server, panel | `feature/v022-browser-engine`, `feature/v022-browser-panel` → `feature/v022-browser` | [browser](../design/browser.md), [browser-engine](../design/browser-engine.md), [browser-panel](../design/browser-panel.md) | `browserEngineChrome` (17 against a real Chrome 156), `browserEngineProtocol`, `browserPreviewServer`, `browserPreviewMarkdown`, `browserPanel`, `browserSurface`, `documentPreview`, `browserApp` | +| Document workspace | `feature/v022-browser` | [document-workspace](../design/document-workspace.md) | `documentWorkspace`, `documentWorkspaceApp` | +| Integration of all of the above | `integration/v022-final` | | CI run on the exact head recorded in the PR | + +### Decisions + +- **Rendering architecture:** native hybrid (in-process decoders and encoders, Kitty placeholders, character art), Chafa not required: see the comparison in graphics-service.md. `jpeg-js` and `marked` are the only new runtime dependencies; `sudoku-gen` was evaluated and rejected (its puzzles cannot be reproduced from a seed). +- **Capabilities from replies, not names:** the host probe also asks DA1 (Sixel), the cell and window pixel size and, on a scratch alternate screen, the three cursor reports of the OSC 66 test. Ghostty stays off for text sizing in Auto. +- **Browser:** an installed Chrome or Chromium over a pipe, never downloaded, never started at startup; previews are a separate trust class from browsing. +- **`/view` of an editable Markdown or HTML file opens source-first** (a deliberate change of the earlier rendered-first default, requested for the workspace); `q`/`b`/`o` in the paste review only choose a mode. +- **The arcade never owns a running command's keyboard:** it refuses to open over one and closes if one starts. + +### Reviews and fixes recorded + +- Automated security review of the graphics service found (1) a file name with control characters drawn unfiltered and (2) the containment check running after `realpath`; both fixed with tests (lexical containment first, same answer for existing and missing paths outside the root, names made safe). +- A review of the Chromium preview found a trust race (scripts toggled while the preview was starting could leave the page running with the opposite setting); fixed by reconciling after open and by running trust and refresh changes in order with scripts turned off first. +- The first CI attempt on #458 failed two macOS live tests (`reattaching across profiles…`, `NMSh inside GNU screen…`) that do not touch the multiline code and passed on the rerun of the failed jobs; the earlier live paste tests that did fail were real consequences of the new default and were updated. +- `git diff --check` found blank lines at the end of new files in three branches; fixed and checked on every branch. + +### Publication (2026-10-21) + +The maintainer approved publishing v0.22.0 on release commit `a46492580398da39b740e6967c14ee6bdda28a66`'s tree plus the release-metadata commit (changelog +date, current-stable text, this record), accepting the documented physical-QA waiver and the known intermittent live-test risks. Decisions kept apart: +the waiver is an acceptance, not evidence; the local full run (3,730 of 3,741; 11 load-sensitive non-passes that passed alone) is a record, not a +clean pass; hosted CI on the exact publication commit is the gate and is recorded in the release notes. + +### Open risks + +- Kitty placeholders, OSC 66 and Chromium frames are untested in a real Ghostty or Kitty; the game feel and look are unverified; Zed shows character art only. +- Merged into `release/v0.22.0` by the maintainer as #470 (`536ce3ae`; its tree equals the verified integration head `9af17713`). #458 and #459 were then marked merged too; they changed no files. +- **Queue-ownership live test (`queueConditionOwnershipLive`), investigated in the release stabilization.** It failed on macOS CI in three attempts in a row and about one local run in ten under heavy CPU load; the older candidate `e8dd44e4` failed it too (1 of 12 under the same load), so it is not a regression of this integration. Tracing the frontend showed the cause: the test typed `echo CONDITIONS-DONE` within about ten milliseconds of the approved queue entry running, before the reattached window had processed that entry's `exec` and `prompt` events. The window took the queue entry's `exec` for its own submission, so the queued command's record is missing and the submitted line is recorded twice. Nothing was lost, duplicated in the shell or run unapproved (the marker file shows each command once and in order). The test now waits until the approved command is recorded; 40 of 40 runs under load passed afterwards. The frontend behavior remains: a documented limit under *Input while a command runs* in the changelog, not a fix, because correcting it means changing how a window attributes shell events to commands it did not submit. +- **Multiplexer live tests (`tests/muxInterop.test.ts`).** Both failures found here were test defects, fixed, with the product behaving as designed (a line typed before NMSh has seen the previous command end is held for approval, #426). The mouse-enabled tmux detach/reattach test failed 10 of 10 runs under heavy CPU load on both the older candidate `e8dd44e4` and the integrated head, and intermittently on hosted runs before the integration (runs 39290676452, 39295673070, 39295725965): the captured pane showed it typing the next line as soon as the fullscreen program printed its exit marker. It now waits for the command's journal record, as the `less` section of the file does. The GNU screen test failed on hosted ubuntu runs: the captured screen showed `jobs` still running when `kill %1; echo JOB-GONE` was typed, because the test took a redrawn older "Completed" row for `jobs`' own. It now waits on the journal too, and its fixed 500 ms pause is gone. Both files then passed 8 of 8 and 10 of 10 runs under heavy load, and the GNU screen test did not fail again on the hosted runs after the change. +- **Hosted macOS live tests were intermittent; one cause was a product bug (fixed), the rest are unexplained.** `typingAfterRead` (zsh: the hidden-read type-ahead cases, `echo NEXT-ahead1/3/5` never completing after the person approves the held line within 10 s) and, once each, `inputOwnershipLive` (a typed answer not reaching a read within 10 s) and `queueConditionOwnershipLive` failed on hosted macOS runs, also before this integration (the tests failed on `fix/v022-harden`, the multiline PR and early integration runs). Local runs under heavy load and 16 of 16 isolated runs of `typingAfterRead` passed; a CI-like local shard (concurrency 3, heavy load) reproduced two other assertions of the same file ("held, it has not run" and "only typing ahead during a hidden read is held"), which were a journal-lag misclassification and are fixed in the test, and the hosted "approved line never completed" case was a product bug found from the captured stream: typing that reached the session after a command was done was held one entry per input piece (about 16 fragment entries for one typed line on a slow macOS prompt hook), now coalesced into the lines typed in `ShellSession` with a regression test (`tests/shellSession.test.ts`) that fails on the old code. On the final tested SHA `2a64f18d` the dispatched CI run passed in full and the pull-request run failed twice on macOS live tests (`inputOwnershipLive`, then `typingAfterRead`); over the last two days of the last 36 completed hosted CI runs on this repository (this branch, its integration and component branches, a dependabot PR), 14 passed, before and after the integration alike. diff --git a/docs/development/v022-release-runbook.md b/docs/development/v022-release-runbook.md new file mode 100644 index 00000000..1c530f6c --- /dev/null +++ b/docs/development/v022-release-runbook.md @@ -0,0 +1,145 @@ +# 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. + +## The final scope (added after the first runbook) + +v0.22.0 also ships multiline modes, the graphics service and image viewing, OSC 66 typography, the document workspace, `/browser` and the Vespyr Arcade +([ledger](v022-ledger.md#final-scope-2026-10-20-multiline-modes-graphics-typography-workspace-browser-arcade)). They reach `release/v0.22.0` through +reviewed PRs merged by the maintainer, in this order: the component PRs (#458, #459) or the single integration PR (`integration/v022-final`, which contains them), +then the release-metadata commit (changelog date, "current stable release" text) on `release/v0.22.0`. Two new runtime dependencies are in `package.json` +and the lockfile (`jpeg-js` 0.4.4, `marked` 18.1.1); check `npm ci` and the source archive build after the merge. Tests that need a real Chrome skip when none can start; +on the hosted runners they run where Chrome is installed. For this release the maintainer accepted hosted CI on the exact final head in place of a local `npm run verify:release` +on an 8 GB machine; record that difference in the release notes. + +## 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 new file mode 100644 index 00000000..1de95701 --- /dev/null +++ b/docs/testing/v021-physical-qa.md @@ -0,0 +1,237 @@ +# 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 + [ledger](../development/v021-ledger.md). Nothing below has been run yet. The [walkthrough](#walkthrough) groups the + passes into three sittings with ready-made fixtures. +- **What does not count:** headless PTY fixtures, synthetic keys, captures of a window driven that way, and green CI. + Each item needs a person at a real terminal with a real keyboard and pointer. +- **Build under test:** the release candidate commit, built from a clean checkout (`npm ci && npm run build`); record + `node dist/index.js --version` (version and build commit) in every row. +- **Isolation:** `scripts/qa/v021-fixtures.sh` makes a throwaway folder with its own NMSh configuration + (`XDG_CONFIG_HOME`) and session service (`NMSH_RUNTIME_DIR`), so the QA build neither reads your settings nor + attaches to your running sessions. `HOME` is unchanged: your shell's own startup files run as usual, and copy + items replace your clipboard. + +## Results + +| Pass | Host and version | OS | Shell and version | Commit | Result | Notes | +|---|---|---|---|---|---|---| +| 1 Multi-row progress (#388) | Ghostty | macOS | zsh | | Not run | | +| 1 Multi-row progress (#388) | Terminal.app | macOS | Bash or Fish | | Not run | | +| 2 `/view` | Ghostty | macOS | zsh | | Not run | | +| 2 `/view` | Terminal.app | macOS | zsh | | Not run | | +| 3 `/edit` | Ghostty | macOS | zsh | | Not run | | +| 3 `/edit` | Terminal.app | macOS | Bash or Fish | | Not run | | +| 4 Semantic Output | Ghostty | macOS | zsh | | Not run | | +| 5 `/track` | Ghostty | macOS | zsh | | Not run | | +| 5 `/track` | Terminal.app | macOS | Fish | | Not run | | +| 6 Modules | Ghostty | macOS | zsh | | Not run | | +| 7 Themes | Ghostty | macOS | zsh | | Not run | | +| 7 Themes | Terminal.app | macOS | zsh | | Not run | | + +Not available on the QA Mac, with the coverage that exists instead: 1.5b (`apt` progress; Linux only: parser and live +PTY tests, Ubuntu and Fedora CI), 4.3 `cargo build` (not installed; Rust diagnostics recognizer tests), 6.2 +(`nix-shell`; the module reads only `IN_NIX_SHELL`, unit-tested), 6.3 inside a container (marker files and host +variables, unit-tested; the host half is checked here). Each stays *Not run* in the table, never *Pass*. + +## Walkthrough + +Three sittings, about 90 minutes in all. Every command below is typed in NMSh unless it says "second tab". + +**Prepare once.** In a plain terminal window (not inside NMSh): + +```sh +cd /Volumes/T7Tmp/ai-dev/worktrees/notMyShell/v021-qa # the release candidate build +node dist/index.js --version # record version and build commit +scripts/qa/v021-fixtures.sh /Volumes/T7Tmp/ai-dev/q21 +``` + +Each sitting starts from a plain window: `/Volumes/T7Tmp/ai-dev/q21/nmsh` (zsh), `… nmsh fish` or `… nmsh bash`. NMSh +opens in `q21/work`; paths below are relative to it. Quit with `exit`. If a launch offers to resume a detached +session (after 5.8), press R and `exit` it. + +**Reporting.** One line per pass, for example `Pass 2 · Ghostty 1.3.1 · zsh 5.9: PASS` or `Pass 2: FAIL 2.7 (the FIFO +hung)`; a screenshot helps. Each result is recorded in the table with the build commit. A skipped item is *Not run*. + +### Sitting A: Ghostty, zsh (passes 1–7, about 60 minutes) + +1. **Progress.** 1.1 as listed, then at 40 columns. 1.2 `node progress/multirow.mjs`: two `layer` rows count up in + place; `LOG pulling two layers`, `LOG checkpoint 1`–`4` and `LOG done` all stay, in order. 1.3 `node + progress/multirow.mjs 120` (30 s) while you resize. 1.4 `/copy`, then `pbpaste`: the final rows once. 1.5 as listed. + 1.5a at 40 columns `node progress/wrapped.mjs`: both `LOG kept line` lines and `LOG done` stay, with one `Downloading + … 100%` and one `eta 0s`. 1.6 `vim view/tabs.txt`, `less view/README.md`, `top` (htop is not installed). 1.7 `sleep + 5`, then `ls` Ctrl+Q and `pwd` Ctrl+Q while it runs. +2. **`/view`.** 2.1 `/view view/README.md`. 2.2 `/view view/ViewerPanel.ts`. 2.3 in either. 2.4 as listed. 2.5 `/view + view/tabs.txt`, `c`, Esc, `pbpaste | cat -vet`: `^I` between columns, `$` only at line ends. 2.6 `/view + view/hostile.txt`. 2.7 `/view view/folder`, `view/missing.txt`, `/dev/zero`, `view/fifo` (returns at once), + `/bin/ls`, `view/big.log` (20 MB: the first 8 MiB with a note). 2.8 as listed, with `node progress/multirow.mjs 120` + running. 2.9 in step 8. +3. **`/edit`.** 3.1 `cd edit/empty`, then as listed, with `cat -vet notes.txt` (macOS `cat` has no `-A`): `^I` and a + final `$`. 3.2–3.4 on that file. 3.5 second tab: `echo x >> /Volumes/T7Tmp/ai-dev/q21/work/edit/empty/notes.txt`. + `cd ../..`. 3.6 `/edit edit/crlf-bom.txt`, change a word, save; `xxd edit/crlf-bom.txt | head -1` starts `efbb bf` + and lines end `0d0a`. `/edit edit/mode640.txt`, save, `ls -l edit/mode640.txt` is `-rw-r-----`. `/edit + edit/link.txt`, save, `ls -l edit/link.txt` still points to `target.txt`. 3.7 `/edit /bin/ls`, `/etc/hosts`, + `edit/big.txt` (2 MB), `edit/readonly.txt`, then `/view` each. 3.8 with `view/README.md` and `edit/target.txt`. + 3.9 `/edit edit/alias/new.txt` (`alias` links to `real`). +4. **Semantic Output.** 4.1 `cd semantic/pass && npm test` (40 passing tests stand in for this repository's suite). + 4.2 `cd ../fail && npm test` (2 failing). 4.3 `cd ../ts && npx --no-install tsc --noEmit bad.ts` (2 errors); `cd + ../c && make` (1 error). 4.4 `cd ../../git && git status`, `ls`, `cat tracked.txt`. 4.5 `cd ../semantic/fail && + npm test`, then as listed, with `pbpaste` after each copy. 4.6 as listed. 4.7 in step 8. `cd ../..` when done. +5. **`/track`.** 5.1 `/track df -h`; `/track --every 5s -- docker system df` is optional (read-only, but it asks your + Docker daemon every 5 s). 5.2 `/track --every 2s -- cat track/f.txt`; second tab: `seq 1 2 > + /Volumes/T7Tmp/ai-dev/q21/work/track/f.txt`, then `seq 1 40 > …`. 5.3–5.7 as listed (5.7's `/tmp/t` commands are + refused and never run; answer No to the ones that ask). 5.8 `/track --every 3s -- sh -c 'sleep 2; echo QA-TRACK'` + (Yes), close the window while it runs, then in a new window `pgrep -fl QA-TRACK` prints nothing. 5.9 and 5.10 as + listed. 5.11 in step 8. +6. **Modules.** 6.1 `cd modules/bun-app`, `/modules`: Bun is recommended; enabled, typing `bun` shows `1.1.30` + requested (Bun is not installed, so nothing active). 6.2 Not run (no Nix). 6.3 on this Mac the container module shows + nothing; the in-container half is Not run. 6.4 and 6.5 as listed. `cd ../..`. +7. **Themes.** 7.1–7.3 as listed. 7.4 in step 8. 7.5 second tab, before and after switching themes: `shasum + "$HOME/Library/Application Support/com.mitchellh.ghostty/config"*`: unchanged. Older configuration: `exit`, then in + a plain window `mkdir -p /Volumes/T7Tmp/ai-dev/q21/config-copy/nmsh && cp "$HOME/Library/Application + Support/notMyShell/config.json" /Volumes/T7Tmp/ai-dev/q21/config-copy/nmsh/` and `/Volumes/T7Tmp/ai-dev/q21/nmsh + copy`: the theme and prompt saved by your v0.20 install load unchanged (only the copy can change). +8. **No color and Safe glyphs** (2.9, 4.7, 5.11, 7.4). `exit`, relaunch as `NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q21/nmsh`, + Settings → Appearance → Safe glyphs, then 2.9 on `view/README.md`, 4.7 with `cd semantic/fail && npm test` at 40 + columns (`cd ../..` after), 5.11 with `/track df -h`. Then 7.4's second half: `exit`, relaunch as `env -u COLORTERM TERM=xterm-256color + /Volumes/T7Tmp/ai-dev/q21/nmsh` and switch between two new themes. + +### Sitting B: Terminal.app, Fish (passes 1, 3 and 5, about 20 minutes) + +`/Volumes/T7Tmp/ai-dev/q21/nmsh fish`, then 1.1–1.7 (not 1.5b), 3.1–3.9 and 5.1–5.10 as in sitting A, and 5.11 with +`NO_COLOR=1 /Volumes/T7Tmp/ai-dev/q21/nmsh fish` as in step 8. Terminal.app is a 256-color terminal: note anything that +reads differently from Ghostty. + +### Sitting C: Terminal.app, zsh (passes 2 and 7, about 15 minutes) + +`/Volumes/T7Tmp/ai-dev/q21/nmsh`, then 2.1–2.8 and 7.1–7.5 as in sitting A, and 2.9 and 7.4 as in step 8. + +When every result is recorded: `rm -rf /Volumes/T7Tmp/ai-dev/q21`. + +## 1. Multi-row progress redraws (#388) + +- [ ] 1.1 `node -e 'for (let i=0;i<=10;i++){process.stdout.write((i?"\x1b[1A\x1b[2K":"")+"Downloading "+i*10+"%\n")}'` + leaves one `Downloading 100%` line, at 80 columns and at 40 columns. +- [ ] 1.2 A two-row display (for example `docker pull` of a multi-layer image, or `brew install` of a formula with + concurrent downloads) updates its rows in place; log lines printed between frames all stay. Record the command. +- [ ] 1.3 Resize the window narrower and wider while 1.2 runs: no earlier command, prompt or NMSh notice is erased. +- [ ] 1.4 `/copy` of that block copies the final rows once, not every frame. +- [ ] 1.5 `printf '10%%\r20%%\r100%%\n'` still shows one `100%` line; `clear` still clears. +- [ ] 1.5a At 40 columns, a progress display whose line is wider than the window (for example `pip install` of a + large wheel, `cargo build` of a crate with a long name, or the wrapped-row fixture in `tests/ansiLiveRegion.test.ts`) + redraws in place and every log line printed before it stays. +- [ ] 1.5b On Linux, `sudo apt install <small package>` (fancy progress on): the transcript keeps the dpkg log lines and + contains no `Progress: [ nn%]` fragments. +- [ ] 1.6 `vim`, `less` and `htop` still take the full screen and return to the composer. +- [ ] 1.7 Queue two quick commands (`ls` then `pwd`) with Ctrl+Q while a short command runs: both stored command + lines end with the known-command color (#408). + +## 2. Native viewer (`/view`, View output) + +- [ ] 2.1 `/view README.md` in this repository: Markdown is rendered (headings, lists, code, tables); `m` shows the + source with line numbers; `m` again returns. +- [ ] 2.2 `/view src/viewer/ViewerPanel.ts`: syntax colors, line numbers, `w` turns wrapping off and Left/Right scroll + sideways with `‹`/`›` marks, `l` hides numbers. +- [ ] 2.3 `/` finds text (lowercase ignores case), `n`/`N` step and wrap with a note, `:` + number goes to a line, + `G`/`g` end and start, PageUp/PageDown, mouse wheel scrolls. +- [ ] 2.4 Run `seq 1 5000`, then `/view` (latest output) and Actions → View output on that block; `/view 2` opens the + previous output. The transcript is unchanged after closing. +- [ ] 2.5 `c` copies the whole document; the clipboard has the exact text (tabs kept), and nothing else. +- [ ] 2.6 Hostile content: `printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > /tmp/h.txt && /view /tmp/h.txt` + shows `␛` marks, no color change, no link, no screen clear. +- [ ] 2.7 Refusals say why: a directory, a missing file, `/dev/zero`, a FIFO (`mkfifo /tmp/f && /view /tmp/f` returns + at once), a binary (`/view /bin/ls`), a 20 MB log (shows the first 8 MiB with a note). +- [ ] 2.8 Esc closes, the composer draft and cursor are where they were, and a running command's output kept + arriving underneath. At 32×10 the viewer still fits and Esc is shown. +- [ ] 2.9 `NO_COLOR=1` and Safe glyphs: matches and the current match are still visible (reverse video). + +## 3. Native editor (`/edit`) + +- [ ] 3.1 `/edit notes.txt` in an empty folder: it says *new file*; type two lines with Enter and a Tab, Ctrl+S says + *Saved*, Esc returns to the composer, `cat -A notes.txt` shows the tab and a final newline. +- [ ] 3.2 Shift+arrows, Shift+Alt+arrows and Cmd+Shift+arrows select; typing replaces the selection; Ctrl+C copies + and Ctrl+X cuts it (paste it back with the terminal's paste); Ctrl+Z undoes a typed word in one step, Ctrl+Y redoes. +- [ ] 3.3 Ctrl+F finds (lowercase ignores case), Enter again finds the next match. +- [ ] 3.4 Edit, then Esc: the question offers Save, Discard changes and Keep editing; each does what it says. +- [ ] 3.5 Conflict: `/edit notes.txt`, change it from another terminal (`echo x >> notes.txt`), then Ctrl+S: nothing is + written until you choose; Overwrite writes yours, Reload shows theirs. +- [ ] 3.6 A CRLF file with a BOM keeps both after saving (`xxd file | head -1`); `chmod 640` survives a save; editing + through a symlink keeps the symlink. +- [ ] 3.7 Refusals: a binary (`/edit /bin/ls`), a root-owned file (`/edit /etc/hosts`), a 2 MB file, a read-only file + (`chmod 444`) each explain why, and `/view` still opens them. +- [ ] 3.8 `e` in `/view README.md` opens the editor on the same file; `vim notes.txt` and `nano notes.txt` still run as + usual from the composer. +- [ ] 3.9 A new file in a folder reached through a symlink (macOS `/tmp`, or `alias -> real`): `/edit alias/new.txt`, + type a line, Ctrl+S, type another, Ctrl+S: *Saved* both times and no conflict question (#425); `real/new.txt` has + both lines. + +## 4. Semantic Output + +- [ ] 4.1 `npm test` in this repository (long, passing): one overview row under the command (`✔ node --test · … passed · + 0 failed …`) above the folded output; nothing else changed; the lifecycle row appears once. +- [ ] 4.2 A failing run (break one test): the overview says how many failed and a details row lists the failing tests; + details are open when the output is folded, closed when it is expanded; clicking the row and Actions → Show or hide + details toggle it; Ctrl+O still folds only the raw output. +- [ ] 4.3 `npx tsc --noEmit` with an error, `cargo build` with an error, `make` of a C file with an error: a + diagnostics overview with `file:line:col` items. +- [ ] 4.4 `git status`, `ls`, `cat README.md`: no overview. +- [ ] 4.5 `/copy --semantic` and `/copy --both` on the failing run (paste into an editor); `/copy latest` is the raw + output only; find (Ctrl+F) never matches the overview text. +- [ ] 4.6 Settings → Transcript → Semantic Output Off: the rows disappear from every block; On brings them back. +- [ ] 4.7 `NO_COLOR=1` and Safe glyphs: the overview reads `+`/`x` and stays readable at 40 columns. + +## 5. `/track` + +- [ ] 5.1 `/track df -h` and (where Docker runs) `/track --every 5s -- docker system df`: one block, a status row and the + result; it updates in place every interval while you run other commands; scrolling up and selecting text in other + blocks is never disturbed. +- [ ] 5.2 Make the result shorter and longer (`/track --every 2s -- cat f.txt` while editing `f.txt`): shorter results + do not move what is below; longer ones say how many lines are not shown. +- [ ] 5.3 `/track refresh` twice quickly: the second is refused, never overlapped. `/track pause`, `/track resume`. +- [ ] 5.4 A failing command (`/track --every 2s -- ls missing`): the status shows the exit code, never a healthy result. +- [ ] 5.5 `/track copy` copies only the latest result; find (Ctrl+F) never matches the live rows. +- [ ] 5.6 `/track stop`: the last result appears once in the transcript; `/resume` of the saved session shows it + without any per-refresh copies. +- [ ] 5.7 An unknown command asks for Yes first ("Track …?"); `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are + refused. Also refused: `/track echo x > /tmp/t 2>/dev/null`, `/track git log --output=/tmp/t`, `/track npx jest -u`. + Asks first: `/track env ls`, `/track curl -X POST http://localhost:1/`, `/track npm publish`, `/track ssh localhost + true`. Starts at once: `/track npm test 2>&1`, `/track curl -s http://localhost:1/` (fails, shows the exit code). +- [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` + still opens its manager. +- [ ] 5.9 Four trackers at once (`ls`, `df -h`, `cat f.txt`, a failing one): each block refreshes on its own, the + composer area shows one `4 trackers · 1 failing` row, narrowing the window to 60 columns and back keeps every block + readable, and `/track stop all` leaves each last result once. +- [ ] 5.10 `/track --every 2s -- sh -c 'trap "" TERM; exec >/dev/null 2>&1; exec sleep 300'` (Yes), then `/track stop`: + within 3 seconds `pgrep -f "sleep 300"` finds nothing. +- [ ] 5.11 Safe glyphs (Settings → Appearance) and `NO_COLOR=1`: the status marks are ASCII (`*`, `=`, `#`) and the + status still says live, paused or stopped in words. + +## 6. Native modules and diagnostics + +- [ ] 6.1 In a folder with `bun.lock` and `"packageManager": "bun@1.1.30"`, `/modules` recommends Bun; enabled, typing + `bun` shows its version (requested, and active when installed through Homebrew). +- [ ] 6.2 `nix-shell -p hello` (or `nix develop`) with the Nix shell module enabled: it shows `impure`; it disappears + after `exit`. +- [ ] 6.3 In a Docker or Podman container (or a Codespace), the container module names it; on the host it shows nothing. +- [ ] 6.4 `/modules explain` lists every configured module with its surface and reason; `/modules timings` lists the + slowest collections; neither changes what is collected (the Status Strip CPU stays as it was). +- [ ] 6.5 Route three modules to the Context Rail (Modules → Surface), narrow the window until one disappears, then run + `/modules explain`: that module says "left out for width in the last frame"; widen the window and run it again: the + note is gone. The same with the Status Strip. + +## 7. Theme families + +- [ ] 7.1 `/theme`: the Built-in list shows Ayu, Kanagawa, Everforest, Flexoki and Night Owl; `/` then `kanagawa` leaves + three; `/` then `light` leaves the light variants; Esc clears the filter, Esc again closes. +- [ ] 7.2 Activate each new variant on a dark terminal: prompt fills, branch, status, selection band and transcript + chrome are readable; module text stays readable on every fill. +- [ ] 7.3 Activate the light variants (Ayu Light, Kanagawa Lotus, Everforest Light, Flexoki Light, Light Owl) on a light + and on a dark terminal: accents and rules change, NMSh's text tiers stay readable. +- [ ] 7.4 `NO_COLOR=1` and a 256-color terminal (`TERM=xterm-256color`, no `COLORTERM`): nothing breaks. +- [ ] 7.5 Switching themes never changes Ghostty's or Terminal.app's own colors or configuration files; a config saved + with an older theme id still loads that theme. diff --git a/docs/testing/v022-physical-qa.md b/docs/testing/v022-physical-qa.md new file mode 100644 index 00000000..8ff4259b --- /dev/null +++ b/docs/testing/v022-physical-qa.md @@ -0,0 +1,513 @@ +# 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. 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. +- **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 | Terminal | Shell | Commit | Result | Notes | +|---|---|---|---|---|---| +| 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 | | +| 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. 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 | 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 | +| 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 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 | +| F1 Multiline modes | Ghostty | zsh | | NOT RUN | final additions | +| F2 Images (Kitty graphics) | Ghostty | zsh | | NOT RUN | final additions | +| F3 Images (character art) | Zed | zsh | | NOT RUN | final additions | +| F4 Text sizing | Ghostty, Kitty | zsh | | NOT RUN | Auto expected off in Ghostty; Kitty not available here | +| F5 Document workspace | Ghostty or Zed | zsh | | NOT RUN | final additions | +| F6 `/browser` | Ghostty, Zed | zsh | | NOT RUN | needs an installed Chrome or Chromium | +| F7 Vespyr Arcade | Ghostty, Zed | zsh | | NOT RUN | final additions | + +**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. + +### Release decision (2026-10-21) + +The maintainer decided to publish v0.22.0 without the physical passes marked NOT RUN above (the nine required passes and F1–F7) and accepted +the risk in writing. That is an acceptance of risk, **not** a result: no row above changed to PASS because of it, and no automated run stands in +for a person at a real terminal. What stands in for them is automated only: hosted CI on the exact release commit (pull-request and dispatched +runs), a local full run on a loaded 8 GB machine (3,730 of 3,741 passing, the 11 load-sensitive live-terminal non-passes passing when rerun +alone), and real-PTY smokes of the image viewer, the document workspace, `/browser` and the arcade. Open risks that remain, none shown harmless: +the unperformed physical checks, intermittent hosted macOS live-terminal tests, the frontend's attribution of a line entered in the few +milliseconds around a queued command's approval, Kitty/OSC 66/Chromium output untried in a real Ghostty or Kitty, and the graphics and browser +limits listed in the changelog. + +### 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 60 minutes) + +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: + +```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 +*Updated for the final modes: Queue all is now offered for a list with NMSh commands (see F1).* +1. `pbcopy < retest/paste-mixed.txt`, paste (Cmd+V), Enter (inserts), Enter: nothing runs; the reviewed list opens on **One at a time · 2 commands**, with Queue all also in the mode strip and **no Run as block** (a note says why). 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 while a command runs (#426, #436), Terminal.app, Fish then Bash, 12 min +Launch `nmsh2 fish`; then repeat in `nmsh2 bash`. +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. + +## Final additions (about 70 minutes; separate from the retests above) + +Added after the retests were written: multiline modes, images, text sizing, the document workspace, `/browser` and the arcade. **Every row below is NOT RUN until a person performs it**; automated tests establish protocol output, fallbacks, rules and ownership, not how any of this looks or feels. + +**Fixtures:** `scripts/qa/v022-final-fixtures.sh /Volumes/T7Tmp/ai-dev/q22 <build-worktree>` adds `work/final/` (images, `docs/`, pastes) and the launcher `/Volumes/T7Tmp/ai-dev/q22/nmsh3` (`nmsh3 fish`, `nmsh3 bash`). It uses your QA configuration, its own session service and its own arcade scores and browser profile. A Chrome or Chromium must be installed for F5-F6 (otherwise F5 checks the fallback and F6 is NOT RUN with the reason). Paths are relative to `work/final`. + +### F1 · Multiline modes, Ghostty, zsh, 8 min +1. `pbcopy < paste-multi.txt`, paste, Enter, Enter: the review opens on **One at a time**; the strip also offers Queue all; **Run as block** is absent with the reason "NMSh commands never go to the shell as a block". `Esc` cancels nothing run. +2. `q` (Queue all selected), nothing has run. Enter: `FIRST-OK` prints, `cd docs` runs, `/view README-images.md` opens the document (the sequence waits), Esc closes it, then `pwd` prints `…/docs`. The strip above the composer read `Queue all · n of 4 · Esc stops` while it ran. +3. Repeat with a failing command first (`false` at the top of the paste): the sequence stops, says what did not run. +4. `pbcopy < paste-block.txt`, paste, Enter, Enter, `b`: one block of three lines, with the warning about shells; Enter runs it as one command. +5. A paste with an unclosed quote: only Run as block, Edit and Cancel, Edit selected; Enter puts the text in the composer. +6. Narrow the window to ~40 columns and with `NO_COLOR=1`: the strip, the selected row and the notes stay readable. + +**PASS** if nothing runs before Enter on a mode and no NMSh command ever reaches the shell. + +### F2 · Images, Ghostty (Kitty graphics), 8 min +1. `/doctor`: the Host terminal rows say **Kitty graphics (verified)**; documents use Kitty Unicode placeholders. (If not, say what they say: that is a result.) +2. `/view images/gradient.png`: the picture fills the panel with its aspect kept, no artifacts; header `gradient.png · PNG · 480 × 270`. `+` `+` zooms, arrows pan, `0` fits, `n`/`p` step through the folder, Esc returns to the shell with **no image left on screen**. +3. `/view images/photo.jpg`, `images/small.webp`, `images/small.gif` (a first-frame note for GIF), `images/not-an-image.png` (a plain message, no crash), `images/icon.svg` (source text, **no** `QA-SVG-RAN`). +4. `/view docs/README-images.md` then `h`: the logo is drawn in the text flow and scrolls with it; the remote badge, `../outside`, the symlink and the missing file are `[image: …]` with their reasons; `i` opens the logo, Esc returns to the same place. Resize the window while it is open. +5. Settings → General → Images: Unicode, then Off: the same documents show character art / placeholders; back to Auto. + +**PASS** if images appear, scroll, clear and resize without leftovers, and nothing outside the folder is drawn. + +### F3 · Images, Zed (character art), 5 min +Repeat F2 steps 2-5 in Zed's terminal (`nmsh3`): `/doctor` says no verified protocol and why; pictures are readable quadrant-block art; nothing escapes to the terminal as garbage. Note the resolution is lower than Ghostty's; do not call it equal. + +### F4 · Text sizing, 5 min +1. Ghostty: `/doctor` says whether text sizing is on. Auto is expected **off** (Ghostty is not known to draw scaled glyphs). Settings → Text sizing → Enhanced, `/view docs/typography.md`, `h`: record what you see (scaled headings, or tiny glyphs in big boxes, or nothing). Set it back to Auto. +2. Kitty, if available: Auto should scale H1 (3×), H2 (2×), H3 (1.5×); body, code and the table stay normal; scroll the document so a heading is half out of view: the plain heading shows, no fragment. `c` copies the source with no escape codes. +3. Zed / Terminal.app: headings use weight and color only. + +### F5 · Document workspace, Ghostty or Zed, 10 min +1. `/view docs/page.html`: source with line numbers, header names the file, controls name `h preview`, `e edit`, `R reload from disk`. `h`: the preview (Chromium when installed: the grid, gradient cards, the pulsing dot and the CSS marquee **move**, scripts stay off: "Scripts are off" line unchanged). `T` turns scripts on: `QA-SCRIPT-RAN` appears; `T` again. +2. `e`: type `her` (they are text), `Ctrl+O`: the preview shows your unsaved change; `Ctrl+O` returns to the same caret. Esc → source (`● edited`), `r` in the preview refreshes without losing the edit. `Ctrl+Z` still undoes. +3. In another window change `docs/page.html` on disk, back here `Ctrl+S`: a conflict dialog; Keep editing; `R` asks before discarding. +4. `/edit docs/crlf-bom.html`, type, Ctrl+S, then `xxd docs/crlf-bom.html | head -2`: the BOM and CRLF are kept. +5. `/view docs/README-images.md`, `h`, then `/edit docs/README-images.md`: the same workspace behavior for Markdown. No browser installed: the preview says it is NMSh's own and still shows the document. + +### F6 · `/browser`, Ghostty (Kitty frames) then Zed, 10 min +1. `/browser docs/page.html`: the page renders in the panel, animates; `j`/`k` scroll; `o` the address bar (type `example.com`, Enter); `[` back; `+`/`-` zoom; `/` find; `t` a new tab, `x` closes it. A click on the page works; `i` focuses the page and a typed `h e r` go to a form field in `docs/app.html` (open it with `o`), Esc returns to browser commands. +2. Refused addresses: `o` then `javascript:alert(1)`, `chrome://settings` and `data:text/html,x` are refused with a reason. +3. Esc closes the browser. Activity Monitor: no Chrome processes remain within ~60 s. +4. Zed: the same page as character art; readable layout, lower resolution; interaction works. **Do not call it equivalent to the real pixels.** +5. Settings → Real browser → Off: `/browser` says it is off; the preview uses NMSh's own renderer. + +### F7 · Vespyr Arcade, Ghostty and Zed, 15 min +1. `/games` (also `/game`, `/arcade`): the hub lists five games with best results; Enter plays; `/game dash` opens Dash directly; Esc leaves. +2. Run, Flight, Dash: Space starts, play ~1 minute each. Check: jump/flap feel, speed ramp, no unfair obstacle, Esc/`p` pauses, the game over card offers Retry / Back to Arcade / Exit, a new best says so. `/game run` after a restart shows the saved best. Resize mid-game; NO_COLOR (`NO_COLOR=1 nmsh3`) stays playable. +3. Classic Sudoku: pick Easy, enter digits, `n` notes, `t` a hint, `u` undo, pause hides the board, finishing shows the celebration; relaunch resumes an unfinished puzzle. +4. Vespyr Doku: Easy, move, Space places a cat, `x` marks, `t` explains a hint, a clashing cat is flagged, finishing celebrates; regions stay distinguishable under NO_COLOR (letters and borders). +5. **Ownership:** run `sleep 5`, and while it runs enter `/games`. The hub must not appear over the running command (it says why, or the line waits for your approval as any line entered while a command runs does). Then run `read x`, answer it: the answer goes to the program and no game or panel ever receives it. + +**PASS** if every game is playable, scores persist, nothing the arcade draws remains after leaving, and the composer is exactly as before. + +## 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 +*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 + 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; 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), 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 + 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 +*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 + 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`. diff --git a/llms.txt b/llms.txt index 65fb4d87..66117a56 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.22.0 (see CHANGELOG.md). Homebrew is the recommended macOS install: brew install raiseCatError/tap/nmsh. Source installation remains available. notMyShell is a terminal frontend that runs on top of a real persistent shell instead of replacing the shell. diff --git a/package-lock.json b/package-lock.json index fcf095eb..ddbd23c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "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": { "fast-xml-parser": "5.11.2", + "jpeg-js": "0.4.4", + "marked": "18.1.1", "node-pty": "^1.1.0", "smol-toml": "1.9.0", "string-width": "^8.3.0", @@ -976,6 +978,24 @@ ], "license": "MIT" }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/marked": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz", + "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", diff --git a/package.json b/package.json index 24d47269..7fe859e0 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", @@ -43,6 +43,8 @@ "type": "module", "dependencies": { "fast-xml-parser": "5.11.2", + "jpeg-js": "0.4.4", + "marked": "18.1.1", "node-pty": "^1.1.0", "smol-toml": "1.9.0", "string-width": "^8.3.0", diff --git a/scripts/qa/v021-fixtures.sh b/scripts/qa/v021-fixtures.sh new file mode 100755 index 00000000..be209355 --- /dev/null +++ b/scripts/qa/v021-fixtures.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Fixtures for the v0.21 physical QA walkthrough (docs/testing/v021-physical-qa.md). +# +# scripts/qa/v021-fixtures.sh <new-directory> +# +# Creates, inside <new-directory> (which must not exist yet), a launcher for this checkout's build with an NMSh +# configuration and session service of its own, so the QA build never reads your settings or attaches to your running +# sessions, and the small files the checklist uses. Nothing outside that directory is changed, nothing is downloaded +# or installed, and nothing runs as root. Delete the directory once the results are recorded. +set -euo pipefail + +target=${1:-} +if [ -z "$target" ]; then echo 'usage: scripts/qa/v021-fixtures.sh <new-directory>' >&2; exit 2; fi +case $target in /*) ;; *) target="$PWD/$target" ;; esac +if [ -e "$target" ]; then echo "refusing: $target already exists" >&2; exit 2; fi +repo=$(cd "$(dirname "$0")/../.." && pwd -P) +if [ ! -f "$repo/dist/index.js" ]; then echo "build first: (cd $repo && npm ci && npm run build)" >&2; exit 2; fi +# The session service's socket lives under the directory; Unix socket paths are limited to about 100 bytes. +if [ ${#target} -gt 60 ]; then echo "refusing: $target is longer than 60 characters (session sockets need a short path)" >&2; exit 2; fi + +mkdir -p "$target" +root=$(cd "$target" && pwd -P) +work="$root/work" + +for shell in zsh bash fish; do + mkdir -p "$root/config-$shell/nmsh" "$root/run-$shell" + chmod 700 "$root/run-$shell" + printf '{"onboardingComplete":true,"toolsSetupComplete":true,"shellBackend":"%s"}\n' "$shell" > "$root/config-$shell/nmsh/config.json" +done + +cat > "$root/nmsh" <<EOF +#!/usr/bin/env bash +# The QA build ($repo) with this folder's configuration and session service: ./nmsh [zsh|bash|fish|copy] +# "copy" uses config-copy/nmsh, a copy of an existing configuration you make yourself (see the walkthrough). +profile=\${1:-zsh} +case \$profile in zsh|bash|fish|copy) ;; *) echo 'usage: ./nmsh [zsh|bash|fish|copy]' >&2; exit 2 ;; esac +if [ ! -d "$root/config-\$profile/nmsh" ]; then echo "missing $root/config-\$profile/nmsh" >&2; exit 2; fi +mkdir -p "$root/run-\$profile" && chmod 700 "$root/run-\$profile" +cd "$work" || exit 1 +exec env XDG_CONFIG_HOME="$root/config-\$profile" NMSH_RUNTIME_DIR="$root/run-\$profile" NMSH_DISABLE_UPDATES=1 node "$repo/dist/index.js" +EOF +chmod +x "$root/nmsh" + +# 1. Progress redraws (#388) ------------------------------------------------------------------------------------- +mkdir -p "$work/progress" +cat > "$work/progress/multirow.mjs" <<'EOF' +// Two rows redrawn in place (cursor up, erase line), with log lines printed between frames, as concurrent +// downloads and container pulls draw them. node multirow.mjs [steps] (default 24, a quarter second each). +const steps = Number(process.argv[2] ?? 24); +const sleep = ms => new Promise(done => setTimeout(done, ms)); +const bar = (done, width = 20) => `[${'='.repeat(Math.round(done * width)).padEnd(width)}]`; +const layers = ['layer 1/2 sha256:9f86d081884c', 'layer 2/2 sha256:60303ae22b99']; +process.stdout.write('LOG pulling two layers\n'); +let drawn = 0; +for (let step = 0; step <= steps; step += 1) { + let out = drawn ? `\x1b[${drawn}A` : ''; + if (step > 0 && step % 6 === 0) out += `\x1b[2KLOG checkpoint ${step / 6}\n`; + layers.forEach((name, index) => { + const done = Math.min(1, (step / steps) * (index ? 0.8 : 1.2)); + out += `\x1b[2K${name} ${bar(done)} ${Math.round(done * 100)}%\n`; + }); + process.stdout.write(out); + drawn = layers.length; + await sleep(250); +} +process.stdout.write('LOG done\n'); +EOF +cat > "$work/progress/wrapped.mjs" <<'EOF' +// A progress display that counts the terminal rows it drew (its line is wider than a 40-column window), moves up by +// them, erases below and redraws, as indicatif, pip and cargo do. The LOG lines must all stay. +const sleep = ms => new Promise(done => setTimeout(done, ms)); +const rows = text => Math.max(1, Math.ceil(text.length / (process.stdout.columns || 80))); +process.stdout.write('LOG kept line one\nLOG kept line two\n'); +let drawn = 0; +for (let n = 0; n <= 10; n += 1) { + if (drawn) process.stdout.write(`\r\x1b[${drawn}A\x1b[J`); + const frame = [`Downloading big-archive-name.tar.gz [${'='.repeat(n)}${' '.repeat(10 - n)}] ${n * 10}%`, `eta ${10 - n}s`]; + process.stdout.write(`${frame.join('\n')}\n`); + drawn = frame.reduce((sum, line) => sum + rows(line), 0); + await sleep(300); +} +process.stdout.write('LOG done\n'); +EOF + +# 2. /view -------------------------------------------------------------------------------------------------------- +mkdir -p "$work/view/folder" +cp "$repo/README.md" "$work/view/README.md" +cp "$repo/src/viewer/ViewerPanel.ts" "$work/view/ViewerPanel.ts" +printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > "$work/view/hostile.txt" +printf 'name\tsize\tnote\nalpha\t1\tfirst\nbeta\t22\tsecond\n' > "$work/view/tabs.txt" +mkfifo "$work/view/fifo" +yes 'a log line for the 8 MiB viewer limit check' | head -c 20971520 > "$work/view/big.log" || true + +# 3. /edit -------------------------------------------------------------------------------------------------------- +mkdir -p "$work/edit/empty" "$work/edit/real" +ln -s real "$work/edit/alias" +printf '\357\273\277line one\r\nline two\r\n' > "$work/edit/crlf-bom.txt" +printf 'keep my mode\n' > "$work/edit/mode640.txt" && chmod 640 "$work/edit/mode640.txt" +printf 'the target\n' > "$work/edit/target.txt" && ln -s target.txt "$work/edit/link.txt" +printf 'read only\n' > "$work/edit/readonly.txt" && chmod 444 "$work/edit/readonly.txt" +yes 'a line for the 1 MiB editor limit check' | head -c 2097152 > "$work/edit/big.txt" || true + +# 4. Semantic Output ---------------------------------------------------------------------------------------------- +mkdir -p "$work/semantic/pass/test" "$work/semantic/fail/test" "$work/semantic/ts/node_modules/.bin" "$work/semantic/c" +for kind in pass fail; do + printf '{"name":"qa-%s","private":true,"scripts":{"test":"node --test"}}\n' "$kind" > "$work/semantic/$kind/package.json" + { + echo "import test from 'node:test';" + echo "import assert from 'node:assert/strict';" + for n in $(seq 1 40); do echo "test('adds case $n', () => assert.equal($n + $n, $((n * 2))));"; done + if [ "$kind" = fail ]; then + echo "test('rounds a price', () => assert.equal(Math.round(2.5), 2));" + echo "test('parses a date', () => assert.equal(new Date('2026-10-18').getUTCDate(), 19));" + fi + } > "$work/semantic/$kind/test/math.test.mjs" +done +printf '{"name":"qa-ts","private":true}\n' > "$work/semantic/ts/package.json" +printf 'const count: number = "three";\nexport const total = count + missingValue;\n' > "$work/semantic/ts/bad.ts" +ln -s "$repo/node_modules/typescript/bin/tsc" "$work/semantic/ts/node_modules/.bin/tsc" +printf 'int main(void) {\n return undefined_name;\n}\n' > "$work/semantic/c/bad.c" +printf 'bad.o: bad.c\n\tcc -c bad.c -o bad.o\n' > "$work/semantic/c/Makefile" + +# A small repository for `git status` (no remote, no hooks, a local identity for its one commit). +mkdir -p "$work/git" +printf 'tracked\n' > "$work/git/tracked.txt" +git -C "$work/git" init -q +git -C "$work/git" add tracked.txt +git -C "$work/git" -c user.name=QA -c user.email=qa@example.invalid -c commit.gpgsign=false commit -q -m 'QA fixture' +printf 'untracked\n' > "$work/git/untracked.txt" + +# 5. /track ------------------------------------------------------------------------------------------------------- +mkdir -p "$work/track" +printf 'one\ntwo\nthree\n' > "$work/track/f.txt" + +# 6. Modules ------------------------------------------------------------------------------------------------------ +mkdir -p "$work/modules/bun-app" +printf '{"name":"bun-app","private":true,"packageManager":"bun@1.1.30"}\n' > "$work/modules/bun-app/package.json" +printf '{"lockfileVersion": 1}\n' > "$work/modules/bun-app/bun.lock" + +printf 'QA fixtures ready in %s\nStart the QA build with: %s/nmsh (or: %s/nmsh fish, %s/nmsh bash)\n' "$root" "$root" "$root" "$root" diff --git a/scripts/qa/v022-final-fixtures.sh b/scripts/qa/v022-final-fixtures.sh new file mode 100755 index 00000000..54f6f3b6 --- /dev/null +++ b/scripts/qa/v022-final-fixtures.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Fixtures for the final v0.22 physical QA additions: images, text sizing, the document workspace, the browser and the arcade +# (docs/testing/v022-physical-qa.md, "Final additions"). +# +# scripts/qa/v022-final-fixtures.sh <q22-folder> <qa-build-worktree> +# +# Adds work/final/ and a launcher, nmsh3, for the build under test: your QA configuration folders (settings carry over), its own +# session service, its own arcade scores (the QA configuration folder, never your real ones). Nothing already in the folder is +# changed or removed, nothing is downloaded or installed, and nothing runs as root. Needs a built tree (npm run build). +set -euo pipefail + +root=${1:-} +build=${2:-} +if [ -z "$root" ] || [ -z "$build" ]; then echo 'usage: scripts/qa/v022-final-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" ] || [ ! -f "$build/dist/graphics/pngEncode.js" ]; then echo "$build has no build: run npm run build there first" >&2; exit 2; fi +final="$root/work/final" +mkdir -p "$final/images" "$final/docs/img" "$final/outside" +add() { if [ -e "$1" ]; then echo "kept $1"; else cat > "$1"; fi; } + +add "$root/nmsh3" <<EOF2 +#!/usr/bin/env bash +# The build under test ($build) with this folder's configuration and its own session service: ./nmsh3 [zsh|bash|fish] +profile=\${1:-zsh} +case \$profile in zsh|bash|fish) ;; *) echo 'usage: ./nmsh3 [zsh|bash|fish]' >&2; exit 2 ;; esac +mkdir -p "$root/run3-\$profile" && chmod 700 "$root/run3-\$profile" +cd "$root/work" || exit 1 +exec env XDG_CONFIG_HOME="$root/config-\$profile" NMSH_RUNTIME_DIR="$root/run3-\$profile" NMSH_DISABLE_UPDATES=1 node "$build/dist/index.js" +EOF2 +chmod +x "$root/nmsh3" + +# Images: a gradient, a logo-like picture, a tall and a wide one, a JPEG, and a transparent PNG, made with the build's own encoders. +node --input-type=module - "$build" "$final" <<'EOF2' +import {writeFileSync, existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +const [build, final] = process.argv.slice(2); +const {encodePng} = await import(pathToFileURL(join(build, 'dist/graphics/pngEncode.js')).href); +const {default: jpeg} = await import(pathToFileURL(join(build, 'node_modules/jpeg-js/index.js')).href); +const make = (width, height, paint) => { 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, width, height), (y * width + x) * 4); return {width, height, data}; }; +const gradient = (x, y, w, h) => [Math.round(255 * x / w), Math.round(255 * y / h), 180, 255]; +const logo = (x, y, w, h) => { const dx = x - w / 2, dy = y - h / 2; const r = Math.hypot(dx, dy); return r < h * 0.42 ? [172, 150, 230, 255] : r < h * 0.5 ? [90, 80, 140, 255] : [0, 0, 0, 0]; }; +const write = (name, bytes) => { const path = join(final, name); if (!existsSync(path)) writeFileSync(path, bytes); }; +write('images/gradient.png', encodePng(make(480, 270, gradient))); +write('images/logo.png', encodePng(make(256, 256, logo))); +write('images/tall.png', encodePng(make(120, 480, gradient))); +write('images/wide.png', encodePng(make(900, 120, gradient))); +write('images/photo.jpg', jpeg.encode({width: 320, height: 200, data: Buffer.from(make(320, 200, gradient).data)}, 85).data); +write('docs/img/logo.png', encodePng(make(256, 256, logo))); +write('outside/secret.png', encodePng(make(64, 64, gradient))); +EOF2 +# A tiny GIF (two frames) and a WebP (lossy, 8x8). +[ -e "$final/images/small.gif" ] || printf 'R0lGODlhCAAIAPAAAP8AAAAAACH5BAAKAAAALAAAAAAIAAgAAAIMhI+py+0Po5y02ouzPgUAOw==' | base64 -d > "$final/images/small.gif" +[ -e "$final/images/small.webp" ] || printf 'UklGRmYAAABXRUJQVlA4IFoAAACwAgCdASoIAAgAAMASJbACdHIAq//cAN4AA/P3/oAA/v58L7yT2/8MSpz/9/v6n+xRs2P8+xRv+xkjr/40Sj85j/hZmWOCH//vhR/+FU3BD//2oj5vzOrzuAA=' | base64 -d > "$final/images/small.webp" +[ -e "$final/docs/link" ] || [ -L "$final/docs/link" ] || ln -s ../outside "$final/docs/link" +add "$final/images/icon.svg" <<'EOF2' +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><script>alert('QA-SVG-RAN')</script><circle cx="32" cy="32" r="30" fill="#ac96e6"/></svg> +EOF2 +add "$final/images/not-an-image.png" <<'EOF2' +this is text pretending to be a picture +EOF2 + +add "$final/docs/README-images.md" <<'EOF2' +# Image fixtures + +A local picture alone in its paragraph: + +![The logo](img/logo.png) + +A picture inside a link: + +[![Linked logo](img/logo.png)](https://example.com/project) + +Text with an ![inline logo](img/logo.png) in the middle of a sentence stays a placeholder. + +A remote badge, never fetched: ![Build badge](https://example.com/badge.png) + +A path out of this folder: ![Outside](../outside/secret.png) + +A symlink out of this folder: ![Link](link/secret.png) + +A missing file: ![Gone](img/missing.png) + +An SVG, never drawn: ![Icon](../images/icon.svg) + +<p align="center"><img src="img/logo.png" alt="HTML logo" width="120"></p> +EOF2 + +add "$final/docs/typography.md" <<'EOF2' +# Heading one + +Body text under the first heading, with **bold**, _italic_, `code` and a [link](https://example.com). + +## Heading two + +> A quotation under the second heading. + +### Heading three + +- [x] a finished task +- [ ] an open task + +#### Heading four + +| Name | Value | +|---|---| +| alpha | 1 | +| beta | 2 | + +##### Heading five + +```sh +echo "code stays ordinary size" +``` + +###### Heading six + +## 日本語 heading stays ordinary text +EOF2 + +add "$final/docs/page.html" <<'EOF2' +<!doctype html> +<html><head><meta charset="utf-8"><title>QA page + + +

QA page: real CSS

+

Flexbox, grid, gradients, transitions, keyframe animations, SVG and an image beside this file.

+
+
One
Two
Three
+

A CSS-only marquee: it moves with scripts off. A CSS-only marquee: it moves with scripts off.

+SVG text +

local logo An external link

+

Scripts are off: this line stays as written.

+ + +EOF2 + +add "$final/docs/app.html" <<'EOF2' + +QA app + +

QA app (needs scripts)

+ +

+ + +EOF2 + +printf '\xef\xbb\xbf

BOM and CRLF

\r\n

kept exactly on save

\r\n' > "$final/docs/crlf-bom.html.tmp" && { [ -e "$final/docs/crlf-bom.html" ] || mv "$final/docs/crlf-bom.html.tmp" "$final/docs/crlf-bom.html"; rm -f "$final/docs/crlf-bom.html.tmp"; } + +add "$final/paste-multi.txt" <<'EOF2' +echo FIRST-OK +cd docs +/view README-images.md +pwd +EOF2 +add "$final/paste-block.txt" <<'EOF2' +for n in 1 2 3 +do + echo "block $n" +done +EOF2 +echo "Final-addition fixtures ready in $final. Start the build under test with: $root/nmsh3 (or: $root/nmsh3 fish, $root/nmsh3 bash)" diff --git a/scripts/qa/v022-fixtures.sh b/scripts/qa/v022-fixtures.sh new file mode 100755 index 00000000..ebf337e9 --- /dev/null +++ b/scripts/qa/v022-fixtures.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Fixtures for the v0.22 physical QA walkthrough (docs/testing/v022-physical-qa.md). +# +# scripts/qa/v022-fixtures.sh +# +# 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 ' >&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" < "$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 +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. +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)" diff --git a/scripts/qa/v022-retest-fixtures.sh b/scripts/qa/v022-retest-fixtures.sh new file mode 100755 index 00000000..53e9078b --- /dev/null +++ b/scripts/qa/v022-retest-fixtures.sh @@ -0,0 +1,127 @@ +#!/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 +# +# 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 ' >&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" <&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' +
+ + + + QA logo + + +# HTML inside Markdown + +

+ CI badge + Bold words and italic words; press Ctrl+C. +

+ +
+ +Markdown after the HTML: a list. + +- one +- two +EOF +add "$retest/page.html" <<'EOF' + +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" + +# 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/scripts/test-file-teardown.mjs b/scripts/test-file-teardown.mjs new file mode 100644 index 00000000..422ab8a2 --- /dev/null +++ b/scripts/test-file-teardown.mjs @@ -0,0 +1,49 @@ +import {after} from 'node:test'; +import {spawnSync} from 'node:child_process'; + +/** + * Preloaded into every test file's process by scripts/test.mjs. A test that stalls past its timeout is cancelled, but + * whatever it started keeps running: a live shell PTY or a piped child then keeps the file's process alive, the file + * never finishes, its failure is never printed, and a CI job waits for its own time limit (#415's Ubuntu shard). + * Once the file's tests are done, a timer that cannot itself keep the process alive gives the file's own cleanup time + * (NMSH_TEST_TEARDOWN_MS, default 15 s); if the process is still alive then, every process the file left running is + * ended and named, so the stall is reported instead of hanging the run. + */ +if (process.env.NODE_TEST_CONTEXT) { + after(() => { + const delay = Number(process.env.NMSH_TEST_TEARDOWN_MS) || 15_000; + setTimeout(endLeftovers, delay).unref(); + }); +} + +export function endLeftovers() { + const rows = processTable(); + const own = rows.find(row => row.pid === process.pid)?.group; + const left = descendants(rows, process.pid).filter(row => !/(?:^|\/)(?:esbuild|ps)$/u.test(row.command)); + for (const row of left) { + // A leader of its own group (a PTY shell, a detached helper) takes its whole group with it; never our own group. + const target = row.group === row.pid && row.group !== own ? -row.group : row.pid; + try { process.kill(target, 'SIGKILL'); } catch { /* Already gone. */ } + } + if (left.length) process.stderr.write(`test teardown: ended ${left.length} process${left.length === 1 ? '' : 'es'} this file left running: ${left.map(row => `${row.pid} ${row.command}`).join(', ')}\n`); + return left; +} + +function processTable() { + const result = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,pgid=,comm='], {encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024}); + if (result.status !== 0 || !result.stdout) return []; + return result.stdout.trim().split('\n').map(line => { + const [pid, parent, group, ...command] = line.trim().split(/\s+/u); + return {pid: Number(pid), parent: Number(parent), group: Number(group), command: command.join(' ')}; + }); +} + +function descendants(rows, root) { + const found = []; + const queue = [root]; + while (queue.length) { + const parent = queue.shift(); + for (const row of rows) if (row.parent === parent && row.pid !== root) { found.push(row); queue.push(row.pid); } + } + return found; +} diff --git a/scripts/test-shell-home.mjs b/scripts/test-shell-home.mjs new file mode 100644 index 00000000..be75a11c --- /dev/null +++ b/scripts/test-shell-home.mjs @@ -0,0 +1,17 @@ +import {mkdirSync} from 'node:fs'; +import {join} from 'node:path'; + +/** + * Marks Fish's man-page completion generation as already done in a test home (#403). + * + * On a home without generated completions, an interactive Fish starts `fish_update_completions` detached + * (Fish 4 checks `$__fish_cache_dir/generated_completions`, Fish 3 `$__fish_user_data_dir/generated_completions`). + * That orphaned process keeps writing about 1,400 files into the home after the shell exits, so removing the home + * races it. Real installations keep Fish's normal behavior; only test homes are marked, in both places. + * + * @param {string} home the HOME the test's shells run with + * @param {{dataHome?: string, cacheHome?: string}} [dirs] XDG_DATA_HOME / XDG_CACHE_HOME when the test sets them + */ +export function markFishCompletionsGenerated(home, {dataHome = join(home, '.local', 'share'), cacheHome = join(home, '.cache')} = {}) { + for (const base of [dataHome, cacheHome]) mkdirSync(join(base, 'fish', 'generated_completions'), {recursive: true}); +} diff --git a/scripts/test.mjs b/scripts/test.mjs index 482eb2b1..902711d0 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -4,6 +4,7 @@ import {availableParallelism, tmpdir} from 'node:os'; import {join} from 'node:path'; import {pathToFileURL} from 'node:url'; import {discoverTestFiles, parseTestOptions, selectTestGroups, testConcurrency} from './test-selection.mjs'; +import {markFishCompletionsGenerated} from './test-shell-home.mjs'; /** Short private temp roots keep Unix sockets below their path length limit. */ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio = 'inherit', report = message => console.error(message)} = {}) { @@ -12,15 +13,23 @@ export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio try { const testHome = join(root, 'home'); mkdirSync(testHome, {mode: 0o700}); + markFishCompletionsGenerated(testHome); // Existing presentation snapshots pin truecolor; baseline fixtures override this explicitly. // Tests never see the user's real NMSh settings: configuration lives under this run's private root. const env = {...process.env, HOME: testHome, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config'), NMSH_DISABLE_UPDATES: '1'}; // A nested runner must not impersonate its parent's test worker. delete env.NODE_TEST_CONTEXT; + // Ambient XDG data, cache and state directories (a developer's, or a wrapper's shared cache) are not the tests': + // shells and tools fall back to the private HOME, as they do on CI where these are unset. + delete env.XDG_DATA_HOME; + delete env.XDG_CACHE_HOME; + delete env.XDG_STATE_HOME; // PTY workers also own shell/helper processes. Bound fan-out on larger // development hosts; explicit Node --test-concurrency options still win. const concurrency = testConcurrency(process.env.NMSH_TEST_CONCURRENCY, availableParallelism()); - const child = spawn(process.execPath, ['--import=tsx', '--test', `--test-concurrency=${concurrency}`, ...args, ...files], { + // Each file's process ends what it left running once its tests are done (a stalled test cannot hang the run). + const teardown = new URL('./test-file-teardown.mjs', import.meta.url).href; + const child = spawn(process.execPath, ['--import=tsx', `--import=${teardown}`, '--test', `--test-concurrency=${concurrency}`, ...args, ...files], { cwd, stdio, env, }); let interrupted = false; diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e727d766..1ce99b0a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -5,10 +5,15 @@ 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'; 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'; @@ -61,13 +66,19 @@ import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../th import {applyThemeBridge, bridgeColorLevel, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelAction, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; -import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection} from '../clipboard/copySelection.js'; +import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection, semanticCopyPayload, type CopyContent} from '../clipboard/copySelection.js'; import {captureOutputAnchor, createOutputSelection, moveOutputSelection, restoreOutputAnchor, revealOutputSelection, validateOutputSelection, type OutputAnchor, type OutputScope, type OutputSelection} from '../output/OutputSelection.js'; import {copyPickerKey, createCopyPicker, renderCopyPicker, type CopyPickerState} from '../ui/CopyPicker.js'; import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, type ReportFormat} from '../clipboard/report.js'; import {applyReportEdit, createReportReview, createTextReview, renderReportReview, reportReviewKey, type ReportReviewState} from '../ui/ReportReview.js'; import {configuredEditor} from '../host/HostActions.js'; import {comparePanelKey, createComparePanel, renderComparePanel, type ComparePanelState} from '../ui/ComparePanel.js'; +import {displayPath, loadFileDocument, outputDocument, 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'; +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'; @@ -111,9 +122,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'; @@ -197,7 +208,7 @@ import {helpMarkdown} from '../help/helpContent.js'; import {renderMarkdownText} from '../help/markdown.js'; import {sweepAnimates, sweepAnsiRow, sweepCells, sweepOnce} from '../motion/lightSweep.js'; import {sweepStill, sweepStyleFor} from '../motion/sweepStyle.js'; -import {mixRgb} from '../chroma/chroma.js'; +import {luminance, mixRgb, resolveColor, theme as themeRole} from '../chroma/chroma.js'; import type {Rgb} from '../chroma/escape.js'; import {isDeterministicPresentation, isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; import {TaskProgress} from '../status/TaskProgress.js'; @@ -206,7 +217,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, 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'; @@ -216,13 +227,14 @@ 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 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'; 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'; @@ -244,6 +256,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'; @@ -263,13 +276,16 @@ 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 {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; -import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.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'; +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, 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'; @@ -279,7 +295,19 @@ import {expandSteps, quotedPlaceholder} from '../pins/recipes.js'; import {splitBatch} from '../input/pasteBatch.js'; import {verifyCommands} from '../input/pasteBatchCheck.js'; import {WHY_FAILED} from '../ask/failure.js'; -import {nmshConfigDirectory} from '../configuration/paths.js'; +import {nmshCacheDirectory, nmshConfigDirectory} from '../configuration/paths.js'; +import {configureTextSizing, describeTextSizing, textSizingEnabled} from '../ui/textSizing.js'; +import {BrowserHost} from '../browser/host.js'; +import {BrowserSession} from '../browser/session.js'; +import {browserPanelKey, createBrowserPanel, renderBrowserPanel, type BrowserPanelState} from '../browser/BrowserPanel.js'; +import {BrowserPreviewView} from '../browser/documentPreview.js'; +import {normalizeAddress} from '../browser/address.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'; import {renameSession} from '../session/SocketSessionClient.js'; @@ -336,7 +364,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'; @@ -384,7 +412,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 '; @@ -415,8 +443,55 @@ 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; +/** 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. + */ +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 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'; +/** + * 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(['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[]; + 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; + /** 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; + /** + * 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. */ const QUEUE_CONFIRM_SETTLE_MS = 600; /** The transcript keeps at least this many rows when the expanded queue takes some (as withQueueRows does). */ @@ -473,6 +548,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. @@ -760,10 +837,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): void { const controller = this.keepAwake(); if (slash.op === 'panel') { @@ -810,7 +905,11 @@ 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; + /** 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; @@ -835,12 +934,47 @@ 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). */ + /** 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). */ - private ownershipLook?: {keys: Key[]; startId: number; answering: boolean; release: () => void}; + private ownershipLook?: OwnershipHold; + /** + * 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}; + /** 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}; + /** + * 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 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}; + /** + * 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. */ + 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). @@ -935,6 +1069,8 @@ export class TerminalApp { this.output.setTranscriptAppearance(this.promptConfiguration.transcript); this.output.presenter.setTreatment(this.promptConfiguration.presentation); this.output.setOutputFolding(this.promptConfiguration.outputFolding); + this.output.setSemanticOutput(this.promptConfiguration.semanticOutput); + this.output.setLiveRows((id, width) => this.trackRows(id, width)); this.output.presenter.setLayout(this.promptConfiguration.transcriptPresentation); this.output.presenter.setHyperlinks(this.host.capabilities.hyperlinks); this.renderer.setCursorStyle(cursorStyleSequence(this.promptConfiguration.cursor.shape, this.promptConfiguration.cursor.blink)); @@ -997,6 +1133,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(); @@ -1163,6 +1301,9 @@ 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(); + this.browserPanel?.session.forgetPictures(); // 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. @@ -1220,6 +1361,16 @@ 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); + } + if (this.browserPanel) { + this.closeBrowser(false); + this.output.addFrontendInteraction('/browser', 'Closed the browser: a command started and owns the keyboard. Open it again at the prompt.', INFO); + } this.commandModes.reset(); this.commandLine.reset(); this.inputState = undefined; @@ -1289,7 +1440,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); @@ -1374,13 +1525,14 @@ 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) { // 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; } @@ -1457,6 +1609,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) { @@ -1527,6 +1680,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. @@ -1620,6 +1774,65 @@ 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.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.browserPanel) { + const panel = this.browserPanel; + const action = browserPanelKey(panel, key, this.panelGeometry(panel.session.viewport().scale)); + if (action?.kind === 'close') this.closeBrowser(); + else if (action?.kind === 'copy') void this.copyText(action.text, action.label); + else if (action?.kind === 'open-document') { this.closeBrowser(false); void this.openDocumentSession(action.path, '/browser', 'preview', true); } + 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); + // Leaving the preview closes its browser page; the engine stays warm for a while and stops by itself when idle. + if (session.mode !== 'preview' && session.preview) { session.preview.dispose(); delete session.preview; } + 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(); + 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 === '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(); } }); + } + this.render(); + return; + } if (this.comparePanel) { const {columns, rows} = this.dimensions(); const action = comparePanelKey(this.comparePanel, key, columns, rows - 4); @@ -1893,7 +2106,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; } @@ -1903,13 +2116,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; @@ -1960,20 +2188,16 @@ 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.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') { done(); void this.runReviewedBlock(batchBlockText(review)); @@ -2035,6 +2259,18 @@ 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(); + 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; @@ -2160,6 +2396,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(); @@ -2247,9 +2487,16 @@ 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'); + // 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; } + else { hold.keys.push(key); return; } } else if (this.needsOwnershipLook(key)) { this.holdForOwnershipLook(key); return; @@ -2263,6 +2510,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(); @@ -2282,7 +2530,9 @@ 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, + ...(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. @@ -2340,7 +2590,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; @@ -2358,9 +2608,12 @@ 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: + // 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; @@ -2438,8 +2691,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') { @@ -2539,22 +2791,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)); @@ -2643,10 +2893,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 { const input = this.editor.text; if (!this.directorySearchActive && this.directoryQuery !== undefined) { @@ -2873,6 +3143,13 @@ 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 === 'arcade') this.openArcade(slash.game, command.trim()); + else if (slash.kind === 'browser') await this.openBrowser(slash.target, command.trim()); + else if (slash.kind === 'editFile') { + if (!slash.target) this.output.addFrontendInteraction(command.trim(), 'Name a file: /edit notes.md. Your own editor and vim, nano or micro still work as usual.', INFO); + else this.openTextEditor(slash.target, command.trim()); + } else if (slash.kind === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -2893,6 +3170,9 @@ export class TerminalApp { if (this.appearanceHub) { this.appearanceHub.view = 'motion'; this.appearanceHub.selected = 0; this.appearanceHub.previewStart = Date.now(); } } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } + else if (slash.kind === 'modules' && slash.report === 'explain') this.output.addFrontendBlock(command.trim(), explainModules(this.promptConfiguration.modules, this.modulesContext(), this.promptConfiguration, Date.now(), + (id, surface) => fitOutcome(surface === 'contextRail' ? this.lastFit.rail : surface === 'statusStrip' ? this.lastFit.strip : undefined, id))); + else if (slash.kind === 'modules' && slash.report === 'timings') this.output.addFrontendBlock(command.trim(), moduleTimings(id => this.contextEngine.status(id))); else if (slash.kind === 'modules') { this.panelOrigin = undefined; this.openModulesManager(); } else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } else if (slash.kind === 'screensaver') { @@ -2979,7 +3259,7 @@ export class TerminalApp { else if (slash.kind === 'providers') this.openProvidersOverview(slash.family); else if (slash.kind === 'llm') this.openUnderstandingPanel(); else if (slash.kind === 'doctor') void this.openDoctor(); - else if (slash.kind === 'watch') this.handleWatch(command, slash.op, slash.arguments); + else if (slash.kind === 'watch') this.handleWatch(command.trim(), slash.op, slash.arguments, slash.track === true); else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); @@ -3008,7 +3288,7 @@ export class TerminalApp { category: 'Transcript', action: {kind: 'keepHistoryClip', id: history.id}}] : []; const composerItems: PaletteItem[] = this.editor.text.trim() && !isSlashInput(this.editor.text) ? [{id: 'clip:keep-composer', label: 'Keep composer draft as clip', detail: 'Keep text in memory without changing the draft or executing it', category: 'Transcript', action: {kind: 'keepComposerClip'}}] : []; - this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record) : [])]); + this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record, this.blockOptions(record)) : [])]); } /** Newest-first indexes of completed commands that did not exit 0 (a nonzero exit or an interrupt is authoritative; output text is not). */ @@ -3200,7 +3480,7 @@ export class TerminalApp { if (this.running || this.settingsPanelActive) return; const record = this.output.view().completed.find(item => item.startId === startId); if (record && !record.frontend) { - this.paletteState = createPalette(blockPaletteItems(record)); + this.paletteState = createPalette(blockPaletteItems(record, this.blockOptions(record))); this.blockPaletteContext = {palette: this.paletteState, scope: this.outputScope()}; if (this.outputNavigation?.intent === 'actions' && this.outputNavigation.focusedId === startId) { this.blockPaletteReturn = {selection: this.outputNavigation, palette: this.paletteState}; @@ -3213,7 +3493,7 @@ export class TerminalApp { const records = this.output.view().completed; const index = records.findIndex(item => item.startId === startId); const record = records[index]; - if (!record || !blockPaletteItems(record).some(item => item.action.kind === 'block' && item.action.id === action)) return; + if (!record || !blockPaletteItems(record, this.blockOptions(record)).some(item => item.action.kind === 'block' && item.action.id === action)) return; // Selection itself never runs actions. An explicit choice ends it before any editor/program handoff. this.endOutputNavigation(false); const includeStatus = this.promptConfiguration.copy.includeStatus; @@ -3230,6 +3510,8 @@ export class TerminalApp { try { await writeClipboard(record.command); this.noteClipboard(`Copied command · ${copyStats(record.command).characters.toLocaleString()} characters`, 'success'); } catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } } else if (action === 'fold') this.output.toggleExpanded(index); + else if (action === 'view') this.openOutputViewer(record); + else if (action === 'details') this.output.toggleSemanticDetails(record.startId); else if (action === 'pager') await this.openBlockInPager(record); else if (action === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } else if (action === 'edit' || action === 'rerun') { @@ -3361,8 +3643,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; } @@ -3553,6 +3839,576 @@ 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, ...(this.viewerImages ? {images: this.viewerImages} : {}), sized: textSizingEnabled()}; + } + + /** Which way images are drawn right now, from the live capabilities and settings. */ + private graphicsDecision(): ReturnType { + 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}); + } + + /** 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: () => this.graphicsDecision(), + 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}); + // 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); + } + + private closeImageView(): void { + const view = this.imageView; + if (!view) return; + this.imageView = undefined; + this.graphics?.releaseOwner(view.owner); + if (view.returnToSession) this.docSession = view.returnToSession as DocumentSession; + else 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}; + } + + /** 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.docSession) this.releaseViewerImages(); + 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}] : []; + }); + } + + // ---- Real browser ------------------------------------------------------------------------------------------- + + private browserHostService(): BrowserHost { + this.browserHost ??= new BrowserHost({profileDir: join(nmshCacheDirectory(), 'browser'), idleMs: 60_000, env: process.env}); + return this.browserHost; + } + + /** Whether the page should be told the terminal is dark: the theme's text is light. */ + private themeColorScheme(): 'light' | 'dark' { + return luminance(resolveColor(themeRole('primary'))) > 0.5 ? 'dark' : 'light'; + } + + /** Where the panel's first row is on the screen (1-based), for mapping mouse positions onto a page. */ + private panelGeometry(scale: number): {top: number; left: number; cell: {width: number; height: number}; scale: number} { + const plan = this.presentationFrame?.plan; + const region = plan?.regions.find(item => item.kind === 'panel'); + return {top: (region?.top ?? 0) + 1 - (plan?.panelPosition === 'top' ? 1 : 0), left: 1 + (this.workspace.open ? this.workspaceColumns().center.x : 0), cell: this.graphicsDecision().cell, scale}; + } + + private newBrowserSession(engine: Awaited>): BrowserSession { + const graphics = this.graphicsService(); + const session = new BrowserSession({ + engine, openStream: owner => graphics.openStream(owner), cell: () => this.graphicsDecision().cell, scale: 'auto', + colorScheme: this.themeColorScheme(), reducedMotion: !this.decorativeMotionAllowed(), + }); + session.onChange(() => { if (!this.stopped) this.render(); }); + return session; + } + + /** /browser [address | file]: a real Chromium in a panel. Never while a program owns the terminal; never downloads a browser. */ + private async openBrowser(target: string, command: string): Promise { + if (this.running || this.passthrough || this.externalPassthrough || this.startupPending) { + this.output.addFrontendInteraction(command, 'The browser opens at the prompt: let the running command finish first, so its input is never taken by a page.', INFO); + return; + } + if (this.promptConfiguration.browser === 'off') { + this.output.addFrontendInteraction(command, 'The real browser is off (Settings → General → Real browser). /view still renders documents with NMSh\'s own renderer.', INFO); + return; + } + const address = target ? normalizeAddress(target, {cwd: this.shellCwd, home: homedir()}) : undefined; + if (address && !address.ok) { this.output.addFrontendInteraction(command, address.reason, INFO); return; } + if (address?.ok && address.local) { await this.openDocumentSession(address.local, command, 'preview', true); return; } + const host = this.browserHostService(); + const availability = await host.availability(); + if (!availability.ok) { + this.output.addFrontendInteraction(command, `${availability.reason} NMSh does not download a browser; set NMSH_BROWSER to one you installed. /view renders documents with NMSh's own renderer.`, INFO); + return; + } + if (this.stopped || this.hasModalInput()) return; + let engine; + try { engine = await host.engine(); } + catch (error) { this.output.addFrontendInteraction(command, error instanceof Error ? error.message : 'The browser could not start.', ERROR); return; } + if (this.stopped || this.hasModalInput()) return; + const session = this.newBrowserSession(engine); + const panel = createBrowserPanel(session, {cwd: this.shellCwd, home: homedir()}); + this.panelOrigin = undefined; + this.browserPanel = panel; + this.render(); + const opened = await session.open(address?.ok ? address.url : 'about:blank'); + if (!opened.ok) this.output.addFrontendInteraction(command, opened.reason, ERROR); + if (!address) { panel.focus = 'address'; panel.draft = ''; } + this.render(); + } + + private closeBrowser(restore = true): void { + const panel = this.browserPanel; + if (!panel) return; + this.browserPanel = undefined; + panel.session.dispose(); + const back = this.browserReturn; + this.browserReturn = undefined; + if (!restore) return; + if (back) this.docSession = back; + else this.returnFromPanel(); + this.render(); + } + + /** A link the preview refused, opened in the real browser on the person's request; Esc there returns to the document. */ + private openLinkInBrowser(url: string): void { + const session = this.docSession; + if (!session) return; + this.browserReturn = session; + this.docSession = undefined; + void this.openBrowser(url, '/browser').then(() => { if (!this.browserPanel && this.browserReturn === session) { this.docSession = session; this.browserReturn = undefined; this.render(); } }); + } + + /** Start the document session's Chromium preview (the native renderer shows meanwhile, and stays if no browser can be used). */ + private async startBrowserPreview(session: DocumentSession): Promise { + if (this.previewStarting === session || session.preview || this.promptConfiguration.browser === 'off') return; + this.previewStarting = session; + try { + const host = this.browserHostService(); + const availability = await host.availability(); + if (!availability.ok) { session.note = `${availability.reason} Showing NMSh's own preview.`; return; } + const [engine, previews] = await Promise.all([host.engine(), host.previews()]); + if (this.docSession !== session || session.mode !== 'preview') return; + const browserSession = this.newBrowserSession(engine); + const file = session.editor.file; + const kind = formatForPath(file.path).format === 'html' ? 'html' : 'markdown'; + const view = await BrowserPreviewView.open({ + session: browserSession, previews, html: text => host.html(text, kind, {title: file.title, colorScheme: this.themeColorScheme()}), + geometry: () => this.panelGeometry(browserSession.viewport().scale), openLink: url => this.openLinkInBrowser(url), dirty: () => session.editor.buffer.dirty, + }, {name: file.title, root: dirname(file.realPath), text: bufferText(session.editor), trusted: session.trusted}); + if ('error' in view) { browserSession.dispose(); session.note = `${view.error} Showing NMSh's own preview.`; return; } + if (this.docSession !== session || session.mode !== 'preview') { view.dispose(); return; } + session.preview = view; + // The person may have turned scripts on or off while the preview was starting: what runs must match what the screen says. + if (view.trusted !== session.trusted) void view.setTrusted(session.trusted, bufferText(session.editor)); + } catch (error) { + session.note = `${error instanceof Error ? error.message : 'The browser preview could not start.'} Showing NMSh's own preview.`; + } finally { + if (this.previewStarting === session) this.previewStarting = undefined; + this.render(); + } + } + + // ---- 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(); } + } + + /** + * /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; + } + // 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 => { + 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; + // Another panel opened while the file was read: it keeps the keys; the file is not opened over it. + if (this.hasModalInput()) { images?.close(); return; } + this.panelOrigin = undefined; + this.releaseViewerImages(); + if (images?.count) this.viewerImages = images; + else images?.close(); + this.viewer = createViewer(document); + this.render(); + }); + } + + /** + * /edit: open one small text file in NMSh's editor, or say in the transcript why it cannot be edited (refused files + * stay readable in /view). `returnTo` is the viewer it was opened from; it is reopened if the file cannot be edited. + */ + private openTextEditor(target: string, command: string, returnTo?: ViewerState): void { + 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) { + 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(); + }); + } + + // ---- 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); + // A real browser renders the same buffer when one is installed; refreshing swaps the served page and reloads it. + if (session.preview) { if (refresh) void session.preview.refresh(bufferText(session.editor)); return; } + void this.startBrowserPreview(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.'; + void session.preview?.setTrusted(action.on, bufferText(editor)); + 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; + this.panelOrigin = undefined; + // The text /copy copies (tabs as the program wrote them), without the optional completion status. + this.viewer = createViewer(outputDocument({command: record.command, output: recordCopyText(record, false), exitCode: record.exitCode}, Math.max(1, number))); + this.render(); + } + /** Compare output…: straight to the difference when the other side is known, otherwise choose it (previous run first). */ private openCompare(base: CompletedCommand, partner?: CompletedCommand): void { this.panelOrigin = undefined; @@ -3575,7 +4431,9 @@ export class TerminalApp { if (!this.outputNavigationAvailable()) return; const parsed = parseCopyArgs(args); if (!parsed.ok) { this.output.addFrontendInteraction(command.trim(), parsed.error, ERROR); return; } - const {selector, status, report} = parsed.request; + const {status, report, content} = parsed.request; + // --semantic and --both copy directly: without a selection they take the latest output, whatever the copy mode. + const selector = content === 'semantic' || content === 'both' ? (parsed.request.selector.kind === 'default' ? {kind: 'latest' as const, count: 1} : parsed.request.selector) : parsed.request.selector; const includeStatus = status ?? this.promptConfiguration.copy.includeStatus; if (selector.kind === 'picker' || (selector.kind === 'default' && this.promptConfiguration.copy.mode === 'picker')) { this.panelOrigin = undefined; @@ -3590,7 +4448,7 @@ export class TerminalApp { const resolved = resolveCopySelection(selector.kind === 'default' ? {kind: 'latest', count: 1} : selector, this.output.recentShellCommands()); if (!resolved.ok) { this.output.addFrontendInteraction(command.trim(), resolved.error, ERROR); return; } if (report) { this.startReport(resolved.records, report); return; } - await this.copyRecords(resolved.records, includeStatus); + await this.copyRecords(resolved.records, includeStatus, undefined, parsed.request.content); } /** @@ -3598,9 +4456,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'); @@ -3614,7 +4474,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; } @@ -4070,6 +4931,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 = ''; @@ -4210,6 +5077,16 @@ 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'); + 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; + 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); @@ -4324,7 +5201,10 @@ export class TerminalApp { if (this.renderRail?.columns === columns) return this.renderRail.prepared; const configuration = this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh' ? {...this.promptConfiguration, contextRail: {...this.promptConfiguration.contextRail, mode: 'off' as const}} : this.promptConfiguration; - return prepareRail(this.promptContext(), columns, configuration); + const report = newFitReport(); + const prepared = prepareRail(this.promptContext(), columns, configuration, 0, report); + this.lastFit.rail = report; + return prepared; } private contextRailRows(columns: number): string[] { @@ -4905,7 +5785,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.arcade || this.browserPanel || 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); } @@ -4921,6 +5801,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)) { @@ -4947,6 +5828,18 @@ 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) { const arcadeFrame = this.arcade.session.render(); this.panelBlocks = arcadeFrame.overlays; return arcadeFrame.rows; } + if (this.browserPanel) { + const panel = this.browserPanel; + const view = renderBrowserPanel(panel, columns, this.dimensions().rows - 4); + // The previous picture is deleted once the new rows are written. + setImmediate(() => panel.session.settle()); + return view.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); 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)); @@ -5027,7 +5920,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); @@ -5291,7 +6184,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(); } @@ -5875,6 +6768,12 @@ export class TerminalApp { private reportReview?: ReportReviewState; /** Compare output…: choosing the other block, then the difference. */ private comparePanel?: ComparePanelState; + /** The native read-only viewer (/view, View output); owns keys while open. */ + private viewer?: ViewerState; + /** A file /view is still reading; a newer request or Esc cancels it. */ + private viewerLoad?: AbortController; + /** NMSh's small text editor (/edit, e in /view); owns keys while open. */ + private textEditor?: EditorState; private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number}; private clipboardNoteTimer?: ReturnType; /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ @@ -7237,6 +8136,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; @@ -7348,8 +8248,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) @@ -7369,6 +8271,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') { @@ -7429,7 +8332,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']} @@ -7482,6 +8387,7 @@ export class TerminalApp { if (next.welcome === 'none') this.welcomeGeneration++; this.output.setTranscriptAppearance(next.transcript); this.output.setOutputFolding(next.outputFolding); + this.output.setSemanticOutput(next.semanticOutput); this.output.presenter.setTreatment(next.presentation); this.output.presenter.setLayout(next.transcriptPresentation); this.transcriptPanelState = undefined; @@ -7925,7 +8831,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), ...this.pasteStepRows(columns), ...this.sequenceRows(columns)]; const background = this.backgroundRows(columns); return shelf ? [...rows, ...background, shelf] : [...rows, ...background]; } @@ -7946,7 +8852,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 { + private async openBatchReview(text: string, current: () => boolean = () => this.pastePreview?.text === text): Promise { const shell = this.batchShell(); if (!shell) return; const capacity = Math.max(0, QUEUE_LIMIT - this.queueState.entries.length); @@ -7958,25 +8864,157 @@ 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 (!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; } 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; + } + + /** 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; + 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. */ 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); @@ -8292,11 +9330,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(); @@ -8318,7 +9363,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. */ @@ -8375,6 +9420,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 { + 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; @@ -8507,16 +9571,48 @@ export class TerminalApp { private doctorPanel?: DoctorPanelState; private watchPanel?: WatchPanelState; /** Watches NMSh schedules itself; they live (and stop) with this window. */ - private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh'); - private readonly watchSubscription = this.watches.onChange((_watch, transition) => { + private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh', Date.now, () => ({...process.env, PATH: this.shellPath()})); + /** Watches started with /track: their live transcript block keeps the height it reached (see trackBlockRows). */ + private readonly trackBlocks = new Map(); + private readonly watchSubscription = this.watches.onChange((watch, transition) => { if (this.stopped) return; // A watch moving between failing and passing is one meaningful event. if (transition) this.transitions.echo(transition === 'pass' ? 'taskDone' : 'taskFailed', Date.now()); + // A tracker the person stopped leaves its last result in the transcript once (a closing window leaves nothing). + const block = this.trackBlocks.get(watch.id); + if (block && watch.status === 'stopped' && !block.finalized) { + block.finalized = true; + this.output.addFrontendBlock(`/track ${watch.command}`, finalResultRows(watch)); + // The anchored transcript row can render its stopped state directly from the task. + // The mutable per-tracker height bookkeeping is no longer needed after finalization. + this.trackBlocks.delete(watch.id); + } this.render(); }); + /** A /track block's live rows (presentation only). */ + private trackRows(id: string, width: number): string[] { + const block = this.trackBlocks.get(id); + const {rows, reserved} = trackBlockRows(this.watches.get(id), width, Date.now(), block?.reserved ?? 0, this.watches.sampling(id)); + if (block) block.reserved = reserved; + return rows; + } + + /** Start a classified (or confirmed) watch; /track also writes its block, whose live rows follow it in place. */ + private startWatch(command: string, watched: string, cwd: string, intervalMs: number | undefined, safety: Exclude, track: boolean): boolean { + let watch; + try { watch = this.watches.start(watched, cwd, intervalMs, safety); } + catch (error) { this.output.addFrontendInteraction(command, error instanceof Error ? error.message : 'Could not start tracking.', ERROR); return false; } + if (track) { + this.trackBlocks.set(watch.id, {reserved: 0, finalized: false}); + const place = cwd === this.shellCwd ? 'here' : `in ${displayPath(cwd)}`; + this.output.addTrackBlock(watch.id, command, [`${GLYPHS.info} Tracking ${watch.command} ${place} with sh -c and your shell's PATH (not its aliases or functions). /track stop ends it.`]); + } + return true; + } + /** /watch …: start (classified first), list, pause, resume, run now, stop. */ - private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start', argument: string): void { + private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy', argument: string, track = false): void { const active = this.watches.active(); const pick = () => { const index = /^\d+$/u.test(argument) ? Number(argument) - 1 : active.length - 1; @@ -8524,24 +9620,52 @@ 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); + } + + /** 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. */ @@ -8579,7 +9703,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(); } @@ -8955,6 +10079,32 @@ export class TerminalApp { /** The one place the clipboard is read (only /ps calls it). */ private readClipboardText: () => Promise = () => 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}; + /** 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 = ''; + /** 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; + /** A Markdown or HTML file open for reading, editing and preview at once (see viewer/DocumentWorkspace). */ + private docSession?: DocumentSession; + /** The real browser (an installed Chromium), reached only through this host and only when a browser surface is opened. */ + private browserHost?: BrowserHost; + private browserPanel?: BrowserPanelState; + /** The document session a /browser opened from a preview link returns to. */ + private browserReturn?: DocumentSession; + private previewStarting?: DocumentSession; + /** 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'})); @@ -9351,14 +10501,40 @@ 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 { + const outcome = await this.resolveAskOwn(text, state); + 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} : {})}; + 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)}; + } + + private async resolveAskOwn(text: string, state: AskState): Promise { const context = await this.askContext(text); state.repoRoot = context.repoRoot; const commands = this.askCommands(); 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(); @@ -10230,6 +11406,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 @@ -10245,7 +11440,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, @@ -10314,11 +11509,16 @@ 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(); } private render(): void { + this.syncTypography(); + this.scheduleSequencePoke(); this.reconcileOutputNavigation(); if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } this.hostSemantics.flush(); @@ -10348,10 +11548,13 @@ 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.session.resize(columns, ptyRows); + this.output.setScreenRows(ptyRows); + this.output.setScreenColumns(columns); } const wrapped = this.output.wrapped(columns); @@ -10517,7 +11720,8 @@ 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()); this.renderRail = undefined; @@ -11185,7 +12389,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) : '']; } @@ -11193,13 +12397,23 @@ 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. 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); - 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); @@ -11210,16 +12424,73 @@ 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.stopAhead && this.running?.startId === this.stopAhead.startId && !this.editor.text) { + return {text: `${this.stopAhead.done ? 'Runs when the prompt is back' : `Runs at the prompt once ${this.stopAhead.program} stops`} · Esc takes it back`, waiting: false}; + } if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; const request = this.inputState?.request; const program = request ? inputProgram(request, this.running.command) : commandWords(this.running.command)[0] ?? 'the program'; + if (this.ownershipLook?.uncertain) return {text: heldInputActions(this.running.command.trim().split(/\s+/u)[0] || 'the command'), waiting: true}; + if (this.ownershipLook?.slow) return {text: `Checking whether ${program} is waiting for input · your typing is kept`, waiting: false}; // A waiting program gets the one instruction, with or without a queue; a question NMSh has not yet probed reads // as a line. if (this.answersByDefault()) return {text: inputComposerHint(request ?? {since: 0, confidence: 'likely', mode: 'line'}, program, this.queueAvailable()), waiting: true}; if (!this.queueAvailable()) return undefined; - return {text: `Enter 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, 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'; + // 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(); + } + + /** + * 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; + // 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); + 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: 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; } + 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. */ @@ -11237,6 +12508,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 { @@ -11259,13 +12541,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 { @@ -11273,6 +12555,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; @@ -11294,6 +12577,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; @@ -11350,8 +12634,37 @@ export class TerminalApp { this.render(); } - /** Add the composer's text to the queue. */ - private enqueue(text: string): void { + /** + * 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, 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); @@ -11359,9 +12672,19 @@ export class TerminalApp { return; } this.syncQueuePolicy(); - this.session.queue({op: 'add', text}); - this.sessionSubmissions.push({text, slash: false}); - if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + 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(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.editor.clear(); this.composerHistory.reset(); this.runningDraft = undefined; @@ -11402,6 +12725,11 @@ 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. + // 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(); @@ -11411,10 +12739,14 @@ export class TerminalApp { return; } if (!text.trim()) return; - this.enqueue(text); + if (this.reviewMixedBlock(text)) return; + 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. const affordance = intent === 'enter' && !text.trim() ? this.queueAffordance() : undefined; @@ -11423,7 +12755,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; } @@ -11602,7 +12938,38 @@ 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 + // 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 && this.justStopped() && state.request.since < stoppedAt) { + const {request: _ended, ...current} = state; + const running = this.running; + const reported = state; + const apply = () => { + if (this.staleStopReport === apply) this.staleStopReport = undefined; + if (this.running === running && this.inputState === current) this.onInputState(reported); + }; + this.staleStopReport = apply; + const later = setTimeout(apply, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); + later.unref?.(); + 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'); + } 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 @@ -11610,20 +12977,103 @@ 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; // 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; + 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?.release(); + if (request) this.ownershipLook?.settle('route'); + this.render(); + } + + /** + * 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}; + // 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) 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 { + // 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 ? '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(); } @@ -11640,44 +13090,137 @@ 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 (#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. 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!; const keys = [key]; - let released = false; - const release = () => { - if (released) return; - released = true; - clearTimeout(timer); - 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 + // 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; + // 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; + 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') { + this.revealHeld(keys); + 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. - 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); + 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; } + // 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(); }; - const timer = setTimeout(release, OWNERSHIP_LOOK_MS); - timer.unref?.(); - const hold = {keys, startId: running.startId, answering: this.answersByDefault(), release}; + // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). + const doubt = () => { + if (settled || hold.atPrompt) return; + 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?.(); + // 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, sawAnsweredHidden: false, atPrompt: false, program, 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 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')); + const answered = (result: LookResult | undefined) => { + 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. 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; + 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 { + private async lookWhenStarted(startId: number, deadline: number): Promise { 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. */ @@ -11685,13 +13228,14 @@ 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; } const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; + 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(); @@ -11702,8 +13246,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(); } @@ -11942,6 +13489,12 @@ export class TerminalApp { this.stopped = true; this.endOutputNavigation(false); this.blockPaletteContext = undefined; + this.closeArcade(false); + this.closeBrowser(false); + this.docSession?.preview?.dispose(); + void this.browserHost?.shutdown(); + this.releaseViewerImages(); + this.graphics?.releaseAll(); this.clips.clear(); this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; this.contextEngine.dispose(); 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/arcade/ArcadeSession.ts b/src/arcade/ArcadeSession.ts new file mode 100644 index 00000000..f65ab7ee --- /dev/null +++ b/src/arcade/ArcadeSession.ts @@ -0,0 +1,389 @@ +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.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) { + 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, 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()); + 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}): {rows: string[]; overlays: TextOverlay[]} { + 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); + } + } + 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 { + 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..108d0dd2 --- /dev/null +++ b/src/arcade/Canvas.ts @@ -0,0 +1,430 @@ +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; + /** 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 { + 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, style}); + 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, '')); + } + + /** 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 = ''; + 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/course.ts b/src/arcade/games/dash/course.ts new file mode 100644 index 00000000..bf88d3f4 --- /dev/null +++ b/src/arcade/games/dash/course.ts @@ -0,0 +1,225 @@ +import type {Rng} from '../../rng.js'; +import { + AIR_STEPS, GAP_MAX, GAP_MIN, HB_X, MIN_GAP_PX, PLATFORM_LEVELS, PW, REACT_STEPS, SPEED_MAX, SPIKE_W, + difficultyAt, maxGapAt, stepPx, STEPS_PER_SECOND, WINDOW_STEPS, type Piece, type PieceKind, +} from './physics.js'; +import {firstWindow, solve, solvePhases, type PhaseTables, type Window} from './solver.js'; + +/** + * The endless course: patterns ("units") placed along the world from a seeded stream. Each unit is a template (a row of + * spikes, a gap, a raised platform, a staircase of platforms, an island in a gap, a platform with spikes on it) and is + * only placed where `solve()` (see solver.ts) proves, by running the real physics, that a player who has just touched + * down and spent the reaction time can still get through with a takeoff window of at least `WINDOW_STEPS` everywhere. + * + * Positions are world pixels; the cat's hitbox left edge is at `distance + HB_X`. + * + * Every so often the course rests: a flat stretch long enough for any jump in progress to land and the reaction time to + * pass. The proof restarts from there, so it only ever examines the units since the last rest (a short stretch), and + * the induction is exact: each placement keeps the stretch from the rest point onward provable, and that is all the next + * one needs. + */ +export type UnitKind = 'spikes' | 'gap' | 'block' | 'pole' | 'stairs' | 'island' | 'perch'; + +export interface Part { kind: PieceKind; dx: number; w: number; level: number } +export interface UnitSpec { kind: UnitKind; parts: Part[]; length: number } + +export interface Unit { + id: number; kind: UnitKind; + /** World x of the unit's first and last pixel. */ + x: number; end: number; + /** The takeoff window for its first jump, proven in isolation (cat distances), and the reaction deadline after it. */ + window: Window | undefined; + /** Whether a rest (a long flat stretch) comes right before it. */ + afterRest: boolean; +} + +/** Cat distance (in px) the first unit is placed at, ahead of the start. */ +export const START_PX = 150; +/** The most steps of units between rests, and the distance a unit may be pushed right looking for a fit. */ +export const MAX_SECTION = 5; +const SCAN_PX = 260; + +const REACH_MAX = AIR_STEPS * SPEED_MAX / STEPS_PER_SECOND; +/** From the last piece to the point from which the proof restarts: any jump in progress has landed and the reaction time has passed. */ +export const REST_FROM_PX = Math.ceil(REACH_MAX + REACT_STEPS * SPEED_MAX / STEPS_PER_SECOND + 8); +/** The least flat stretch between the last piece before a rest and the first piece after it. */ +export const REST_PX = REST_FROM_PX + HB_X + PW + 40; + +export const spikesSpec = (count: number, spacing: number): UnitSpec => ({ + kind: 'spikes', length: count * SPIKE_W + (count - 1) * spacing, + parts: Array.from({length: count}, (_, index) => ({kind: 'spike' as const, dx: index * (SPIKE_W + spacing), w: SPIKE_W, level: 0})), +}); +export const gapSpec = (width: number): UnitSpec => ({kind: 'gap', length: width, parts: [{kind: 'gap', dx: 0, w: width, level: 0}]}); +export const blockSpec = (width: number, level: number, kind: 'block' | 'pole' = 'block'): UnitSpec => ({kind, length: width, parts: [{kind: 'block', dx: 0, w: width, level}]}); +export const stairsSpec = (first: {w: number; level: number}, second: {w: number; level: number}): UnitSpec => ({ + kind: 'stairs', length: first.w + second.w, + parts: [{kind: 'block', dx: 0, w: first.w, level: first.level}, {kind: 'block', dx: first.w, w: second.w, level: second.level}], +}); +/** A platform standing alone in a gap: gap, island and gap are one stretch of missing ground with a block in it. */ +export const islandSpec = (before: number, width: number, level: number, after: number): UnitSpec => ({ + kind: 'island', length: before + width + after, + parts: [{kind: 'gap', dx: 0, w: before + width + after, level: 0}, {kind: 'block', dx: before, w: width, level}], +}); +/** A platform with spikes at its far end: land on the near part, hop the spikes. */ +export const perchSpec = (width: number, level: number, spikes: number): UnitSpec => ({ + kind: 'perch', length: width, + parts: [{kind: 'block', dx: 0, w: width, level}, ...Array.from({length: spikes}, (_, index) => ({kind: 'spike' as const, dx: width - (spikes - index) * SPIKE_W, w: SPIKE_W, level}))], +}); + +/** + * One template picked from four rolls in [0, 1); `diff` (0..1) widens the choice and the parameters, `per` (px per step at + * the cat's speed) sizes things that must leave room for several takeoff windows: a platform you land on and then leave + * again must be long enough for the landing spread, the reaction time and the next window. + */ +export function pickSpec(kindRoll: number, a: number, b: number, c: number, diff: number, per: number): UnitSpec { + const table: Array<[UnitKind, number]> = [ + ['spikes', 3], ['gap', 2.2], ['block', 2], ['pole', 0.8], + ['stairs', diff > 0.08 ? 1.5 : 0], ['island', diff > 0.14 ? 1.5 : 0], ['perch', diff > 0.22 ? 1.4 : 0], + ]; + const total = table.reduce((sum, [, weight]) => sum + weight, 0); + let roll = kindRoll * total; + let kind: UnitKind = 'spikes'; + for (const [name, weight] of table) { if (roll < weight) { kind = name; break; } roll -= weight; } + const level = (roll01: number): number => PLATFORM_LEVELS[Math.min(PLATFORM_LEVELS.length - 1, Math.floor(roll01 * PLATFORM_LEVELS.length))]!; + const room = Math.ceil(per * (3 * WINDOW_STEPS + 4)) + 4; + const gapTop = Math.max(GAP_MIN, Math.min(GAP_MAX, maxGapAt(per * STEPS_PER_SECOND) - 3)); + switch (kind) { + case 'spikes': return spikesSpec(Math.min(3, 1 + Math.floor(a * (1.5 + 1.8 * diff))), b < 0.55 ? 0 : 4 * (1 + Math.floor(c * 2))); + case 'gap': return gapSpec(GAP_MIN + Math.floor(a * (gapTop - GAP_MIN) * (0.5 + 0.5 * diff) + 0.5)); + case 'block': return blockSpec(10 + Math.floor(a * 24), level(b)); + case 'pole': return blockSpec(4 + 2 * Math.floor(a * 2), PLATFORM_LEVELS[1 + Math.floor(b * 2)]!, 'pole'); + case 'stairs': { + const lower = level(a * 0.66); + const upper = PLATFORM_LEVELS.filter(value => value > lower)[Math.floor(b * 0.99)] ?? PLATFORM_LEVELS[PLATFORM_LEVELS.length - 1]!; + return stairsSpec({w: room + Math.floor(c * 8), level: lower}, {w: 16 + Math.floor(a * 14), level: upper}); + } + case 'island': return islandSpec(GAP_MIN + Math.floor(a * 6), room + Math.floor(b * 8), [2, 4, 6][Math.floor(c * 3)]!, GAP_MIN + Math.floor(c * 6)); + default: { + const spikes = 1 + (c < 0.35 + 0.3 * diff ? 1 : 0); + return perchSpec(room + spikes * SPIKE_W + Math.floor(a * 6), level(b * 0.66), spikes); + } + } +} + +/** Pieces of a unit placed with its first pixel at world x (ids filled in by the caller). */ +export function buildPieces(spec: UnitSpec, x: number, unit: number, nextId: () => number): Piece[] { + return spec.parts.map(part => ({id: nextId(), kind: part.kind, x: x + part.dx, w: part.w, level: part.level, unit})) + .sort((left, right) => left.x - right.x || left.id - right.id); +} + +/** What the world needs of a course: pieces and units in order, growth ahead of the cat, forgetting behind it. */ +export interface CourseLike { + readonly pieces: Piece[]; + readonly units: readonly Unit[]; + ensure(untilX: number): void; + prune(x: number): void; +} + +export interface CourseOptions { + /** The cat's starting distance (tests start far along to examine fast speeds). */ + from?: number; +} + +export class Course implements CourseLike { + /** Pieces ahead of and around the cat, sorted by x. */ + readonly pieces: Piece[] = []; + readonly units: Unit[] = []; + private section: Piece[] = []; + private sectionStart: number; + private sinceRest = 0; + private restAfter = 4; + private lastEnd: number; + private nextId = 1; + private nextUnit = 1; + private lastFree = 0; + /** The proof table of the current section as committed, reused for every candidate placement. */ + private base: PhaseTables | undefined; + /** World x up to which the course has been generated. */ + frontier: number; + /** Placements the last `emit` had to try, for tests and tuning. */ + trials = 0; + + constructor(private readonly rng: Rng, options: CourseOptions = {}) { + const from = options.from ?? 0; + this.sectionStart = from; + this.lastEnd = from - HB_X; + this.frontier = from; + this.lastFree = from; + } + + ensure(untilX: number): void { while (this.frontier < untilX) this.emit(); } + /** Forget pieces wholly behind `x`. */ + prune(x: number): void { + let drop = 0; + while (drop < this.pieces.length && this.pieces[drop]!.x + this.pieces[drop]!.w < x) drop += 1; + if (drop > 0) this.pieces.splice(0, drop); + while (this.units.length > 8 && this.units[0]!.end < x) this.units.shift(); + } + + private emit(): void { + const rng = this.rng; + // A fixed number of draws per unit, whatever the proof then rejects, so the stream never depends on feasibility. + const spreadRoll = rng.next(); + const restRoll = rng.next(); + const rolls = Array.from({length: 3}, () => [rng.next(), rng.next(), rng.next(), rng.next()] as const); + const diff = difficultyAt(this.lastEnd); + const first = this.units.length === 0; + let rest = !first && this.sinceRest >= this.restAfter; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (rest) { + this.sectionStart = this.lastEnd + REST_FROM_PX - HB_X; + this.section = []; + this.sinceRest = 0; + this.restAfter = 3 + Math.floor(restRoll * (MAX_SECTION - 2)); + this.lastFree = this.sectionStart; + this.base = undefined; + } + const specs = attempt === 0 ? rolls.map(([k, a, b, c]) => pickSpec(k, a, b, c, diff, stepPx(this.lastEnd + REST_PX))) : [spikesSpec(1, 0)]; + for (const spec of specs) { + const base = first ? this.sectionStart + START_PX : rest ? this.lastEnd + REST_PX : this.lastEnd + MIN_GAP_PX; + const placed = this.place(spec, base, spreadRoll, diff); + if (placed !== undefined) { this.commit(spec, placed, rest); return; } + } + rest = true; + } + throw new Error('Vespyr Dash could not place a unit'); + } + + /** The least x at or after `base` (nudged right by the unit's share of spare room) where the unit is provable. */ + private place(spec: UnitSpec, base: number, spreadRoll: number, diff: number): number | undefined { + const start = Math.ceil(Math.max(base, this.lastFree + HB_X + PW - 24)); + let found: number | undefined; + for (let x = start; x <= start + SCAN_PX && found === undefined; x += 5) if (this.fits(spec, x)) found = x; + if (found === undefined) return undefined; + for (let x = Math.max(start, found - 4); x < found; x += 1) if (this.fits(spec, x)) { found = x; break; } + const roomy = Math.round(found + spreadRoll * (140 * (1 - diff) + 8)); + return this.fits(spec, roomy) ? roomy : found; + } + + private proof(pieces: Piece[], x: number, spec: UnitSpec): PhaseTables | undefined { + return solvePhases(pieces, this.sectionStart, x + spec.length + REACH_MAX + 30, this.base ? {tables: this.base, x} : undefined); + } + + private fits(spec: UnitSpec, x: number): boolean { + this.trials += 1; + const pieces = [...this.section, ...buildPieces(spec, x, 0, () => 0)]; + return this.proof(pieces, x, spec) !== undefined; + } + + private commit(spec: UnitSpec, x: number, afterRest: boolean): void { + const unit = this.nextUnit; + this.nextUnit += 1; + const pieces = buildPieces(spec, x, unit, () => this.nextId++); + // The window the unit offers a player standing ready on open ground before it: found in isolation. + const alone = solve(buildPieces(spec, x, unit, () => 0), x - 100, x + spec.length + REACH_MAX + 30); + const window = firstWindow(alone); + this.units.push({id: unit, kind: spec.kind, x, end: x + spec.length, window, afterRest}); + this.pieces.push(...pieces); + this.section.push(...pieces); + this.base = this.proof(this.section, x, spec); + this.sinceRest += 1; + this.lastEnd = x + spec.length; + this.frontier = this.lastEnd; + this.lastFree = window?.free ?? this.lastEnd; + } +} diff --git a/src/arcade/games/dash/game.ts b/src/arcade/games/dash/game.ts new file mode 100644 index 00000000..aec45498 --- /dev/null +++ b/src/arcade/games/dash/game.ts @@ -0,0 +1,196 @@ +import type {Key} from '../../../terminal/keys.js'; +import type {Canvas} from '../../Canvas.js'; +import {Flash, ParticleSystem, Popups, Shake, Trail, type EffectsLevel} from '../../effects.js'; +import type {ArcadePalette} from '../../palette.js'; +import type {ControlHint, GameContext, GameInstance, GameKeyResult, GameResult, GameStatus} from '../../types.js'; +import {stepPx} from './physics.js'; +import {DashScene, drawTooSmall, geometry, MIN_COLUMNS, MIN_ROWS, type Geometry} from './scene.js'; +import type {PlayerPose} from './sprites.js'; +import {DashWorld} from './world.js'; + +/** What a person's saved Vespyr Dash record holds, read defensively (the file can be old, empty or hand-edited). */ +export interface DashRecord { best: number; bestDistance: 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>): DashRecord { + return {best: clean(raw.best), bestDistance: clean(raw.bestDistance), attempts: clean(raw.attempts)}; +} + +const POP_MS = 700; +/** After a crash the world holds still this many steps (the cat shown hit) before the game over card: shorter with reduced motion. */ +const DYING_STEPS = 24; +const DYING_STEPS_REDUCED = 8; +const LAND_STEPS = 6; +const HOT_MS = 360; +const TRAIL_LENGTH: Readonly> = {off: 0, subtle: 8, normal: 14, rich: 20}; + +/** Test seams: a prepared world or particle pool the test can inspect. The arcade always uses the defaults. */ +export interface DashParts { world?: DashWorld; particles?: ParticleSystem; trail?: Trail } + +export function createDashGame(context: GameContext, parts: DashParts = {}): GameInstance { + const courseRng = context.rng.fork('course'); + const fxRng = context.rng.fork('fx'); + const world = parts.world ?? new DashWorld(courseRng); + const scene = new DashScene(); + const effects = context.motion.effects; + const particles = parts.particles ?? new ParticleSystem(fxRng, 120, effects); + const trail = parts.trail ?? new Trail(TRAIL_LENGTH[effects], effects); + const popups = new Popups(4, 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 phase = 0; + let landSteps = 0; + let dying = 0; + let disposed = false; + const hot = {support: -2, x: 0, amount: 0}; + + const tooSmall = (): boolean => size.columns < MIN_COLUMNS || size.rows < MIN_ROWS; + + function pose(): PlayerPose { + if (status === 'ready') return visualMs % 3200 < 150 ? 'blink' : 'ride'; + if (!world.alive) return 'hit'; + if (world.airborne) return 'air'; + return landSteps > 0 ? 'land' : 'ride'; + } + + function finish(): void { + status = 'over'; + const score = world.score; + const previous = readRecord(context.record.get()); + const next: DashRecord = {best: Math.max(previous.best, score), bestDistance: Math.max(previous.bestDistance, world.meters), attempts: previous.attempts + 1}; + context.record.update(record => { record.best = next.best; record.bestDistance = next.bestDistance; record.attempts = next.attempts; }); + saved = next; + result = { + score, scoreText: String(score).padStart(5, '0'), record: score > 0 && score > startBest, + details: [`Distance ${world.meters} m`, `Best ${next.best}`, `Attempts ${next.attempts}`], + }; + } + + /** Screen pixel of the cat's centre. */ + const centerY = (): number => geo.groundY - world.y - 3; + + function trailAndDust(): void { + if (!decor) return; + trail.shift(-stepPx(world.distance)); + if (world.alive) trail.push(geo.catX + 1, centerY()); + if (palette && world.steps % 5 === 0 && !world.airborne && world.alive) { + particles.burst({x: geo.catX + 1, y: geo.groundY - world.y - 1, count: 1, speed: world.speed * 0.4, angle: Math.PI, spread: 0.4, life: 300, gravity: -30, 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; + trailAndDust(); + if (outcome.landedAfter > 0) { + landSteps = LAND_STEPS; + if (!context.motion.reduced) { hot.support = outcome.support; hot.x = world.distance + 3; hot.amount = 1; } + if (palette && decor && outcome.landedAfter >= 3) { + particles.burst({x: geo.catX + 3, y: geo.groundY - world.y - 1, count: 7, speed: 38, angle: -Math.PI / 2, spread: 1.4, life: 380, gravity: 70, drag: 2, colors: [palette.accent, palette.haze, palette.muted]}); + } + } + if (outcome.tookOff && palette && decor) { + particles.burst({x: geo.catX + 3, y: geo.groundY - world.y, count: 5, speed: 30, angle: Math.PI / 2, spread: 0.9, life: 300, gravity: -20, drag: 2, colors: [palette.accent, palette.haze]}); + } + 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 + 3, Math.max(1, Math.floor(geo.groundY / 2) - 9), palette.accent); + } + if (world.phase > phase) { + phase = world.phase; + if (!context.motion.reduced && palette) popups.add(`Phase ${phase + 1}`, Math.floor(geo.width / 2), 3, palette.ink); + } + if (outcome.fate !== 'alive') { + dying = context.motion.reduced ? DYING_STEPS_REDUCED : DYING_STEPS; + if (!context.motion.reduced && palette) { + flash.trigger(true); + shake.trigger(context.motion.effects !== 'off'); + } + if (palette) particles.burst({x: geo.catX + 3, y: geo.groundY - world.y - 3, count: 24, speed: 62, spread: Math.PI, life: 600, gravity: 80, 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); + hot.amount = Math.max(0, hot.amount - dtMs / HOT_MS); + }, + + key(key: Key): GameKeyResult { + const jump = key.kind === 'up' || (key.kind === 'text' && key.value === ' '); + if (!jump) return 'ignored'; + if (status === 'ready') { + status = 'playing'; + world.jump(); + return 'handled'; + } + if (status !== 'playing') return 'ignored'; + if (dying === 0) world.jump(); + 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; } + scene.draw(canvas, { + world, alpha: status === 'playing' && dying === 0 ? alpha : 1, palette: colors, decor, reduced: context.motion.reduced, visualMs, + pose: pose(), particles, trail, popups, shake: shake.offset, flash: flash.amount, hot, + 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(); + 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: 'start'}, {keys: 'Esc', action: 'menu'}] + : [{keys: 'Space/Up', action: 'jump'}, {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; + 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.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/dash/index.ts b/src/arcade/games/dash/index.ts new file mode 100644 index 00000000..325eeb23 --- /dev/null +++ b/src/arcade/games/dash/index.ts @@ -0,0 +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 {createDashGame, readRecord} from './game.js'; +import {geometry} from './scene.js'; +import {playerSprite, spikeSprite} from './sprites.js'; + +/** Vespyr Dash: a rhythm platformer. Vespyr rides a glowing board at a steady pace; Space or Up jumps. See docs/design/arcade-dash.md. */ +export const dashGame: GameDefinition = { + id: 'dash', aliases: ['vespyr-dash'], title: 'Vespyr Dash', tagline: 'Rhythm platformer', + description: 'Time your jumps over spikes, gaps and platforms. The pace never stops and never gets unfair.', kind: 'action', + controls: [{keys: 'Space/Up', action: 'jump'}], + summary: record => { const saved = readRecord(record); return saved.attempts > 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 + 12; + const x = geo.width - ((frame * 3) % span); + const hop = Math.max(0, 8 - Math.abs(x - 6 - geo.catX) * 0.6); + buffer.sprite(spikeSprite(palette), x, geo.groundY - 4); + buffer.sprite(playerSprite(hop > 0.5 ? 'air' : 'ride', palette), geo.catX, geo.groundY - 6 - Math.round(hop)); + canvas.blit(buffer); + }, + create: context => createDashGame(context), +}; diff --git a/src/arcade/games/dash/physics.ts b/src/arcade/games/dash/physics.ts new file mode 100644 index 00000000..9a3ecffd --- /dev/null +++ b/src/arcade/games/dash/physics.ts @@ -0,0 +1,223 @@ +/** + * Vespyr Dash 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 and + * the cat scrolls right at `speedAt(distance)` whatever the player does, so where the cat is horizontally at step n is a + * function of the course alone. Everything the generator promises (a gap is crossable, a platform is reachable, a spike + * row is clearable) is computed by running the same `stepBody` the game runs, 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 = 360; +export const JUMP_V = 88; + +/** The player's sprite is 7 x 6; the hitbox is the 5 x 5 body inside it (ear tips and the board's glow do not hurt). */ +export const SPRITE_W = 7; +export const SPRITE_H = 6; +export const HB_X = 1; +export const PW = 5; +export const PH = 5; + +/** A spike is drawn 6 x 4 and hurts in its 4 x 3 core: the tip pixel and the outer pixels are forgiving. */ +export const SPIKE_W = 6; +export const SPIKE_H = 4; +export const SPIKE_HIT_X = 1; +export const SPIKE_HIT_W = 4; +export const SPIKE_HIT_H = 3; + +/** Landing on a ledge that is at most this far above the feet counts as landing (a corner is forgiven, a wall is not). */ +export const SNAP_PX = 1; +/** Falling this far below the ground is the end. */ +export const FALL_DEPTH = 14; +/** A press this close before touchdown is remembered and jumps on landing. */ +export const JUMP_BUFFER_STEPS = 6; + +/** + * Speed rises in four levels (px/s), one per `LEVEL_PX` of distance, and stays at the last. The steps are multiples of a + * quarter pixel per tick, so the cat's position is always a multiple of 0.25 px: with pieces on whole pixels there are + * a handful of alignments ("phases") between the cat's steps and the course, and the proof below can check every one + * rather than sample some. + */ +export const SPEEDS: readonly number[] = [60, 75, 90, 105]; +export const LEVEL_PX = 1800; +export const SPEED_START = SPEEDS[0]!; +export const SPEED_MAX = SPEEDS[SPEEDS.length - 1]!; +export const RAMP_PX = LEVEL_PX * (SPEEDS.length - 1); +export const speedLevel = (distance: number): number => Math.min(SPEEDS.length - 1, Math.max(0, Math.floor(distance / LEVEL_PX))); +export const speedAt = (distance: number): number => SPEEDS[speedLevel(distance)]!; +/** Pixels the cat advances in one step when it is at `distance`: exact in binary floating point. */ +export const stepPx = (distance: number): number => speedAt(distance) / STEPS_PER_SECOND; +/** 0..1, nondecreasing and capped: drives the mix of patterns and how much spare room they get. */ +export const difficultyAt = (distance: number): number => Math.min(1, Math.max(0, distance) / RAMP_PX); +/** + * The alignments of the cat's steps to whole-pixel pieces when it stands at `distance` (a whole pixel): the offsets of its + * next step grid, every quarter pixel up to one step. A stretch of course is proven for all of them (4 to 7). + */ +export function phasesAt(distance: number): number[] { + return Array.from({length: Math.round(stepPx(distance) * 4)}, (_, index) => index * 0.25); +} + +export type PieceKind = 'gap' | 'block' | 'spike'; + +/** + * One thing on the course, in world pixels. `level` is a block's top above the ground, or the height a spike stands at + * (0 on the ground, a block's top when it sits on one). A gap is a stretch where the ground is missing; the ground itself + * is implicit, everywhere outside the gaps, so it can never disagree with them. A block may stand inside a gap (an island). + * `unit` is the pattern it belongs to. + */ +export interface Piece { id: number; kind: PieceKind; x: number; w: number; level: number; unit: number } + +export interface Body { + /** Height of the feet above the ground. */ + y: number; + vy: number; + grounded: boolean; + /** Set by `stepBody` on the step the feet touched a surface from the air. */ + landed: boolean; + /** The block landed on this step or last stood on (-1 for the ground). */ + support: number; +} + +export type Fate = 'alive' | 'spike' | 'wall' | 'fall'; + +export const newBody = (): Body => ({y: 0, vy: 0, grounded: true, landed: false, support: -1}); + +/** True when any part of [left, right) is ground, that is not inside a gap. Pieces are sorted by `x`. */ +function overGround(left: number, right: number, pieces: readonly Piece[]): boolean { + let from = left; + for (const piece of pieces) { + if (piece.x >= right) break; + if (piece.kind !== 'gap') continue; + const end = piece.x + piece.w; + if (end <= from) continue; + if (piece.x > from) return true; + from = end; + if (from >= right) return false; + } + return from < right; +} + +const SCRATCH = {landTop: -Infinity, landId: -1, held: -Infinity, heldId: -1}; + +/** One surface under the hitbox: a landing, something to stand on, or (false) a wall. */ +function touch(scratch: typeof SCRATCH, top: number, id: number, y: number, vy: number, before: number): boolean { + if (top > y) { + if (vy > 0 || (top - y > SNAP_PX && before < top)) return false; + if (top > scratch.landTop) { scratch.landTop = top; scratch.landId = id; } + } else if (top > scratch.held) { scratch.held = top; scratch.heldId = id; } + return true; +} + +/** + * Advance the body one fixed step with the hitbox's left edge at `left` (the cat has already scrolled). Contact rules: + * a surface under the feet holds them; a surface whose top is above the feet is a wall (death) unless it is within + * `SNAP_PX` or the feet were above it a step ago (then it is a landing); spikes hurt only in their core; below the ground + * line the cat falls until `FALL_DEPTH`. Touching edges is clear; only a real overlap counts. `pieces` is sorted by `x`. + */ +export function stepBody(body: Body, left: number, pieces: readonly Piece[]): Fate { + const right = left + PW; + const before = body.y; + let y = body.y; + let vy = body.vy; + body.landed = false; + if (!body.grounded) { vy -= GRAVITY * DT; y += vy * DT; } + const scratch = SCRATCH; + scratch.landTop = -Infinity; scratch.landId = -1; scratch.held = -Infinity; scratch.heldId = -1; + if (overGround(left, right, pieces) && !touch(scratch, 0, -1, y, vy, before)) return 'wall'; + for (const piece of pieces) { + if (piece.x >= right) break; + if (piece.x + piece.w <= left || piece.kind !== 'block') continue; + if (!touch(scratch, piece.level, piece.id, y, vy, before)) return 'wall'; + } + const landTop = scratch.landTop; + const landId = scratch.landId; + const held = scratch.held; + const heldId = scratch.heldId; + if (landTop > -Infinity) { + body.landed = !body.grounded; + body.grounded = true; body.y = landTop; body.vy = 0; body.support = landId; + } else if (body.grounded) { + if (held === y) body.support = heldId; + else { body.grounded = false; body.vy = 0; } + } else { + body.y = y; body.vy = vy; + } + if (body.y < -FALL_DEPTH) return 'fall'; + for (const piece of pieces) { + if (piece.x >= right) break; + if (piece.kind !== 'spike') continue; + const hitLeft = piece.x + SPIKE_HIT_X; + if (left < hitLeft + SPIKE_HIT_W && right > hitLeft && body.y < piece.level + SPIKE_HIT_H && body.y + PH > piece.level) return 'spike'; + } + return 'alive'; +} + +/** The highest surface under a hitbox whose left edge is `left` (a standing body's height), or -Infinity over a gap. */ +export function surfaceAt(left: number, pieces: readonly Piece[]): number { + const right = left + PW; + let top = overGround(left, right, pieces) ? 0 : -Infinity; + for (const piece of pieces) { + if (piece.x >= right) break; + if (piece.kind === 'block' && piece.x + piece.w > left && piece.level > top) top = piece.level; + } + return top; +} + +/** Start a jump from a standing body. */ +export function takeoff(body: Body): void { body.grounded = false; body.vy = JUMP_V; } + +/** Height after j steps of a jump over open ground; index 0 is takeoff, the last entry is the landing (0). */ +export const JUMP_PROFILE: readonly number[] = (() => { + const body = newBody(); + takeoff(body); + const heights = [0]; + for (let guard = 0; guard < 600; guard += 1) { + stepBody(body, 0, []); + heights.push(body.y); + if (body.grounded) break; + } + return heights; +})(); +export const AIR_STEPS = JUMP_PROFILE.length - 1; +export const APEX = Math.max(...JUMP_PROFILE); + +export interface JumpStats { + /** Pixels the cat travels per step at this speed. */ + perStep: number; + airSteps: number; + airSeconds: number; + apex: number; + /** Horizontal distance of one jump over open ground (takeoff to touchdown), in px. */ + reach: number; +} + +/** Exact jump numbers at `speed` (px/s): airtime, apex and horizontal distance, from the real profile. */ +export function jumpStats(speed: number): JumpStats { + const perStep = speed / STEPS_PER_SECOND; + return {perStep, airSteps: AIR_STEPS, airSeconds: AIR_STEPS * DT, apex: APEX, reach: AIR_STEPS * perStep}; +} + +/** A takeoff window must be at least this many steps wide (0.2 s) for a pattern to count as fair. */ +export const WINDOW_STEPS = 12; +/** Steps the player needs after touching down before the next action: a deliberately generous human reaction. */ +export const REACT_STEPS = 12; +/** No two things sit closer than this many px (a visual floor; the schedule demands far more). */ +export const MIN_GAP_PX = 6; + +/** + * The widest gap with a takeoff window of `WINDOW_STEPS + 1` steps at `speed`. The cat can leave the near edge as long + * as any of it is over ground and lands safely once any of it is over the far ground, so the window is + * `reach + PW - gap` px wide: `AIR_STEPS + (PW - gap) / perStep` steps. + */ +export function maxGapAt(speed: number): number { + const {perStep} = jumpStats(speed); + return Math.floor(PW + (AIR_STEPS - WINDOW_STEPS - 1) * perStep); +} +/** Gaps are never wider than this at any speed (the limit at the speed cap, less a margin of 4 px). */ +export const GAP_MAX = maxGapAt(SPEED_MAX) - 4; +export const GAP_MIN = 12; +/** Platform tops the generator uses; the apex must clear them by at least a pixel and a half. */ +export const PLATFORM_LEVELS: readonly number[] = [4, 6, 8]; diff --git a/src/arcade/games/dash/scene.ts b/src/arcade/games/dash/scene.ts new file mode 100644 index 00000000..835c1d8d --- /dev/null +++ b/src/arcade/games/dash/scene.ts @@ -0,0 +1,256 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import type {Rgb} from '../../../chroma/escape.js'; +import {typeset} from '../../../ui/typography.js'; +import {Canvas, PixelBuffer} from '../../Canvas.js'; +import {clamp, lerp, smoothstep} from '../../easing.js'; +import type {ParticleSystem, Popups, Trail} from '../../effects.js'; +import type {ArcadePalette} from '../../palette.js'; +import {SPEED_MAX, SPEED_START, SPIKE_H, SPIKE_W, type Piece} from './physics.js'; +import {plainPlayer, playerSprite, spikeSprite, type PlayerPose} from './sprites.js'; +import {PHASE_PX, type DashWorld} from './world.js'; + +/** The scene is one half-block pixel layer (width = columns, height = 2 x rows) over a gradient of cell backgrounds. */ +export const MIN_COLUMNS = 40; +export const MIN_ROWS = 12; +/** Ground band, in pixels from the bottom: the surface cell row and one row of texture below it. */ +const GROUND_PX = 4; +/** Distance over which the color phase blends into the next one. */ +const BLEND_PX = 140; + +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.2), 8, 28), skyRows: Math.max(1, rows - GROUND_PX / 2)}; +} + +export interface SceneView { + world: DashWorld; + alpha: number; + palette: ArcadePalette; + /** Parallax, trail, streaks and the pixel spikes; off without color, where every lit pixel would be the same solid block. */ + decor: boolean; + reduced: boolean; + visualMs: number; + pose: PlayerPose; + particles: ParticleSystem; + trail: Trail; + popups: Popups; + shake: {x: number; y: number}; + flash: number; + /** The surface the cat last landed on (-1 the ground), the world x of the landing, and how bright it still glows (0..1). */ + hot: {support: number; x: number; amount: number}; + score: number; + best: number; + 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 accent of each color phase, all palette roles; a new one every `PHASE_METERS`. */ +export function phaseTones(palette: ArcadePalette): readonly Rgb[] { + return [palette.accent, palette.ok, palette.warn, palette.accentSoft, palette.bad]; +} + +/** The tint at `distance`: the current phase's tone, blended in from the previous one over `BLEND_PX`. */ +export function phaseTint(palette: ArcadePalette, distance: number): Rgb { + const tones = phaseTones(palette); + const index = Math.max(0, Math.floor(distance / PHASE_PX)); + const into = distance - index * PHASE_PX; + const current = tones[index % tones.length]!; + if (index === 0) return current; + return mixRgb(tones[(index - 1) % tones.length]!, current, smoothstep(into / BLEND_PX)); +} + +export class DashScene { + private buffer = new PixelBuffer(0, 0); + private gaps = new Uint8Array(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); } + if (this.gaps.length !== geo.width) this.gaps = new Uint8Array(geo.width); + const {palette, world} = view; + const buffer = this.buffer; + buffer.clear(); + const distance = lerp(world.prevDistance, world.distance, view.alpha); + const feet = lerp(world.prevY, world.y, view.alpha); + const camera = distance - geo.catX; + const tint = phaseTint(palette, distance); + const beat = view.reduced || view.phase !== 'playing' ? 0 : Math.max(0, 1 - ((distance / 48) % 1) / 0.3) ** 2 * 0.12; + const lift = (color: Rgb, extra = 0): Rgb => { + const amount = Math.min(1, extra + view.flash * 0.7); + return amount > 0 ? mixRgb(color, palette.haze, amount) : color; + }; + const skyTop = mixRgb(palette.deep, tint, 0.16); + const skyBottom = mixRgb(palette.dusk, tint, 0.34); + const ground = mixRgb(skyTop, palette.deep, 0.55); + const surface = mixRgb(tint, palette.ink, 0.3); + const blockBody = mixRgb(skyBottom, palette.ink, 0.3); + const blockTop = mixRgb(tint, palette.ink, 0.65); + + // Sky gradient, then the ground band's cells. + canvas.clear(lift(skyBottom)); + for (let row = 0; row < geo.skyRows; row += 1) canvas.fill(0, row, canvas.width, 1, ' ', {bg: lift(mixRgb(skyTop, skyBottom, (row + 0.5) / geo.skyRows), beat)}); + canvas.fill(0, geo.skyRows, canvas.width, canvas.height - geo.skyRows, ' ', {bg: ground}); + + const screenX = (worldX: number): number => Math.round(worldX - camera); + this.gaps.fill(0); + for (const piece of world.course.pieces) { + if (piece.kind !== 'gap') continue; + const from = Math.max(0, screenX(piece.x)); + const to = Math.min(geo.width, screenX(piece.x + piece.w)); + for (let x = from; x < to; x += 1) this.gaps[x] = 1; + } + + if (view.decor) this.backdrop(view, geo, distance, {top: skyTop, bottom: skyBottom}, tint, beat); + this.groundBand(canvas, buffer, view, geo, ground, surface, camera); + + for (const piece of world.course.pieces) { + const x = screenX(piece.x); + if (x >= geo.width) break; + if (x + piece.w < 0 || piece.kind === 'gap') continue; + if (piece.kind === 'block') this.block(buffer, view, geo, piece, x, blockBody, blockTop, tint); + } + + // The player, over a soft shadow. + const sprite = playerSprite(view.pose, palette); + const footY = geo.groundY - Math.round(feet); + if (view.decor) { + view.trail.draw(buffer, mixRgb(palette.accent, palette.haze, 0.35), mixRgb(palette.accent, skyBottom, 0.85)); + view.particles.draw(buffer, skyBottom); + } + const spike = spikeSprite(palette); + for (const piece of world.course.pieces) { + const x = screenX(piece.x); + if (x >= geo.width) break; + if (x + piece.w < 0 || piece.kind !== 'spike') continue; + if (view.decor) buffer.sprite(spike, x, geo.groundY - piece.level - SPIKE_H); + } + if (view.decor) buffer.sprite(sprite, geo.catX, footY - sprite.height); + canvas.blit(buffer, view.shake.x, view.shake.y); + + if (!view.decor) this.plain(canvas, view, geo, screenX, footY); + if (view.decor) view.popups.draw(canvas, skyTop); + this.hud(canvas, view, geo, beat > 0 || (view.phase === 'playing' && !view.reduced && (distance / 48) % 1 < 0.3)); + } + + /** Stars, two parallax layers of sharp geometry and, at speed, a few streaks. Everything is a tint of the sky. */ + private backdrop(view: SceneView, geo: Geometry, distance: number, sky: {top: Rgb; bottom: Rgb}, tint: Rgb, beat: number): void { + const {palette} = view; + const buffer = this.buffer; + const calm = view.reduced; + const stars = Math.floor((geo.width * geo.groundY) / 260); + for (let index = 0; index < stars; index += 1) { + const x = hash(index * 2) % geo.width; + const y = hash(index * 2 + 1) % Math.max(1, geo.groundY - 14); + buffer.set(x, y, mixRgb(sky.top, palette.ink, 0.28 + (index % 3) * 0.08)); + } + const far = mixRgb(sky.bottom, palette.deep, 0.5); + const near = mixRgb(sky.bottom, palette.deep, 0.68); + const drift = calm ? 0 : 1; + for (let x = 0; x < geo.width; x += 1) { + const u = x + distance * 0.12 * drift; + const cell = Math.floor(u / 36); + const peak = 5 + (hash(cell) % 6); + const rise = 1 - Math.abs(((u - cell * 36) / 36) * 2 - 1); + const height = Math.round(peak * rise); + if (height > 0) buffer.rect(x, geo.groundY - height, 1, height, mixRgb(far, tint, beat * 2)); + const v = x + distance * 0.32 * drift; + const pillar = Math.floor(v / 15); + if (hash(pillar + 977) % 3 === 0 && v - pillar * 15 < 2) buffer.rect(x, geo.groundY - 3 - (hash(pillar) % 9), 1, 3 + (hash(pillar) % 9), mixRgb(near, tint, beat * 3)); + } + const fraction = clamp((view.world.speed - SPEED_START) / (SPEED_MAX - SPEED_START), 0, 1); + const streaks = calm ? 0 : Math.floor(fraction * 7); + for (let index = 0; index < streaks; index += 1) { + const length = 4 + (hash(index + 31) % 5) + Math.floor(fraction * 6); + const span = geo.width + length; + const x = ((((hash(index * 5) % span) - distance * (1.6 + (index % 3) * 0.4)) % span) + span) % span - length; + const y = 2 + (hash(index * 11 + 3) % Math.max(2, geo.groundY - 12)); + for (let step = 0; step < length; step += 1) buffer.set(x + step, y, mixRgb(sky.bottom, palette.haze, 0.1 + 0.35 * (step / length))); + } + } + + private groundBand(canvas: Canvas, buffer: PixelBuffer, view: SceneView, geo: Geometry, ground: Rgb, surface: Rgb, camera: number): void { + const {palette} = view; + const void_ = mixRgb(palette.deep, ground, 0.35); + const texture = geo.skyRows + 1; + const scroll = Math.floor(camera); + for (let x = 0; x < geo.width; x += 1) { + if (this.gaps[x]) { + canvas.fill(x, geo.skyRows, 1, canvas.height - geo.skyRows, ' ', {bg: void_}); + continue; + } + let color = surface; + if (view.hot.amount > 0 && view.hot.support === -1) { + const near = 1 - Math.abs(x + camera - view.hot.x) / 18; + if (near > 0) color = mixRgb(surface, palette.haze, near * view.hot.amount * 0.9); + } + buffer.set(x, geo.groundY, color); + buffer.set(x, geo.groundY + 1, mixRgb(surface, ground, 0.55)); + const n = hash(x + scroll) % 9; + if (texture < canvas.height) canvas.put(x, texture, n === 0 ? '▒' : '░', {fg: palette.faint, bg: ground}); + } + } + + private block(buffer: PixelBuffer, view: SceneView, geo: Geometry, piece: Piece, x: number, body: Rgb, top: Rgb, tint: Rgb): void { + const {palette} = view; + const y = geo.groundY - piece.level; + let edge = top; + if (view.hot.amount > 0 && view.hot.support === piece.id) edge = mixRgb(top, palette.haze, view.hot.amount); + buffer.rect(x, y, piece.w, geo.height - y, body); + buffer.rect(x, y, piece.w, 1, edge); + buffer.rect(x, y + 1, 1, geo.height - y - 1, mixRgb(body, tint, 0.45)); + buffer.rect(x + piece.w - 1, y + 1, 1, geo.height - y - 1, mixRgb(body, palette.deep, 0.4)); + } + + /** Without color or with Safe Glyphs: spikes are ▲ glyphs, the cat is the ASCII cat; the pixel scene is blocks and ground only. */ + private plain(canvas: Canvas, view: SceneView, geo: Geometry, screenX: (worldX: number) => number, footY: number): void { + const {palette, world} = view; + for (const piece of world.course.pieces) { + const x = screenX(piece.x); + if (x >= geo.width) break; + if (x + piece.w < 0 || piece.kind !== 'spike') continue; + const row = (geo.groundY - piece.level) / 2 - 1; + for (let column = 0; column < SPIKE_W; column += 1) canvas.put(x + column, row, '▲', {fg: palette.bad, bold: true}); + } + const rows = plainPlayer(view.pose); + const bottom = Math.round(footY / 2) - 1; + rows.forEach((text, index) => canvas.text(geo.catX, bottom - (rows.length - 1) + index, text, {fg: palette.cat.body, bold: true})); + } + + private hud(canvas: Canvas, view: SceneView, geo: Geometry, tick: boolean): void { + const {palette, world} = 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 >= 44) canvas.text(1, 0, `HI ${String(Math.min(view.best, 99999)).padStart(5, '0')}`, {fg: palette.muted}); + if (view.phase !== 'ready') { + const speed = `${(world.speed / SPEED_START).toFixed(2)}x`; + const status = `${world.meters} m ${speed}`; + canvas.text(1, 1, status, {fg: palette.muted}); + canvas.text(1 + status.length + 1, 1, tick ? '•' : '·', {fg: palette.faint}); + } + if (view.phase !== 'ready') return; + const title = typeset({role: 'title', text: 'Vespyr Dash', maxColumns: canvas.width - 4, maxRows: 2}); + const top = clamp(Math.floor(geo.skyRows / 3) - 1, 2, Math.max(2, 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 >= 40 ? 'Space or Up to dash · jump the spikes' : 'Space to dash'; + 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/dash/solver.ts b/src/arcade/games/dash/solver.ts new file mode 100644 index 00000000..9ec4acf2 --- /dev/null +++ b/src/arcade/games/dash/solver.ts @@ -0,0 +1,176 @@ +import { + AIR_STEPS, HB_X, phasesAt, PW, REACT_STEPS, WINDOW_STEPS, newBody, stepBody, stepPx, surfaceAt, takeoff, + type Body, type Piece, +} from './physics.js'; + +/** + * The fairness proof, as a backward dynamic program over the fixed-step timeline of the real simulation. + * + * Because the cat scrolls at `speedAt(distance)` whatever the player does, the hitbox's position at step t is the same for + * every player. A grounded state is therefore just a step index (the height is the surface under that position). From + * grounded step t there are two moves: keep running (`next[t]`: the next grounded step, possibly after stepping off a ledge + * and falling) or jump (`jump[t]`: the grounded step of the touchdown). Both are found by running `stepBody` itself. + * + * A grounded step is *robust* (`ok[t]`) when the player is not forced into one lucky frame: + * - there is a run of at least `WINDOW_STEPS` consecutive steps t..t+W-1 at which a jump lands somewhere robust, or + * - running on leads to a robust step. + * and a touchdown is robust only after `REACT_STEPS` more steps of running (landing plus reaction: no action is assumed + * sooner). Beyond the end of the examined stretch the ground is open and everything is robust. + */ +export interface Table { + /** Cat distance at each step. */ + distance: number[]; + /** Index of the first step beyond the examined stretch. */ + end: number; + next: Int32Array; + jump: Int32Array; + good: Uint8Array; + ok: Uint8Array; + run: Int32Array; +} + +const DEAD = -1; +/** How far beyond the hitbox a single simulated move can read: the longest fall or jump, with room to spare. */ +const REACH_PX = 150; + +/** True when no piece is within reach of a hitbox whose left edge is `left`. */ +function clear(pieces: readonly Piece[], left: number): boolean { + for (const piece of pieces) { + if (piece.x >= left + PW + REACH_PX) return true; + if (piece.x + piece.w > left - 4) return false; + } + return true; +} + +/** Cat distances for `count` steps starting at `from`, following the real speed curve. */ +export function distances(from: number, count: number): number[] { + const out = [from]; + for (let index = 1; index < count; index += 1) out.push(out[index - 1]! + stepPx(out[index - 1]!)); + return out; +} + +function settle(table: Table, body: Body, from: number, pieces: readonly Piece[]): number { + const last = table.distance.length - 1; + for (let step = from + 1; step <= last; step += 1) { + if (stepBody(body, table.distance[step]! + HB_X, pieces) !== 'alive') return DEAD; + if (body.grounded) return step; + } + return table.end; +} + +/** + * Solve the stretch from cat distance `from` (the cat stands on open ground there) to `until`; `pieces` are sorted by x. + * `tail` extra steps are simulated beyond `until` so a jump started near the end can land. + */ +export interface Reuse { + /** A table solved from the same `from` for pieces that agree with these ones left of world x `x`. */ + table: Table; + x: number; +} + +/** No simulation from a step reads further than this many px beyond the hitbox (the longest fall or jump is under 80 steps). */ +const LOOK_PX = REACH_PX; + +export function solve(pieces: readonly Piece[], from: number, until: number, reuse?: Reuse, tail = 90): Table { + const distance = [from]; + while (distance[distance.length - 1]! < until) distance.push(distance[distance.length - 1]! + stepPx(distance[distance.length - 1]!)); + const end = distance.length - 1; + for (let index = 0; index < tail; index += 1) distance.push(distance[distance.length - 1]! + stepPx(distance[distance.length - 1]!)); + const table: Table = { + distance, end, next: new Int32Array(end + 1).fill(DEAD), jump: new Int32Array(end + 1).fill(DEAD), + good: new Uint8Array(end + 1), ok: new Uint8Array(end + 1), run: new Int32Array(end + 2), + }; + const body = newBody(); + for (let step = 0; step < end; step += 1) { + if (reuse && step < reuse.table.end && distance[step]! + HB_X + PW + LOOK_PX < reuse.x) { + table.next[step] = reuse.table.next[step]!; + table.jump[step] = reuse.table.jump[step]!; + continue; + } + const left = distance[step]! + HB_X; + const top = surfaceAt(left, pieces); + if (top === -Infinity) continue; + if (top === 0 && clear(pieces, left)) { + // Nothing within reach of the hitbox: running on goes on, and a jump lands a full flat jump later. + table.next[step] = step + 1; + table.jump[step] = step + AIR_STEPS; + continue; + } + Object.assign(body, {y: top, vy: 0, grounded: true, landed: false, support: -1}); + table.next[step] = settle(table, body, step, pieces); + Object.assign(body, {y: top, vy: 0, grounded: true, landed: false, support: -1}); + takeoff(body); + table.jump[step] = settle(table, body, step, pieces); + } + table.ok[end] = 1; + table.good[end] = 1; + for (let step = end - 1; step >= 0; step -= 1) { + const landing = table.jump[step]!; + table.good[step] = landing !== DEAD && reacted(table, landing) ? 1 : 0; + const onward = table.next[step]!; + table.run[step] = table.good[step] ? 1 + (onward === step + 1 ? table.run[step + 1]! : 0) : 0; + table.ok[step] = table.run[step]! >= WINDOW_STEPS || (onward !== DEAD && table.ok[Math.min(onward, end)]) ? 1 : 0; + } + return table; +} + +/** After touching down at `landing` the player runs on for the reaction time, then must be at a robust step. */ +function reacted(table: Table, landing: number): boolean { + let step = landing; + while (step < landing + REACT_STEPS) { + if (step >= table.end) return true; + step = table.next[step]!; + if (step === DEAD) return false; + } + return step >= table.end || table.ok[step] === 1; +} + +/** The takeoff window of the first thing in the way, from a standing start. */ +export interface Window { + /** Cat distances of the first and last takeoff, and the number of steps. */ + from: number; to: number; steps: number; + /** Cat distance at which the player touches down after the last takeoff, and when the reaction time has passed. */ + lands: number; free: number; +} + +export function firstWindow(table: Table): Window | undefined { + let step = 0; + while (step < table.end) { + const onward = table.next[step]!; + if (onward === DEAD) break; + step = onward; + } + if (step >= table.end) return undefined; + let last = step - 1; + while (last >= 0 && !table.good[last]) last -= 1; + if (last < 0) return undefined; + let first = last; + while (first > 0 && table.good[first - 1]) first -= 1; + const landing = table.jump[last]!; + let free = landing; + while (free < landing + REACT_STEPS && free < table.end) { const onward = table.next[free]!; if (onward === DEAD) break; free = onward; } + return { + from: table.distance[first]!, to: table.distance[last]!, steps: last - first + 1, + lands: table.distance[Math.min(landing, table.distance.length - 1)]!, free: table.distance[Math.min(free, table.distance.length - 1)]!, + }; +} + +/** True when a player standing on open ground at `from` can get through `pieces` with a window of at least `WINDOW_STEPS` everywhere. */ +export const isRobust = (pieces: readonly Piece[], from: number, until: number): boolean => solve(pieces, from, until).ok[0] === 1; + +/** The tables of one stretch at every alignment of the cat's steps to the course. */ +export type PhaseTables = Table[]; + +/** + * Solve at every phase (the cat can arrive at `from` aligned any of those ways); `reuse` are the previous tables. Stops at + * the first phase that is not robust and returns undefined: a candidate placement usually fails at the first. + */ +export function solvePhases(pieces: readonly Piece[], from: number, until: number, reuse?: {tables: PhaseTables; x: number}): PhaseTables | undefined { + const tables: PhaseTables = []; + for (const [index, phase] of phasesAt(from).entries()) { + const table = solve(pieces, from + phase, until, reuse ? {table: reuse.tables[index]!, x: reuse.x} : undefined); + if (table.ok[0] !== 1) return undefined; + tables.push(table); + } + return tables; +} diff --git a/src/arcade/games/dash/sprites.ts b/src/arcade/games/dash/sprites.ts new file mode 100644 index 00000000..ee7165c3 --- /dev/null +++ b/src/arcade/games/dash/sprites.ts @@ -0,0 +1,43 @@ +import {mixRgb} from '../../../chroma/chroma.js'; +import {CAT_ASCII, CAT_ASCII_BLINK, catPixels} from '../../../idle/catSprite.js'; +import {spriteFromRows, type Sprite} from '../../Canvas.js'; +import type {ArcadePalette} from '../../palette.js'; + +/** + * Vespyr on his glowing board. The silhouette is cut from `catPixels()` (the approved sprite): the head with its two ears + * and the two eyes, and the chest, in a 7 x 6 block that sits on a board of light. Only the board and the eyelids change + * between poses, so he is recognizably the same cat as everywhere else. + */ +export type PlayerPose = 'ride' | 'air' | 'land' | 'hit' | 'blink'; + +const HEAD_FIRST = 1; +const HEAD_LAST = 6; + +/** The pose's pixel rows: `L` body, `E` eye, `S` chest, `B` board, `G` the board's glowing ends, `.` see-through. */ +export function playerRows(pose: PlayerPose): string[] { + const source = catPixels(pose === 'hit' || pose === 'blink' ? 'blink' : 'idle'); + const head = source.slice(0, 5).map((row, index) => `.${row.slice(HEAD_FIRST, HEAD_LAST)}.`.replace(/L/gu, index === 4 ? 'S' : 'L')); + switch (pose) { + case 'air': return [...head, '.GBBBG.']; + case 'land': return [head[0]!, head[1]!, head[2]!, head[4]!, 'GBBBBBG']; + case 'hit': return [...head, 'G.B.B.G']; + default: return [...head, 'GBBBBBG']; + } +} + +export function playerSprite(pose: PlayerPose, palette: ArcadePalette): Sprite { + return spriteFromRows(playerRows(pose), { + L: palette.cat.body, E: palette.cat.eye, S: palette.cat.shade, B: palette.accent, G: mixRgb(palette.accent, palette.haze, 0.6), + }); +} + +/** The cat for a plain render (NO_COLOR, Safe Glyphs): the NMSh ASCII cat, 7 x 3 cells, the same width as the sprite. */ +export const plainPlayer = (pose: PlayerPose): readonly string[] => (pose === 'hit' || pose === 'blink' ? CAT_ASCII_BLINK : CAT_ASCII); + +/** A spike, 6 x 4: `A` fill, `B` the lit tip. Its hitbox is the 4 x 3 core (see physics). */ +export const SPIKE_ROWS: readonly string[] = ['..BB..', '.AAAA.', '.AAAA.', 'AAAAAA']; + +export function spikeSprite(palette: ArcadePalette): Sprite { + const base = mixRgb(palette.bad, palette.ink, 0.15); + return spriteFromRows(SPIKE_ROWS, {A: base, B: mixRgb(base, palette.ink, 0.6)}); +} diff --git a/src/arcade/games/dash/world.ts b/src/arcade/games/dash/world.ts new file mode 100644 index 00000000..036d4807 --- /dev/null +++ b/src/arcade/games/dash/world.ts @@ -0,0 +1,105 @@ +import {hashSeed, type Rng} from '../../rng.js'; +import {Course, type CourseLike, type CourseOptions} from './course.js'; +import {HB_X, JUMP_BUFFER_STEPS, newBody, speedAt, stepBody, stepPx, takeoff, type Body, type Fate} from './physics.js'; + +/** Distance (px) per score point and per meter; every unit cleared is worth `CLEAR_POINTS` more. */ +export const SCORE_PX = 5; +export const METER_PX = 10; +export const CLEAR_POINTS = 10; +/** A new color phase every this many meters. */ +export const PHASE_METERS = 80; +export const PHASE_PX = PHASE_METERS * METER_PX; +/** One beat of the visual metronome every this many px of distance. */ +export const BEAT_PX = 48; +/** The course is always generated at least this far ahead of the cat. */ +export const LOOKAHEAD_PX = 480; + +export interface StepOutcome { + /** The cat took off this step (a press, or a buffered press on touchdown). */ + tookOff: boolean; + /** The cat touched down this step, after this many steps in the air; the piece it stands on (-1: the ground). */ + landedAfter: number; + support: number; + /** What ended the run on this step, if anything. */ + fate: Fate; +} + +/** + * The simulation: a cat scrolling 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 presses on the same steps give the same run at any frame rate. + */ +export class DashWorld { + readonly course: CourseLike; + readonly body: Body = newBody(); + distance: number; + prevDistance: number; + prevY = 0; + alive = true; + fate: Fate = 'alive'; + steps = 0; + cleared = 0; + private buffer = 0; + private airSteps = 0; + + /** `options.course` substitutes a prepared course (tests replay one course against several players). */ + constructor(rng: Rng, options: CourseOptions & {course?: CourseLike} = {}) { + this.distance = options.from ?? 0; + this.prevDistance = this.distance; + this.course = options.course ?? new Course(rng, options); + this.course.ensure(this.distance + LOOKAHEAD_PX); + } + + get speed(): number { return speedAt(this.distance); } + get y(): number { return this.body.y; } + get airborne(): boolean { return !this.body.grounded; } + get score(): number { return Math.floor(this.distance / SCORE_PX) + this.cleared * CLEAR_POINTS; } + get meters(): number { return Math.floor(this.distance / METER_PX); } + get phase(): number { return Math.floor(this.distance / PHASE_PX); } + /** 0..1 within the current beat. */ + get beat(): number { return (this.distance / BEAT_PX) % 1; } + + /** Jump if standing; in the air, remember the press briefly so a jump happens exactly on touchdown. */ + jump(): boolean { + if (!this.alive) return false; + if (this.airborne) { this.buffer = JUMP_BUFFER_STEPS; return false; } + this.launch(); + return true; + } + + private launch(): void { + takeoff(this.body); + this.airSteps = 0; + } + + step(): StepOutcome { + const outcome: StepOutcome = {tookOff: false, landedAfter: 0, support: -1, fate: 'alive'}; + if (!this.alive) return outcome; + this.steps += 1; + this.prevDistance = this.distance; + this.prevY = this.body.y; + if (this.buffer > 0) this.buffer -= 1; + this.distance += stepPx(this.distance); + if (this.airborne) this.airSteps += 1; + const fate = stepBody(this.body, this.distance + HB_X, this.course.pieces); + if (this.body.landed) { + outcome.landedAfter = this.airSteps; + outcome.support = this.body.support; + if (this.buffer > 0) { this.buffer = 0; this.launch(); outcome.tookOff = true; } + } + this.course.ensure(this.distance + LOOKAHEAD_PX); + this.course.prune(this.distance - 40); + for (const unit of this.course.units) { + if (unit.end >= this.distance + HB_X) break; + if (unit.id > this.cleared) this.cleared = unit.id; + } + if (fate !== 'alive') { this.alive = false; this.fate = fate; outcome.fate = fate; } + return outcome; + } + + /** A hash of everything that decides the future, for determinism tests. */ + hash(): number { + const { body } = this; + const parts = [this.distance, body.y, body.vy, body.grounded ? 1 : 0, this.buffer, this.steps, this.cleared, this.course.pieces.length, this.course.pieces[0]?.x ?? 0, this.alive ? 1 : 0]; + return hashSeed(parts.map(part => part.toFixed(6)).join('|')); + } +} 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 new file mode 100644 index 00000000..52c8954a --- /dev/null +++ b/src/arcade/games/doku/index.ts @@ -0,0 +1,330 @@ +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'; + +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..0717a517 --- /dev/null +++ b/src/arcade/games/doku/render.ts @@ -0,0 +1,265 @@ +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..cdc429d8 --- /dev/null +++ b/src/arcade/games/doku/sprite.ts @@ -0,0 +1,47 @@ +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/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 new file mode 100644 index 00000000..77c04e37 --- /dev/null +++ b/src/arcade/games/flight/index.ts @@ -0,0 +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 {createFlightGame, readRecord} from './game.js'; +import {OB_W} from './physics.js'; +import {obstacleColor} from './scene.js'; +import {catSprite} from './sprites.js'; + +/** 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/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/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 new file mode 100644 index 00000000..bf11332b --- /dev/null +++ b/src/arcade/games/run/index.ts @@ -0,0 +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 {createRunGame, readRecord} from './game.js'; +import {geometry} from './scene.js'; +import {catSprite, obstacleSprite} from './sprites.js'; + +/** 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/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 new file mode 100644 index 00000000..aeabe985 --- /dev/null +++ b/src/arcade/games/sudoku/index.ts @@ -0,0 +1,349 @@ +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'; + +/** 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/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/ask/commandSuggestions.ts b/src/ask/commandSuggestions.ts new file mode 100644 index 00000000..70e511eb --- /dev/null +++ b/src/ask/commandSuggestions.ts @@ -0,0 +1,46 @@ +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'; + +/** + * 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. "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 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'; + 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: `NMSh's check: ${RISK_WORDS[risk]}. Your local model (${modelLabel}) says: “${suggestion.explanation}” Check it before you run it.`}; +} + +const RISK_WORDS: Record = {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/concepts.ts b/src/ask/concepts.ts index 9eda8b8a..eb0824f3 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.'}, @@ -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).'}, @@ -253,6 +253,18 @@ 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 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: 'arcade', label: 'Play a Vespyr Arcade game', support: 'actionable', open: '/games', covers: ['/games', '/game', '/arcade'], + aliases: ['play a game', 'play games', 'games', 'arcade', 'vespyr arcade', 'sudoku', 'vespyr run', 'vespyr flight', 'vespyr dash', 'vespyr doku', 'doku'], + description: '/games (also /arcade) opens the Vespyr Arcade menu: Run, Flight, Dash, Sudoku and Doku. /game run|flight|dash|sudoku|doku starts one directly. Only the open game reads the keyboard; Esc pauses and leaves. It never starts while a command is running.'}, + {id: 'browser', label: 'Open the built-in browser', support: 'actionable', open: '/browser', covers: ['/browser'], + aliases: ['browser', 'open a browser', 'open a web page', 'open a website', 'browse the web', 'web browser', 'chromium'], + description: '/browser [url|file] opens a real Chromium page inside the terminal when Chrome, Chromium or Edge is installed (started on demand, isolated profile). Tabs, address bar, back/forward, find, zoom, forms and mouse. Without a browser or a graphics-capable terminal it says so and stays closed. Markdown and HTML previews in /view use the same engine.'}, + {id: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'], + aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'], + description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR still work as usual.'}, {id: 'paste-batch', label: 'Paste and queue a batch', support: 'actionable', open: '/ps', covers: ['/ps', '/clips'], aliases: ['paste commands', 'paste batch', 'paste from clipboard', 'queue pasted commands', 'review pasted commands', 'ps command', 'run as block'], description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch with choices to run as one block, queue separately, edit or cancel; quotes, loops and heredocs stay whole. C deliberately keeps a clip in memory; /clips reviews it before insertion. Clips disappear when this frontend exits. Nothing executes automatically.'}, @@ -364,7 +376,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, - {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'output-actions', 'compare', 'transcripts', 'pins']}, + {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'output-actions', 'view', 'edit-file', 'compare', 'transcripts', 'pins']}, {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], @@ -376,5 +388,6 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, {id: 'layout', title: 'Layout & folding', why: 'Decide where the composer sits and how much output stays in view.', concepts: ['layout', 'composerDividers', 'folding', 'statusStrip', 'idle']}, + {id: 'arcade', title: 'Arcade & browser', why: 'Five small terminal games, and a real browser page inside the terminal when a Chromium-based browser is installed.', concepts: ['arcade', 'browser']}, {id: 'keyboard', title: 'Keyboard', why: 'Terminal key integration for Shift+Enter, Option as Meta and enhanced keys.', concepts: ['keyboard']}, ]; 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 012e1003..213feb9d 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; @@ -158,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/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/src/browser/BrowserPanel.ts b/src/browser/BrowserPanel.ts new file mode 100644 index 00000000..e262d95d --- /dev/null +++ b/src/browser/BrowserPanel.ts @@ -0,0 +1,360 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {editText} from '../ui/formControls.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {framePanel, renderTabStrip, selectedRowBand, tabWindow} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {addressKind, displayUrl, hasCredentials, normalizeAddress, plainText} from './address.js'; +import {cellToViewport, insideArea, keyToInput, type MouseGeometry} from './keymap.js'; +import type {BrowserSession, TabView} from './session.js'; + +/** + * The /browser panel, in the viewer family: a tab strip, the address, the page, one status line and the controls. + * + * Focus decides who owns the keys, and there are exactly four owners: + * chrome the browser's own commands. The default. No key here ever reaches the page. + * page every key goes to the page, except Esc (back to chrome) and Ctrl+C (closes the browser). + * address keys edit the address; Enter goes there, Esc cancels. + * find keys edit the search text; Enter finds, Esc returns to chrome. + * The address shown always comes from the tab's real URL, never from anything the page says. Ctrl+L cannot be offered: the key + * decoder reports no Ctrl+L, so `o` (and a click on the address) open the address bar. + */ +export type BrowserFocus = 'chrome' | 'page' | 'address' | 'find'; + +export interface BrowserPanelState { + readonly session: BrowserSession; + focus: BrowserFocus; + /** The address being typed. */ + draft: string; + /** The search text being typed. */ + findDraft: string; + cwd: string; + home?: string; + /** Where the last rendering put things, relative to the panel's first row, for mouse hits. */ + layout?: PanelLayout; + /** A mouse button is down on the page: the release is forwarded wherever it happens. */ + pressed: boolean; +} + +export interface PanelLayout { + tabRow: number; + addressRow: number; + pageRow: number; + pageRows: number; + columns: number; + /** Cell ranges of the visible tabs on the tab row (0-based, end exclusive). */ + tabHits: Array<{start: number; end: number; index: number}>; +} + +export type BrowserPanelAction = + | {kind: 'close'} + | {kind: 'copy'; text: string; label: string} + /** A Markdown file was typed in the address: the caller opens it in the document viewer. */ + | {kind: 'open-document'; path: string}; + +export interface PanelGeometry { + /** The 1-based screen row and column of the panel's first row and column. */ + top: number; + left?: number; + cell: {width: number; height: number}; + scale: number; +} + +type MouseKey = {kind: 'mouseClick' | 'mouseRelease' | 'mouseDrag' | 'mouseMove'; x?: number; y?: number}; + +export const MIN_COLUMNS = 32; +export const MIN_HEIGHT = 10; +const COMPACT_COLUMNS = 50; +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const BOLD = '\u001b[1m'; + +export function createBrowserPanel(session: BrowserSession, options: {cwd: string; home?: string}): BrowserPanelState { + return {session, focus: 'chrome', draft: '', findDraft: '', cwd: options.cwd, ...(options.home ? {home: options.home} : {}), pressed: false}; +} + +// ---- keys ----------------------------------------------------------------------------------------------------------- + +export function browserPanelKey(state: BrowserPanelState, key: Key, geometry: PanelGeometry): BrowserPanelAction | undefined { + if (key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'mouseClick' || key.kind === 'mouseRelease' || key.kind === 'mouseDrag' || key.kind === 'mouseMove') return mouseKey(state, key as MouseKey, geometry); + if (key.kind === 'wheelUp' || key.kind === 'wheelDown') { void state.session.scroll('line', key.kind === 'wheelUp' ? -1 : 1); return undefined; } + if (key.kind === 'focusIn' || key.kind === 'focusOut') return undefined; + switch (state.focus) { + case 'page': return pageKey(state, key); + case 'address': return addressKey(state, key); + case 'find': return findKey(state, key); + default: return chromeKey(state, key); + } +} + +function pageKey(state: BrowserPanelState, key: Key): BrowserPanelAction | undefined { + if (key.kind === 'escape') { state.focus = 'chrome'; return undefined; } + if (key.kind === 'paste') { if (key.value) void state.session.insertText(key.value); return undefined; } + const input = keyToInput(key); + if (input) void state.session.sendKey(input); + return undefined; +} + +function addressKey(state: BrowserPanelState, key: Key): BrowserPanelAction | undefined { + if (key.kind === 'escape') { state.focus = 'chrome'; return undefined; } + if (key.kind === 'enter') { + const result = normalizeAddress(state.draft, {cwd: state.cwd, ...(state.home ? {home: state.home} : {})}); + if (!result.ok) { state.session.setNotice(result.reason); return undefined; } + state.focus = 'chrome'; + if (result.local) return {kind: 'open-document', path: result.local}; + void state.session.navigate(result.url); + return undefined; + } + const next = editText(state.draft, key); + if (next !== undefined) state.draft = next.slice(0, 2048); + return undefined; +} + +function findKey(state: BrowserPanelState, key: Key): BrowserPanelAction | undefined { + if (key.kind === 'escape') { state.focus = 'chrome'; return undefined; } + if (key.kind === 'enter') { + const current = state.session.activeView()?.find?.query; + if (!state.findDraft) void state.session.clearFind(); + else if (current === state.findDraft) void state.session.findNext(false); + else void state.session.find(state.findDraft); + return undefined; + } + const next = editText(state.findDraft, key); + if (next !== undefined) state.findDraft = next.slice(0, 200); + return undefined; +} + +function chromeKey(state: BrowserPanelState, key: Key): BrowserPanelAction | undefined { + const session = state.session; + switch (key.kind) { + case 'escape': return {kind: 'close'}; + case 'down': void session.scroll('line', 1); return undefined; + case 'up': void session.scroll('line', -1); return undefined; + case 'left': void session.scroll('line', -1, true); return undefined; + case 'right': void session.scroll('line', 1, true); return undefined; + case 'pageDown': void session.scroll('page', 1); return undefined; + case 'pageUp': void session.scroll('page', -1); return undefined; + case 'lineHome': case 'bufferHome': void session.scroll('top', -1); return undefined; + case 'lineEnd': case 'bufferEnd': void session.scroll('bottom', 1); return undefined; + case 'complete': session.cycle(1); return undefined; + case 'focusPrevious': session.cycle(-1); return undefined; + case 'enter': state.focus = 'page'; return undefined; + case 'text': return chromeText(state, key.value); + default: return undefined; + } +} + +function chromeText(state: BrowserPanelState, text: string): BrowserPanelAction | undefined { + const session = state.session; + if (/^[1-9]$/u.test(text)) { session.activate(Number(text) - 1); return undefined; } + switch (text) { + case 'j': void session.scroll('line', 1); break; + case 'k': void session.scroll('line', -1); break; + case ' ': void session.scroll('page', 1); break; + case 'b': void session.scroll('page', -1); break; + case 'g': void session.scroll('top', -1); break; + case 'G': void session.scroll('bottom', 1); break; + case '+': case '=': void session.zoomBy(1); break; + case '-': case '_': void session.zoomBy(-1); break; + case '0': void session.setZoom(1); break; + case '[': case 'H': void session.back(); break; + case ']': case 'L': void session.forward(); break; + case 'r': void session.reload(); break; + case 'R': void session.hardReload(); break; + case 's': void session.stop(); break; + case 'o': { const url = session.activeView()?.url ?? ''; state.draft = url === 'about:blank' ? '' : url; state.focus = 'address'; break; } + case 't': state.draft = ''; state.focus = 'address'; void session.newTab(); break; + case 'x': if (session.count <= 1) return {kind: 'close'}; session.closeTab(); break; + case '/': state.findDraft = session.activeView()?.find?.query ?? ''; state.focus = 'find'; break; + case 'n': void session.findNext(false); break; + case 'N': void session.findNext(true); break; + case 'i': state.focus = 'page'; break; + case 'c': { const view = session.activeView(); return view ? {kind: 'copy', text: view.url, label: 'Copied the page address'} : undefined; } + default: break; + } + return undefined; +} + +function mouseKey(state: BrowserPanelState, key: MouseKey, geometry: PanelGeometry): undefined { + const layout = state.layout; + if (!layout || key.x === undefined || key.y === undefined) return undefined; + const left = geometry.left ?? 1; + const mouse: MouseGeometry = { + area: {row: geometry.top + layout.pageRow, column: left, columns: layout.columns, rows: layout.pageRows}, + cell: geometry.cell, scale: geometry.scale, ...(state.session.activeViewport() ? {viewport: state.session.activeViewport()!} : {}), + }; + const cell = {x: key.x, y: key.y}; + const inside = insideArea(cell, mouse.area); + if (key.kind === 'mouseClick') { + if (inside) { + const point = cellToViewport(cell, mouse); + state.focus = 'page'; + state.pressed = true; + if (point) void state.session.sendMouse({type: 'down', ...point, button: 'left', clickCount: 1}); + return undefined; + } + const row = key.y - geometry.top; + if (row === layout.tabRow) { + const column = key.x - left; + const hit = layout.tabHits.find(range => column >= range.start && column < range.end); + if (hit) state.session.activate(hit.index); + } else if (row === layout.addressRow) { + const url = state.session.activeView()?.url ?? ''; + state.draft = url === 'about:blank' ? '' : url; + state.focus = 'address'; + } + return undefined; + } + if (key.kind === 'mouseRelease') { + if (!state.pressed) return undefined; + state.pressed = false; + const point = cellToViewport(cell, mouse, {clamp: true}); + if (point) void state.session.sendMouse({type: 'up', ...point, button: 'left', clickCount: 1}); + return undefined; + } + if (key.kind === 'mouseDrag' && state.pressed) { + const point = cellToViewport(cell, mouse, {clamp: true}); + if (point) void state.session.sendMouse({type: 'move', ...point}); + } else if (key.kind === 'mouseMove' && state.focus === 'page' && inside) { + const point = cellToViewport(cell, mouse); + if (point) void state.session.sendMouse({type: 'move', ...point}); + } + return undefined; +} + +// ---- drawing -------------------------------------------------------------------------------------------------------- + +export interface BrowserRender { + rows: string[]; + /** Where the page is drawn, relative to the first returned row (and column 0). `rows` is 0 when the panel is too small. */ + pageArea: {row: number; column: number; columns: number; rows: number}; +} + +export function renderBrowserPanel(state: BrowserPanelState, columns: number, height: number): BrowserRender { + const subtle = foreground(UI_COLORS.subtle); + if (columns < MIN_COLUMNS || height < MIN_HEIGHT) { + const message = `Needs at least ${MIN_COLUMNS}x${MIN_HEIGHT}`; + const rows = Array.from({length: Math.max(1, height)}, () => ''); + rows[Math.floor(rows.length / 2)] = `${subtle}${truncateText(message, Math.max(1, columns))}${RESET}`; + delete state.layout; + return {rows, pageArea: {row: 0, column: 0, columns: 0, rows: 0}}; + } + const controls = renderControlRows(controlsFor(state), Math.max(1, columns - 4)).slice(0, height < 16 ? 1 : 2).map(row => ` ${row}`); + const pageRows = Math.max(1, height - 5 - controls.length); + state.session.resize({columns, rows: pageRows}); + const views = state.session.view(); + const active = state.session.activeIndex; + const tab = views[active]; + + const tabHits: PanelLayout['tabHits'] = []; + const tabLine = tabRow(views, active, columns, state, tabHits); + const body = [tabLine, addressRow(state, tab, columns), ...pageContent(state, tab, columns, pageRows), statusRow(state, tab, columns), ...controls]; + const rows = [...framePanel(body.map((row, index) => (index >= 2 && index < 2 + pageRows ? row : truncateAnsi(row, columns))), columns), framePanel([], columns)[0]!]; + state.layout = {tabRow: 1, addressRow: 2, pageRow: 3, pageRows, columns, tabHits}; + return {rows: rows.slice(0, height), pageArea: {row: 3, column: 0, columns, rows: pageRows}}; +} + +function tabTitle(view: TabView): string { + let name = view.title; + if (!name) { try { name = new URL(view.url).hostname; } catch { name = ''; } } + if (!name) name = view.url === 'about:blank' || !view.url ? 'New tab' : plainText(displayUrl(view.url), 30); + const marker = view.loading ? (getCurrentGlyphMode() === 'nerd' ? '…' : '*') : ''; + return `${marker}${truncateText(name, 18)}`; +} + +function tabRow(views: TabView[], active: number, columns: number, state: BrowserPanelState, hits: PanelLayout['tabHits']): string { + const subtle = foreground(UI_COLORS.subtle); + if (!views.length) return ` ${subtle}No tabs${RESET}`; + if (columns < COMPACT_COLUMNS) { + const label = `tab ${active + 1} of ${views.length}`; + hits.push({start: 0, end: columns, index: (active + 1) % views.length}); + return ` ${subtle}${truncateText(`${label} · ${tabTitle(views[active]!)}`, columns - 2)}${RESET}`; + } + const labels = views.map(tabTitle); + const widths = labels.map(label => displayWidth(` ${label} `)); + const {start, end} = tabWindow(widths, active, columns); + let at = start > 0 ? 2 : 2; + for (let index = start; index <= end; index += 1) { + hits.push({start: at, end: at + widths[index]!, index}); + at += widths[index]! + 1; + } + void state; + return renderTabStrip(labels, active, columns, false); +} + +function marker(kind: ReturnType): string { + switch (kind) { + case 'https': return `${foreground(UI_COLORS.success)}${GLYPHS.saved}${RESET}`; + case 'http': return `${foreground(UI_COLORS.warning)}!${RESET}`; + case 'file': return `${foreground(UI_COLORS.subtle)}f${RESET}`; + default: return `${foreground(UI_COLORS.subtle)}${GLYPHS.separator}${RESET}`; + } +} + +function addressRow(state: BrowserPanelState, tab: TabView | undefined, columns: number): string { + const width = Math.max(4, columns - 6); + if (state.focus === 'address') { + const text = displayUrl(state.draft); + const shown = [...text].length > width - 1 ? `…${[...text].slice(-(width - 2)).join('')}` : text; + const row = ` ${foreground(UI_COLORS.accent)}>${RESET} ${foreground(UI_COLORS.primary)}${shown}${REVERSE} ${RESET}`; + return selectedRowBand(row, columns); + } + const url = tab?.url ?? ''; + const kind = addressKind(url); + const flag = hasCredentials(url) ? ` ${foreground(UI_COLORS.warning)}(has a user name)${RESET}` : ''; + const shown = url ? truncateText(displayUrl(url), Math.max(4, width - (flag ? 16 : 0))) : ''; + return ` ${marker(kind)} ${foreground(UI_COLORS.primary)}${shown}${RESET}${flag}`; +} + +function pageContent(state: BrowserPanelState, tab: TabView | undefined, columns: number, rows: number): string[] { + const session = state.session; + if (tab && !tab.error && session.hasPicture()) return session.pageLines().slice(0, rows); + const subtle = foreground(UI_COLORS.subtle); + const failure = foreground(UI_COLORS.failure); + let message = ''; + let color = subtle; + if (!tab) message = 'No tab is open. Press t to open one.'; + else if (tab.error) { message = `${tab.error} Press r to reload.`; color = failure; } + else if (tab.loading) message = 'Loading…'; + else if (tab.url === 'about:blank') message = 'A blank page. Press o to type an address.'; + else message = 'Waiting for the page…'; + const out = Array.from({length: rows}, () => ' '.repeat(columns)); + const at = Math.floor((rows - 1) / 2); + const text = truncateText(message, Math.max(1, columns - 4)); + const pad = Math.max(0, Math.floor((columns - displayWidth(text)) / 2)); + out[at] = `${' '.repeat(pad)}${color}${text}${RESET}${' '.repeat(Math.max(0, columns - pad - displayWidth(text)))}`; + return out; +} + +function statusRow(state: BrowserPanelState, tab: TabView | undefined, columns: number): string { + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const width = Math.max(4, columns - 4); + if (state.focus === 'find') { + return ` ${accent}/${RESET} ${foreground(UI_COLORS.primary)}${truncateText(plainText(state.findDraft, 200), width - 4)}${REVERSE} ${RESET}`; + } + const chip = state.focus === 'page' ? `${BOLD}${accent}[page]${RESET} ` : state.focus === 'address' ? `${BOLD}${accent}[address]${RESET} ` : ''; + const parts: string[] = []; + const notice = state.session.notice(); + if (notice) parts.push(notice); + else if (state.focus === 'page' && tab?.editable) parts.push('typing in the page · Esc for browser commands'); + if (tab && !notice) { + if (tab.title && !(state.focus === 'page' && tab.editable)) parts.push(tab.title); + if (addressKind(tab.url) === 'http') parts.push('not secure'); + } + if (tab && tab.zoom !== 1) parts.push(`${Math.round(tab.zoom * 100)}%`); + if (tab?.find) parts.push(tab.find.matches ? `${tab.find.current} of ${tab.find.matches}` : 'no match'); + const chipWidth = displayWidth(chip); + return ` ${chip}${subtle}${truncateText(parts.join(' · '), Math.max(1, width - chipWidth))}${RESET}`; +} + +function controlsFor(state: BrowserPanelState): Array { + switch (state.focus) { + case 'page': return [['Esc', 'browser commands'], ['Ctrl+C', 'close browser']]; + case 'address': return [['Enter', 'go'], ['Esc', 'cancel']]; + case 'find': return [['Enter', 'find'], ['Esc', 'done']]; + default: return [['j/k', 'scroll'], ['Space', 'page'], ['o', 'address'], ['/', 'find'], ['[ ]', 'back/forward'], ['r', 'reload'], ['t', 'new tab'], ['x', 'close tab'], ['+/-', 'zoom'], ['Enter', 'type in page'], ['Esc', 'close']]; + } +} diff --git a/src/browser/address.ts b/src/browser/address.ts new file mode 100644 index 00000000..34a308ac --- /dev/null +++ b/src/browser/address.ts @@ -0,0 +1,155 @@ +import {statSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; + +/** + * What the address bar accepts. A typed address is the person's own request, so the rules are strict: only web pages, local + * files and about:blank open in the browser; every other scheme is refused with a plain reason. NMSh has no search engine and + * never looks anything up on the person's behalf, so text that looks like a search is refused with a hint. + */ +export type AddressResult = {ok: true; url: string; local?: string} | {ok: false; reason: string}; + +export interface AddressOptions { + cwd: string; + home?: string; +} + +const MAX_ADDRESS = 2048; +/** Files that open in the document viewer, not in the browser. */ +const DOCUMENT_EXTENSIONS = new Set(['.md', '.markdown']); +const LOOPBACK = /^(?:localhost|.+\.localhost|127(?:\.\d{1,3}){3}|\[::1\])$/iu; +const SCHEME = /^([a-z][a-z0-9+.-]*):/iu; +/** `host:port` (and `host:port/path`) is an address, not a scheme. */ +const HOST_PORT = /^[^\s/:@?#]+:\d{1,5}(?:[/?#]|$)/u; +const BIDI_AND_FORMAT = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/gu; +const REFUSED_SCHEMES: Readonly> = { + javascript: 'Script addresses are never run.', + data: 'Data addresses are not opened.', + blob: 'Blob addresses are not opened.', + chrome: 'Browser-internal pages are not available.', + 'chrome-untrusted': 'Browser-internal pages are not available.', + 'chrome-extension': 'Extensions are not supported.', + devtools: 'Developer tools are not available.', + 'view-source': 'View-source is not available.', + ftp: 'FTP is not supported; use an https address.', + mailto: 'Mail links are not opened from the browser.', + tel: 'Phone links are not opened from the browser.', + ws: 'Only web pages open here.', + wss: 'Only web pages open here.', + filesystem: 'Filesystem addresses are not opened.', +}; + +export function normalizeAddress(input: string, options: AddressOptions): AddressResult { + const text = input.trim(); + if (!text) return refuse('Type a web address (example.com) or a file path.'); + if (text.length > MAX_ADDRESS) return refuse('That address is too long.'); + if (/[\u0000-\u001f\u007f-\u009f]/u.test(text) || /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(text)) return refuse('The address contains control or direction characters.'); + + const scheme = SCHEME.exec(text)?.[1]?.toLowerCase(); + if (scheme && !HOST_PORT.test(text)) return explicit(text, scheme, options); + + if (isPathLike(text)) return filePath(text, options, true); + // A name that exists beside the shell (notes.html, a path with spaces) is the file, before anything is guessed from its shape. + const existing = filePath(text, options, false); + if (existing.ok) return existing; + if (/\s/u.test(text)) return refuse('NMSh has no search engine. Type a web address (example.com), a localhost address or a file path.'); + return host(text); +} + +function refuse(reason: string): AddressResult { return {ok: false, reason}; } + +function explicit(text: string, scheme: string, options: AddressOptions): AddressResult { + if (scheme === 'about') return /^about:blank$/iu.test(text) ? {ok: true, url: 'about:blank'} : refuse('Only about:blank is available.'); + if (scheme === 'http' || scheme === 'https') { + let url: URL; + try { url = new URL(text); } catch { return refuse('That is not a valid web address.'); } + if (!url.hostname) return refuse('That web address has no host.'); + if (url.username || url.password) return refuse('Addresses with a user name or password are not opened. Remove the credentials from the address.'); + return {ok: true, url: url.href}; + } + if (scheme === 'file') { + let url: URL; + try { url = new URL(text); } catch { return refuse('That is not a valid file address.'); } + if (url.hostname && url.hostname !== 'localhost') return refuse('Only files on this computer open.'); + let file: string; + try { file = fileURLToPath(url); } catch { return refuse('That is not a valid file address.'); } + return filePath(file, options, true); + } + return refuse(REFUSED_SCHEMES[scheme] ?? `${scheme}: addresses are not opened. Only web pages (http, https) and local files open here.`); +} + +function isPathLike(text: string): boolean { + return text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || text === '.' || text === '..' || text === '~' || text.startsWith('~/'); +} + +function filePath(text: string, options: AddressOptions, explain: boolean): AddressResult { + let target = text; + if (target === '~' || target.startsWith('~/')) { + if (!options.home) return refuse('The home folder is not known.'); + target = path.join(options.home, target.slice(1)); + } + const resolved = path.resolve(options.cwd, target); + let stat; + try { stat = statSync(resolved); } catch { return refuse(explain ? 'No such file.' : 'not a file'); } + if (!stat.isFile()) return refuse(explain ? 'That is not a regular file.' : 'not a file'); + const url = pathToFileURL(resolved).href; + return DOCUMENT_EXTENSIONS.has(path.extname(resolved).toLowerCase()) ? {ok: true, url, local: resolved} : {ok: true, url}; +} + +function host(text: string): AddressResult { + const authority = text.split(/[/?#]/u, 1)[0]!; + if (authority.includes('@')) return refuse('Addresses with a user name or password are not opened. Remove the credentials from the address.'); + let url: URL; + try { url = new URL(`https://${text}`); } catch { return refuse('That is not a valid web address.'); } + const name = url.hostname; + const bare = name.replace(/^\[|\]$/gu, ''); + const known = LOOPBACK.test(name) || bare.includes(':') || name.includes('.'); + if (!name || !known) return refuse('That is not a web address. Add a domain (example.com) or use localhost:port.'); + if (LOOPBACK.test(name)) url.protocol = 'http:'; + return {ok: true, url: url.href}; +} + +/** A URL as it is shown: control, direction and format characters removed, a password hidden. Never the page's own text. */ +export function displayUrl(url: string): string { + let shown = url; + try { + const parsed = new URL(url); + if (parsed.password) { parsed.password = '***'; shown = parsed.href; } + } catch { /* shown as given */ } + return shown.replace(BIDI_AND_FORMAT, ''); +} + +/** Text from a page (a title, a dialog message) made safe to draw: control and direction characters removed, bounded. */ +export function plainText(text: string, max = 200): string { + const clean = text.replace(BIDI_AND_FORMAT, ' ').replace(/\s+/gu, ' ').trim(); + return [...clean].length > max ? `${[...clean].slice(0, max - 1).join('')}…` : clean; +} + +/** True when a URL carries a user name or password (flagged where it is shown). */ +export function hasCredentials(url: string): boolean { + try { const parsed = new URL(url); return Boolean(parsed.username || parsed.password); } catch { return false; } +} + +export type AddressKind = 'https' | 'http' | 'file' | 'blank' | 'other'; + +/** How trustworthy the shown address is, from the real URL. */ +export function addressKind(url: string): AddressKind { + if (url === 'about:blank' || url === '') return 'blank'; + try { + const protocol = new URL(url).protocol; + if (protocol === 'https:') return 'https'; + if (protocol === 'http:') return 'http'; + if (protocol === 'file:') return 'file'; + } catch { /* other */ } + return 'other'; +} + +/** A popup or link target a page asks for: only plain web addresses and about:blank, never credentials. */ +export function popupAddress(url: string): string | undefined { + if (url === 'about:blank') return url; + try { + const parsed = new URL(url); + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) return undefined; + return parsed.href; + } catch { return undefined; } +} diff --git a/src/browser/documentPreview.ts b/src/browser/documentPreview.ts new file mode 100644 index 00000000..76907d9c --- /dev/null +++ b/src/browser/documentPreview.ts @@ -0,0 +1,199 @@ +import type {Key} from '../terminal/keys.js'; +import type {TextOverlay} from '../arcade/Canvas.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 type {PreviewView} from '../viewer/DocumentWorkspace.js'; +import {cellToViewport} from './keymap.js'; +import type {BrowserSession} from './session.js'; +import type {PreviewHandle, PreviewServer} from './types.js'; + +/** + * The document workspace's Chromium preview: the CURRENT buffer of a Markdown or HTML file, rendered by a real browser and shown + * through the graphics service, with only what reading a document needs (scroll, zoom, find, refresh). It is a thin view over a + * one-tab `BrowserSession` of kind `preview`: + * + * - the page is served from memory on a loopback-only server (`PreviewServer`), so unsaved text previews without being saved and + * relative stylesheets, images and fonts resolve from the document's own folder under the server's restrictions; + * - scripts are off unless the person trusts the file; CSS animations and transitions run either way; + * - nothing leaves the preview origin: a clicked link is reported, never followed, and `o` offers to open it in `/browser`; + * - refresh swaps the served HTML and reloads the page (Chromium restores the scroll position of a reload); the editor's buffer + * and undo history are never touched. + */ +export interface PreviewDocument { + name: string; + /** The folder relative references resolve against. */ + root: string; + text: string; + trusted: boolean; +} + +export interface DocumentPreviewDeps { + session: BrowserSession; + previews: PreviewServer; + /** The complete HTML for the buffer's text (a Markdown file is converted, HTML is served as it is). */ + html(text: string): string; + /** Where the panel sits on the screen, to map mouse clicks onto the page. */ + geometry(): {top: number; left: number; cell: {width: number; height: number}; scale: number}; + /** The person asked to open a refused link in the real browser. */ + openLink(url: string): void; + /** The buffer has unsaved changes (for the header). */ + dirty(): boolean; +} + +const RESET = '\u001b[0m'; + +export class BrowserPreviewView implements PreviewView { + private handle: PreviewHandle | undefined; + private area = {columns: 1, rows: 1, row: 0, column: 0}; + private finding: {value: string} | undefined; + private disposed = false; + private document: PreviewDocument; + /** Refreshes and trust changes run one at a time, in the order they were asked for. */ + private queue: Promise = Promise.resolve(); + + private constructor(private readonly deps: DocumentPreviewDeps, document: PreviewDocument) { + this.document = document; + } + + /** Serve the document and open its page; resolves with the view, or the reason a preview cannot be shown. */ + static async open(deps: DocumentPreviewDeps, document: PreviewDocument): Promise { + const view = new BrowserPreviewView(deps, document); + try { + view.handle = await deps.previews.serve({html: deps.html(document.text), root: document.root, trusted: document.trusted, name: document.name}); + const opened = await deps.session.open(view.handle.url, {kind: 'preview', scripts: document.trusted}); + if (!opened.ok) { view.dispose(); return {error: opened.reason}; } + } catch (error) { + view.dispose(); + return {error: error instanceof Error ? error.message : 'The preview could not start.'}; + } + return view; + } + + /** Whether scripts are on for the page being shown (what the page is actually served and run with). */ + get trusted(): boolean { return this.document.trusted; } + + private enqueue(work: () => Promise): Promise { + const next = this.queue.then(work, work); + this.queue = next.catch(() => undefined); + return next; + } + + /** Replace the served page with the buffer's text now and reload it. Never reads or writes the file. */ + refresh(text: string): Promise { + return this.enqueue(async () => { + if (this.disposed || !this.handle) return; + this.document = {...this.document, text}; + this.handle.update(this.deps.html(text)); + await this.deps.session.reload(); + }); + } + + /** Turn scripts on or off for this file: the page is served and loaded again under the new rules. */ + setTrusted(trusted: boolean, text: string): Promise { + return this.enqueue(async () => { + if (this.disposed) return; + // Scripts go off before anything else changes, so a failure part-way never leaves them on. + if (!trusted) await this.deps.session.setScripts(false); + this.document = {...this.document, trusted, text}; + this.handle?.dispose(); + this.handle = await this.deps.previews.serve({html: this.deps.html(text), root: this.document.root, trusted, name: this.document.name}); + if (trusted) await this.deps.session.setScripts(true); + await this.deps.session.navigate(this.handle.url); + }); + } + + problems(): ReadonlyArray<{path: string; reason: string}> { return this.handle?.problems() ?? []; } + capturing(): boolean { return Boolean(this.finding); } + + status(): string | undefined { + const view = this.deps.session.activeView(); + const parts: string[] = []; + if (view?.error) parts.push(view.error); + if (view && view.zoom !== 1) parts.push(`${Math.round(view.zoom * 100)}%`); + if (view?.find) parts.push(view.find.matches ? `${view.find.current}/${view.find.matches} for “${view.find.query}”` : `no match for “${view.find.query}”`); + const notice = this.deps.session.notice(); + if (notice) parts.push(notice); + const missing = this.problems(); + if (missing.length) parts.push(`${missing.length} resource${missing.length === 1 ? '' : 's'} missing or refused (${missing.at(-1)!.path})`); + return parts.join(' · ') || undefined; + } + + render(columns: number, height: number): {rows: string[]; overlays?: TextOverlay[]} { + const subtle = foreground(UI_COLORS.subtle); + const primary = foreground(UI_COLORS.primary); + const warning = foreground(UI_COLORS.warning); + const width = Math.max(10, columns - 4); + const controls = renderControlRows(this.finding + ? [['Enter', 'find'], ['Esc', 'cancel']] + : [['↑↓', 'scroll'], ['/', 'find'], ['+/-', 'zoom'], ['h', 'source'], ['e', 'edit'], ['r', 'refresh'], ['T', this.document.trusted ? 'scripts off' : 'scripts on'], ...(this.deps.session.lastBlocked ? [['o', 'open link'] as const] : []), ['Esc', 'source']], width); + const title = truncateText(this.document.name, Math.max(8, Math.floor(width * 0.5))); + const detail = `rendered by Chromium${this.document.trusted ? ' · scripts on' : ''}`; + const header = ` ${primary}${title}${RESET}${this.deps.dirty() ? `${warning} ● edited${RESET}` : ''} ${subtle}${truncateText(detail, Math.max(1, width - displayWidth(title) - (this.deps.dirty() ? 10 : 0) - 2))}${RESET}`; + const pageRows = Math.max(1, height - 2 - 1 - 1 - controls.length); + // The page fills the panel inside its margins; the session debounces the viewport change and renders the new size. + this.area = {columns: width, rows: pageRows, row: 0, column: 0}; + this.deps.session.resize({columns: width, rows: pageRows}); + const lines = this.deps.session.pageLines(); + const body = Array.from({length: pageRows}, (_, index) => ` ${lines[index] ?? ''}`); + const status = this.finding + ? `${foreground(UI_COLORS.accent)}Find: ${RESET}${truncateText(this.finding.value, Math.max(1, width - 8))}${foreground(UI_COLORS.accent)}▏${RESET}` + : `${subtle}${truncateText(this.status() ?? '', width)}${RESET}`; + const out = [header, ...body, ` ${status}`, ...controls.map(row => ` ${row}`)].slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)); + const rule = framePanel([], columns)[0]!; + this.deps.session.settle(); + return {rows: [...framePanel(out, columns), rule]}; + } + + key(key: Key): void { + const session = this.deps.session; + if (this.finding) { + if (key.kind === 'escape' || key.kind === 'interrupt') { this.finding = undefined; return; } + if (key.kind === 'enter') { const query = this.finding.value; this.finding = undefined; void session.find(query); return; } + const next = editText(this.finding.value, key); + if (next !== undefined) this.finding.value = next.slice(0, 200); + return; + } + const text = key.kind === 'text' ? key.value : undefined; + if (key.kind === 'mouseClick' || key.kind === 'mouseRelease' || key.kind === 'mouseDrag' || key.kind === 'mouseMove') { this.mouse(key); return; } + if (key.kind === 'wheelUp') void session.scroll('line', -1); + else if (key.kind === 'wheelDown') void session.scroll('line', 1); + else if (key.kind === 'up' || text === 'k') void session.scroll('line', -1); + else if (key.kind === 'down' || text === 'j') void session.scroll('line', 1); + else if (key.kind === 'pageUp' || text === 'b') void session.scroll('page', -1); + else if (key.kind === 'pageDown' || text === ' ' || text === 'f') void session.scroll('page', 1); + else if (key.kind === 'bufferHome' || key.kind === 'lineHome' || text === 'g') void session.scroll('top', -1); + else if (key.kind === 'bufferEnd' || key.kind === 'lineEnd' || text === 'G') void session.scroll('bottom', 1); + else if (text === '+' || text === '=') void session.zoomBy(1); + else if (text === '-' || text === '_') void session.zoomBy(-1); + else if (text === '0') void session.setZoom(1); + else if (text === '/' || key.kind === 'find') this.finding = {value: session.activeView()?.find?.query ?? ''}; + else if (text === 'n') void session.findNext(false); + else if (text === 'N') void session.findNext(true); + else if (text === 'o') { const link = session.lastBlocked; if (link) this.deps.openLink(link.url); } + } + + private mouse(key: Key & {x?: number; y?: number}): void { + if (key.kind !== 'mouseClick' && key.kind !== 'mouseRelease' && key.kind !== 'mouseDrag' && key.kind !== 'mouseMove') return; + if (key.x === undefined || key.y === undefined) return; + const geometry = this.deps.geometry(); + // The page starts one row below the panel's header row and two columns in (the margins of render()). + const area = {row: geometry.top + 1, column: geometry.left + 2, columns: this.area.columns, rows: this.area.rows}; + const point = cellToViewport({x: key.x, y: key.y}, {area, cell: geometry.cell, scale: geometry.scale}, {clamp: key.kind === 'mouseRelease'}); + if (!point) return; + const session = this.deps.session; + if (key.kind === 'mouseClick') void session.sendMouse({type: 'down', ...point, button: 'left', clickCount: 1}); + else if (key.kind === 'mouseRelease') void session.sendMouse({type: 'up', ...point, button: 'left', clickCount: 1}); + else void session.sendMouse({type: 'move', ...point}); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.handle?.dispose(); + this.handle = undefined; + this.deps.session.dispose(); + } +} diff --git a/src/browser/engine/cdp.ts b/src/browser/engine/cdp.ts new file mode 100644 index 00000000..abb3437a --- /dev/null +++ b/src/browser/engine/cdp.ts @@ -0,0 +1,159 @@ +import type {Readable, Writable} from 'node:stream'; + +/** + * A DevTools Protocol client over Chrome's `--remote-debugging-pipe`: UTF-8 JSON messages, each followed by one NUL byte, written to + * the child's fd 3 and read from its fd 4. No network port exists. Everything is bounded: one message may not exceed + * `maxMessageBytes` (the connection is torn down instead of buffering without limit), every request has a timeout, and when the pipe + * closes every pending request rejects. + */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; +export const MAX_MESSAGE_BYTES = 32 * 1024 * 1024; + +export class CdpError extends Error { + constructor(message: string, readonly code?: number) { + super(message); + this.name = 'CdpError'; + } +} + +export interface CdpEvent { + method: string; + params: any; + /** Present for events of an attached (flat) session. */ + sessionId?: string; +} + +interface Pending { + method: string; + resolve(value: any): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +export interface CdpOptions { + requestTimeoutMs?: number; + maxMessageBytes?: number; +} + +export class CdpClient { + private nextId = 1; + private readonly pending = new Map(); + private readonly eventListeners = new Set<(event: CdpEvent) => void>(); + private readonly closeListeners = new Set<(reason: string) => void>(); + private chunks: Buffer[] = []; + private buffered = 0; + private closedReason: string | undefined; + private readonly requestTimeoutMs: number; + private readonly maxMessageBytes: number; + + constructor(private readonly output: Writable, private readonly input: Readable, options: CdpOptions = {}) { + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.maxMessageBytes = options.maxMessageBytes ?? MAX_MESSAGE_BYTES; + input.on('data', (chunk: Buffer | string) => this.receive(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)); + input.on('end', () => this.close('The browser closed its connection.')); + input.on('close', () => this.close('The browser closed its connection.')); + input.on('error', error => this.close(`The browser connection failed: ${error.message}`)); + output.on('error', error => this.close(`The browser connection failed: ${error.message}`)); + output.on('close', () => this.close('The browser closed its connection.')); + } + + get closed(): boolean { return this.closedReason !== undefined; } + + send(method: string, params: object = {}, options: {sessionId?: string; timeoutMs?: number} = {}): Promise { + if (this.closedReason !== undefined) return Promise.reject(new CdpError(this.closedReason)); + const id = this.nextId++; + const message: Record = {id, method, params}; + if (options.sessionId) message.sessionId = options.sessionId; + return new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs; + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new CdpError(`The browser did not answer ${method} within ${Math.round(timeoutMs / 1000)} seconds.`)); + }, timeoutMs); + timer.unref(); + this.pending.set(id, {method, resolve, reject, timer}); + try { + this.output.write(`${JSON.stringify(message)}\0`); + } catch (error) { + this.pending.delete(id); + clearTimeout(timer); + reject(new CdpError(error instanceof Error ? error.message : String(error))); + } + }); + } + + onEvent(listener: (event: CdpEvent) => void): () => void { + this.eventListeners.add(listener); + return () => { this.eventListeners.delete(listener); }; + } + + onClose(listener: (reason: string) => void): () => void { + this.closeListeners.add(listener); + return () => { this.closeListeners.delete(listener); }; + } + + /** Tear the connection down; every pending request rejects with `reason`. Safe to call repeatedly. */ + close(reason = 'The browser connection was closed.'): void { + if (this.closedReason !== undefined) return; + this.closedReason = reason; + this.chunks = []; + this.buffered = 0; + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(new CdpError(reason)); + } + this.input.destroy(); + this.output.destroy(); + for (const listener of [...this.closeListeners]) listener(reason); + this.closeListeners.clear(); + this.eventListeners.clear(); + } + + private receive(chunk: Buffer): void { + let start = 0; + while (start <= chunk.length && this.closedReason === undefined) { + const nul = chunk.indexOf(0, start); + const end = nul === -1 ? chunk.length : nul; + if (this.buffered + (end - start) > this.maxMessageBytes) { + this.close(`The browser sent a message larger than ${Math.round(this.maxMessageBytes / 1024 / 1024)} MB.`); + return; + } + if (end > start) { + this.chunks.push(chunk.subarray(start, end)); + this.buffered += end - start; + } + if (nul === -1) return; + const raw = this.chunks.length === 1 ? this.chunks[0]! : Buffer.concat(this.chunks, this.buffered); + this.chunks = []; + this.buffered = 0; + if (raw.length > 0) this.dispatch(raw.toString('utf8')); + start = nul + 1; + } + } + + private dispatch(text: string): void { + let message: any; + try { + message = JSON.parse(text); + } catch { + return; // not ours to interpret; a well-formed peer never sends this + } + if (typeof message !== 'object' || message === null) return; + if (typeof message.id === 'number') { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new CdpError(`${pending.method}: ${String(message.error.message ?? 'failed')}`, typeof message.error.code === 'number' ? message.error.code : undefined)); + else pending.resolve(message.result ?? {}); + return; + } + if (typeof message.method === 'string') { + const event: CdpEvent = {method: message.method, params: message.params ?? {}, ...(typeof message.sessionId === 'string' ? {sessionId: message.sessionId} : {})}; + for (const listener of [...this.eventListeners]) { + try { listener(event); } catch { /* a listener's bug must not break the stream */ } + } + } + } +} diff --git a/src/browser/engine/chrome.ts b/src/browser/engine/chrome.ts new file mode 100644 index 00000000..9490a112 --- /dev/null +++ b/src/browser/engine/chrome.ts @@ -0,0 +1,97 @@ +import {execFile} from 'node:child_process'; +import {constants as fsConstants} from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type {BrowserAvailability} from '../types.js'; + +/** + * Finds an installed Chromium-family browser. Nothing is ever downloaded: if none is found the reason says what to install or set. + * `NMSH_BROWSER` (an absolute path) wins and is not second-guessed: if it is wrong the person is told so rather than silently + * falling back to another browser. + */ +export const MIN_CHROME_MAJOR = 110; +const VERSION_TIMEOUT_MS = 5000; + +const MAC_APPS = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' +]; +const LINUX_NAMES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']; + +export interface LocatorHost { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + /** A regular file the current user may execute. */ + isExecutable(file: string): Promise; + /** The first line `--version` prints. Rejects when it cannot run. */ + version(file: string): Promise; +} + +export const realLocatorHost: LocatorHost = { + platform: process.platform, + env: process.env, + async isExecutable(file) { + try { + if (!(await fs.stat(file)).isFile()) return false; + await fs.access(file, fsConstants.X_OK); + return true; + } catch { + return false; + } + }, + version(file) { + return new Promise((resolve, reject) => { + execFile(file, ['--version'], {timeout: VERSION_TIMEOUT_MS, encoding: 'utf8', env: {PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? ''}, maxBuffer: 64 * 1024, windowsHide: true}, + (error, stdout) => error ? reject(error) : resolve(String(stdout).split('\n')[0]?.trim() ?? '')); + }); + } +}; + +const INSTALL_HINT = 'Install Google Chrome, Chromium or Microsoft Edge, or set NMSH_BROWSER to the full path of its executable.'; + +export async function candidatePaths(host: Pick): Promise { + if (host.platform === 'darwin') return MAC_APPS; + const found: string[] = []; + for (const dir of (host.env.PATH ?? '').split(path.delimiter)) { + if (!path.isAbsolute(dir)) continue; + for (const name of LINUX_NAMES) found.push(path.join(dir, name)); + } + // Keep the preference order of LINUX_NAMES across directories. + return found.sort((a, b) => LINUX_NAMES.indexOf(path.basename(a)) - LINUX_NAMES.indexOf(path.basename(b))); +} + +export function parseChromeMajor(version: string): number | undefined { + const match = /(\d+)\.\d+\.\d+/u.exec(version); + return match ? Number(match[1]) : undefined; +} + +async function check(host: LocatorHost, executable: string): Promise { + let version: string; + try { + version = await host.version(executable); + } catch { + return {ok: false, reason: `${executable} did not answer --version, so NMSh cannot use it as a browser. ${INSTALL_HINT}`}; + } + const major = parseChromeMajor(version); + if (major === undefined) return {ok: false, reason: `${executable} does not look like Chrome or Chromium (it printed "${version.slice(0, 60).replace(/[^\x20-\x7e]/gu, '')}"). ${INSTALL_HINT}`}; + if (major < MIN_CHROME_MAJOR) return {ok: false, reason: `${executable} is version ${major}; NMSh needs version ${MIN_CHROME_MAJOR} or newer. Update it or set NMSH_BROWSER to a newer one.`}; + return {ok: true, executable, version}; +} + +export async function locateBrowser(host: LocatorHost = realLocatorHost, override?: string): Promise { + const configured = override ?? host.env.NMSH_BROWSER; + if (configured) { + if (!path.isAbsolute(configured)) return {ok: false, reason: 'NMSH_BROWSER must be the absolute path of a browser executable.'}; + if (!(await host.isExecutable(configured))) return {ok: false, reason: `NMSH_BROWSER points to ${configured}, which is not an executable file.`}; + return check(host, configured); + } + let rejected: BrowserAvailability | undefined; + for (const candidate of await candidatePaths(host)) { + if (!(await host.isExecutable(candidate))) continue; + const result = await check(host, candidate); + if (result.ok) return result; + rejected ??= result; + } + return rejected ?? {ok: false, reason: `No Chrome, Chromium or Edge was found. ${INSTALL_HINT}`}; +} diff --git a/src/browser/engine/engine.ts b/src/browser/engine/engine.ts new file mode 100644 index 00000000..14943f3e --- /dev/null +++ b/src/browser/engine/engine.ts @@ -0,0 +1,231 @@ +import {spawn, type ChildProcess} from 'node:child_process'; +import fs from 'node:fs'; +import type {Readable, Writable} from 'node:stream'; +import type {BrowserAvailability, BrowserEngine, BrowserPage, BrowserStats, OpenOptions} from '../types.js'; +import {CdpClient} from './cdp.js'; +import {locateBrowser, realLocatorHost, type LocatorHost} from './chrome.js'; +import {ChromePage} from './page.js'; + +/** + * The single Chromium process tree behind every page. It starts on the first `open`, talks over a pipe (no network port), runs with a + * minimal environment and its own profile, and stops when the last page has been closed for `idleMs`, on `shutdown()`, or when NMSh + * exits. If the browser dies on its own every page is closed and the next `open` starts a fresh one. + */ +export interface BrowserEngineOptions { + profileDir: string; + /** How long the browser stays up after its last page closes (default 60 s). */ + idleMs?: number; + /** Use this browser instead of searching (an absolute path). */ + executable?: string; + env?: NodeJS.ProcessEnv; + /** Test seam: how the browser is found. */ + locator?: LocatorHost; +} + +export const DEFAULT_IDLE_MS = 60_000; +const CLOSE_GRACE_MS = 2000; +const STARTUP_TIMEOUT_MS = 30_000; + +export function chromeArguments(profileDir: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string[] { + const args = [ + '--headless=new', '--remote-debugging-pipe', `--user-data-dir=${profileDir}`, + '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-component-update', + '--disable-background-networking', '--disable-sync', '--disable-breakpad', '--disable-default-apps', '--mute-audio', + '--renderer-process-limit=2', '--disable-features=Translate,MediaRouter,OptimizationHints,InterestFeedContentSuggestions', + '--force-color-profile=srgb', '--deny-permission-prompts', + // Never touch the person's keychain / wallet for the throwaway profile's cookie encryption. + platform === 'darwin' ? '--use-mock-keychain' : '--password-store=basic' + ]; + if (env.NMSH_BROWSER_NO_SANDBOX === '1') args.push('--no-sandbox'); + return args; +} + +/** The only environment the browser sees: no tokens, no NMSh variables. */ +export function chromeEnvironment(profileDir: string, source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {HOME: profileDir}; + for (const name of ['PATH', 'LANG', 'TMPDIR'] as const) if (source[name] !== undefined) env[name] = source[name]; + return env; +} + +interface Running { + process: ChildProcess; + cdp: CdpClient; + startedAt: number; + exited: Promise; + stopping: boolean; +} + +export class ChromeEngine implements BrowserEngine { + private readonly env: NodeJS.ProcessEnv; + private readonly idleMs: number; + private readonly locator: LocatorHost; + private readonly open_ = new Map(); + private running: Running | undefined; + private launching: Promise | undefined; + private stopping: Promise | undefined; + private idleTimer: NodeJS.Timeout | undefined; + private found: BrowserAvailability | undefined; + private counter = 0; + private executable: string | undefined; + + constructor(private readonly options: BrowserEngineOptions) { + this.env = options.env ?? process.env; + this.idleMs = options.idleMs ?? DEFAULT_IDLE_MS; + this.locator = options.locator ?? {...realLocatorHost, env: this.env}; + } + + async available(): Promise { + if (this.found?.ok) return this.found; + this.found = await locateBrowser(this.locator, this.options.executable); + return this.found; + } + + pages(): readonly BrowserPage[] { + return [...this.open_.values()]; + } + + stats(): BrowserStats { + const running = this.running && !this.running.stopping ? this.running : undefined; + return { + running: running !== undefined, + pages: this.open_.size, + ...(running?.process.pid !== undefined ? {pid: running.process.pid} : {}), + ...(running ? {startedAt: running.startedAt} : {}), + ...(this.executable ? {executable: this.executable} : {}) + }; + } + + async open(options: OpenOptions): Promise { + this.cancelIdle(); + if (this.stopping) await this.stopping; + const running = await this.start(); + this.cancelIdle(); + const page = await ChromePage.open({ + cdp: running.cdp, + released: released => { + this.open_.delete(released.id); + if (this.open_.size === 0) this.armIdle(); + } + }, `page-${++this.counter}`, options); + if (page.isClosed) throw new Error('The browser closed while the page was opening.'); + this.open_.set(page.id, page); + return page; + } + + private armIdle(): void { + this.cancelIdle(); + if (!this.running) return; + this.idleTimer = setTimeout(() => { void this.shutdown(); }, this.idleMs); + this.idleTimer.unref(); + } + + private cancelIdle(): void { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + + private start(): Promise { + if (this.running && !this.running.stopping) return Promise.resolve(this.running); + this.launching ??= this.launch().finally(() => { this.launching = undefined; }); + return this.launching; + } + + private async launch(): Promise { + const availability = await this.available(); + if (!availability.ok) throw new Error(availability.reason); + const profileDir = this.options.profileDir; + fs.mkdirSync(profileDir, {recursive: true, mode: 0o700}); + try { fs.chmodSync(profileDir, 0o700); } catch { /* not ours to change */ } + + const child = spawn(availability.executable, chromeArguments(profileDir, this.env), { + stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], + env: chromeEnvironment(profileDir, this.env), + windowsHide: true + }); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString('utf8')).slice(-2000); }); + const exited = new Promise(resolve => { child.once('exit', () => resolve()); child.once('error', () => resolve()); }); + const spawnError = new Promise((_, reject) => child.once('error', error => reject(new Error(`The browser could not be started: ${error.message}`)))); + const earlyExit = new Promise((_, reject) => child.once('exit', (code, signal) => reject(new Error( + `The browser stopped right after starting (${signal ?? `exit ${code}`}). ${stderr.trim().split('\n').slice(-2).join(' ').slice(0, 300)}`.trim())))); + spawnError.catch(() => undefined); + earlyExit.catch(() => undefined); + + const cdp = new CdpClient(child.stdio[3] as Writable, child.stdio[4] as Readable); + const running: Running = {process: child, cdp, startedAt: Date.now(), exited, stopping: false}; + const killNow = (): void => { try { child.kill('SIGKILL'); } catch { /* already gone */ } }; + process.on('exit', killNow); + const onGone = (crashed: boolean): void => { + process.off('exit', killNow); + cdp.close('The browser stopped.'); + if (this.running === running) this.running = undefined; + for (const page of [...this.open_.values()]) page.browserGone(crashed && !running.stopping); + this.open_.clear(); + if (this.running === undefined) this.cancelIdle(); + }; + void exited.then(() => onGone(true)); + cdp.onClose(() => { if (!running.stopping) killNow(); }); + // Every new page target starts paused. A window opened by a page (it has an opener) is closed before it can request anything; + // the pages NMSH creates itself are resumed at once. + cdp.onEvent(event => { + if (event.method !== 'Target.attachedToTarget' || event.sessionId !== undefined) return; + const info = event.params.targetInfo; + if (info?.type === 'page' && info.openerId) void cdp.send('Target.closeTarget', {targetId: info.targetId}).catch(() => undefined); + else if (event.params.waitingForDebugger) void cdp.send('Runtime.runIfWaitingForDebugger', {}, {sessionId: event.params.sessionId}).catch(() => undefined); + }); + + try { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('The browser did not start in time.')), STARTUP_TIMEOUT_MS); + timer.unref(); + }); + try { + await Promise.race([ + (async () => { + await cdp.send('Target.setDiscoverTargets', {discover: true}); + await cdp.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{type: 'page'}, {exclude: true}]}); + await cdp.send('Browser.setDownloadBehavior', {behavior: 'deny', eventsEnabled: true}); + })(), + spawnError, earlyExit, timeout + ]); + } finally { + clearTimeout(timer); + } + } catch (error) { + running.stopping = true; + killNow(); + await exited; + throw error; + } + this.running = running; + this.executable = availability.executable; + return running; + } + + shutdown(): Promise { + this.stopping ??= this.stop().finally(() => { this.stopping = undefined; }); + return this.stopping; + } + + private async stop(): Promise { + this.cancelIdle(); + if (this.launching) await this.launching.catch(() => undefined); + const running = this.running; + if (!running) return; + running.stopping = true; + for (const page of [...this.open_.values()]) page.browserGone(false); + this.open_.clear(); + const exitedIn = (ms: number): Promise => new Promise(resolve => { + const timer = setTimeout(() => resolve(false), ms); + timer.unref(); + void running.exited.then(() => { clearTimeout(timer); resolve(true); }); + }); + await running.cdp.send('Browser.close', {}, {timeoutMs: CLOSE_GRACE_MS}).catch(() => undefined); + if (await exitedIn(CLOSE_GRACE_MS)) return; + try { running.process.kill('SIGTERM'); } catch { /* gone */ } + if (await exitedIn(CLOSE_GRACE_MS)) return; + try { running.process.kill('SIGKILL'); } catch { /* gone */ } + await running.exited; + } +} diff --git a/src/browser/engine/index.ts b/src/browser/engine/index.ts new file mode 100644 index 00000000..20d19abd --- /dev/null +++ b/src/browser/engine/index.ts @@ -0,0 +1,14 @@ +import type {BrowserEngine, PreviewServer} from '../types.js'; +import {ChromeEngine} from './engine.js'; +import {createPreviewServer as createServer} from './preview.js'; + +export {htmlDocumentFor, markdownToHtml} from './markdownHtml.js'; +export {locateBrowser} from './chrome.js'; + +export function createBrowserEngine(options: {profileDir: string; idleMs?: number; executable?: string; env?: NodeJS.ProcessEnv}): BrowserEngine { + return new ChromeEngine(options); +} + +export function createPreviewServer(): PreviewServer { + return createServer(); +} diff --git a/src/browser/engine/markdownHtml.ts b/src/browser/engine/markdownHtml.ts new file mode 100644 index 00000000..d4a139f6 --- /dev/null +++ b/src/browser/engine/markdownHtml.ts @@ -0,0 +1,59 @@ +import {marked} from 'marked'; + +/** + * Markdown to a complete HTML document for the preview. GFM is on and raw HTML in the source passes through untouched: the preview + * page is served with a CSP that forbids scripts and remote loads (and the page runs with scripts disabled unless trusted), so + * sanitising here would only change what the author wrote. The stylesheet is built in and typesets like a README; no external + * fonts or resources. Light and dark are CSS variables selected by the `colorScheme` option. + */ +export interface MarkdownOptions { + title: string; + colorScheme: 'light' | 'dark'; +} + +const LIGHT = '--bg:#ffffff;--fg:#1f2328;--muted:#59636e;--link:#0969da;--rule:#d1d9e0;--code-bg:#f2f4f6;--quote:#59636e;--th-bg:#f6f8fa'; +const DARK = '--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--link:#4493f8;--rule:#3d444d;--code-bg:#1a2029;--quote:#9198a1;--th-bg:#151b23'; + +const STYLE = ` +:root{${LIGHT};color-scheme:light} +:root[data-scheme=dark]{${DARK};color-scheme:dark} +html{background:var(--bg)} +body{margin:0 auto;padding:24px 28px 64px;max-width:46rem;background:var(--bg);color:var(--fg);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;overflow-wrap:break-word} +h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.6em 0 .6em;font-weight:600} +h1{font-size:2em;padding-bottom:.3em;border-bottom:1px solid var(--rule)} +h2{font-size:1.5em;padding-bottom:.3em;border-bottom:1px solid var(--rule)} +h3{font-size:1.25em}h4{font-size:1em}h5{font-size:.875em}h6{font-size:.85em;color:var(--muted)} +body>:first-child{margin-top:0} +p,ul,ol,blockquote,pre,table,details{margin:0 0 1em} +a{color:var(--link)} +img,video{max-width:100%;height:auto} +code,pre,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:.875em} +code{background:var(--code-bg);padding:.15em .35em;border-radius:5px} +pre{background:var(--code-bg);padding:14px 16px;border-radius:6px;overflow:auto;line-height:1.45} +pre code{background:none;padding:0;font-size:1em} +blockquote{margin-left:0;padding:0 1em;color:var(--quote);border-left:.25em solid var(--rule)} +hr{border:0;border-top:1px solid var(--rule);margin:1.5em 0} +table{border-collapse:collapse;display:block;overflow:auto;max-width:100%} +th,td{border:1px solid var(--rule);padding:6px 13px} +th{background:var(--th-bg);font-weight:600} +ul,ol{padding-left:2em} +li+li{margin-top:.25em} +li:has(>input[type=checkbox]){list-style:none;margin-left:-1.4em} +li>input[type=checkbox]{margin-right:.5em} +`; + +export function escapeHtml(text: string): string { + return text.replace(/&/gu, '&').replace(//gu, '>').replace(/"/gu, '"').replace(/'/gu, '''); +} + +export function markdownToHtml(markdown: string, options: MarkdownOptions): string { + const body = marked.parse(markdown, {gfm: true, async: false}) as string; + return `\n\n\n\n` + + `\n${escapeHtml(options.title)}\n\n\n\n${body}\n\n`; +} + +/** The one entry point for the preview: HTML is served as written, Markdown is converted. */ +export function htmlDocumentFor(source: string, kind: 'html' | 'markdown', options: Partial = {}): string { + if (kind === 'html') return source; + return markdownToHtml(source, {title: options.title ?? 'Preview', colorScheme: options.colorScheme ?? 'light'}); +} diff --git a/src/browser/engine/page.ts b/src/browser/engine/page.ts new file mode 100644 index 00000000..6c21f7c0 --- /dev/null +++ b/src/browser/engine/page.ts @@ -0,0 +1,672 @@ +import {randomBytes} from 'node:crypto'; +import type {BrowserPage, Frame, KeyEventInput, MouseEventInput, OpenOptions, PageEvent, PageKind, Viewport} from '../types.js'; +import type {CdpClient, CdpEvent} from './cdp.js'; +import {cleanForDisplay} from './preview.js'; + +/** + * One page of the browser: a DevTools target plus the policy NMSh puts around it. Web pages live in the default (persistent) browser + * context and may only be at http, https, file or about:blank; previews each get a throwaway context and may only ever reach their + * own loopback origin (every other request is failed in the network layer and reported as `blocked`). + */ +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 5; +const MAX_URL_DISPLAY = 2000; +const TITLE_POLL_MS = 500; + +export interface PageHost { + cdp: CdpClient; + /** Called once when the page is gone, for the engine's bookkeeping. */ + released(page: ChromePage): void; +} + +export type UrlVerdict = {ok: true; url: string} | {ok: false; reason: string}; + +/** What a web page may navigate to. Everything else (javascript:, data:, chrome:, devtools:, view-source:, blob:, ftp:…) is refused. */ +export function checkWebUrl(address: string): UrlVerdict { + let url: URL; + try { + url = new URL(address); + } catch { + return {ok: false, reason: 'not a valid address'}; + } + if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'file:') return {ok: true, url: url.href}; + if (address.trim().toLowerCase() === 'about:blank') return {ok: true, url: 'about:blank'}; + return {ok: false, reason: `${cleanForDisplay(url.protocol, 30)} addresses are not opened`}; +} + +export function pngSize(png: Uint8Array): {width: number; height: number} { + if (png.length < 24) return {width: 0, height: 0}; + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + return {width: view.getUint32(16), height: view.getUint32(20)}; +} + +const VIRTUAL_KEYS: Readonly> = { + Backspace: 8, Tab: 9, Enter: 13, Escape: 27, ' ': 32, PageUp: 33, PageDown: 34, End: 35, Home: 36, + ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, Insert: 45, Delete: 46 +}; +const EDIT_COMMANDS: Readonly> = {a: 'selectAll', c: 'copy', x: 'cut', v: 'paste', z: 'undo', y: 'redo'}; +const BUTTON_MASK = {left: 1, right: 2, middle: 4} as const; + +export function keyParameters(type: 'down' | 'up' | 'char', event: KeyEventInput): Record { + const modifiers = event.modifiers ?? 0; + const key = event.key; + let virtual = VIRTUAL_KEYS[key]; + if (virtual === undefined && /^F([1-9]|1[0-2])$/u.test(key)) virtual = 111 + Number(key.slice(1)); + if (virtual === undefined && key.length === 1) { + const upper = key.toUpperCase(); + if (/^[A-Z0-9]$/u.test(upper)) virtual = upper.charCodeAt(0); + } + const text = event.text ?? (key.length === 1 ? key : key === 'Enter' ? '\r' : undefined); + const params: Record = {key, modifiers}; + if (event.code) params.code = event.code; + if (virtual !== undefined) params.windowsVirtualKeyCode = virtual; + if (type === 'up') return {...params, type: 'keyUp'}; + if (type === 'char') return {...params, type: 'char', text: event.text ?? (key.length === 1 ? key : '')}; + const shortcut = (modifiers & (2 | 4)) !== 0 && key.length === 1; // Ctrl or Meta: an editing command, not text + if (shortcut) { + const command = EDIT_COMMANDS[key.toLowerCase()]; + return {...params, type: 'rawKeyDown', ...(command ? {commands: [command]} : {})}; + } + const printable = text !== undefined && text !== '' && (modifiers & 1) === 0; + return printable ? {...params, type: 'keyDown', text, unmodifiedText: text} : {...params, type: 'rawKeyDown'}; +} + +const EDITABLE_SCRIPT = (binding: string): string => `(() => { + const send = window[${JSON.stringify(binding)}]; + if (typeof send !== 'function') return; + try { delete window[${JSON.stringify(binding)}]; } catch {} + const NON_TEXT = new Set(['button', 'checkbox', 'radio', 'submit', 'reset', 'image', 'file', 'range', 'color']); + const editable = () => { + let el = document.activeElement; + while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT') return !NON_TEXT.has((el.type || 'text').toLowerCase()); + return tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true; + }; + let last = false; + const report = () => { const now = editable(); if (now !== last) { last = now; send(now ? '1' : '0'); } }; + addEventListener('focusin', report, true); + addEventListener('focusout', () => setTimeout(report, 0), true); +})();`; + +const FIND_FUNCTION = `(query, backwards) => { + const state = globalThis.__nmshFind || (globalThis.__nmshFind = {query: '', index: -1}); + const needle = query.toLowerCase(); + const ranges = []; + if (needle && document.body) { + const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'TEMPLATE']); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {acceptNode(node) { + const parent = node.parentElement; + if (!parent || SKIP.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + if (parent.checkVisibility && !parent.checkVisibility({visibilityProperty: true})) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }}); + for (let node; (node = walker.nextNode()) && ranges.length < 10000;) { + const text = node.nodeValue.toLowerCase(); + for (let at = 0, i; (i = text.indexOf(needle, at)) !== -1; at = i + needle.length) { + const range = document.createRange(); + range.setStart(node, i); + range.setEnd(node, i + needle.length); + ranges.push(range); + } + } + } + const selection = getSelection(); + selection.removeAllRanges(); + if (ranges.length === 0) { state.query = query; state.index = -1; return {matches: 0, current: 0}; } + const index = state.query !== query || state.index < 0 + ? (backwards ? ranges.length - 1 : 0) + : (state.index + (backwards ? -1 : 1) + ranges.length) % ranges.length; + state.query = query; + state.index = index; + selection.addRange(ranges[index]); + const element = ranges[index].startContainer.parentElement; + if (element) element.scrollIntoView({block: 'center', inline: 'nearest'}); + return {matches: ranges.length, current: index + 1}; +}`; + +const CLEAR_FIND = `(() => { const state = globalThis.__nmshFind; if (state) { state.query = ''; state.index = -1; } getSelection().removeAllRanges(); })()`; + +export class ChromePage implements BrowserPage { + readonly id: string; + readonly kind: PageKind; + private _url = 'about:blank'; + private _title = ''; + private _loading = false; + private _canGoBack = false; + private _canGoForward = false; + private _viewport: Viewport; + private _zoom = 1; + private _editable = false; + private scripts: boolean; + private colorScheme: OpenOptions['colorScheme']; + private reducedMotion: boolean | undefined; + + private targetId = ''; + private sessionId = ''; + private contextId: string | undefined; + private mainFrameId = ''; + private allowedOrigin: string | undefined; + private binding = ''; + private findWorld: number | undefined; + private mouseButtons = 0; + private frameSequence = 0; + private screencasting = false; + private delivering = false; + private skipped: number[] = []; + private closing: Promise | undefined; + private done = false; + private readonly frameListeners = new Set<(frame: Frame) => void | Promise>(); + private readonly eventListeners = new Set<(event: PageEvent) => void>(); + private readonly unsubscribe: () => void; + private titleTimer: NodeJS.Timeout | undefined; + private titlePolling = false; + + private constructor(private readonly host: PageHost, id: string, options: OpenOptions) { + this.id = id; + this.kind = options.kind; + this._viewport = {...options.viewport}; + this.scripts = options.scripts ?? options.kind === 'web'; + this.colorScheme = options.colorScheme; + this.reducedMotion = options.reducedMotion; + this.unsubscribe = host.cdp.onEvent(event => this.onCdpEvent(event)); + // `Target.targetInfoChanged` is not sent for every title change (a script setting document.title, a background window), so the + // title is also read directly; one small call twice a second. + this.titleTimer = setInterval(() => { void this.refreshTitle(); }, TITLE_POLL_MS); + this.titleTimer.unref(); + } + + static async open(host: PageHost, id: string, options: OpenOptions): Promise { + const page = new ChromePage(host, id, options); + try { + await page.setup(options); + } catch (error) { + await page.close().catch(() => undefined); + throw error; + } + return page; + } + + get url(): string { return this._url; } + get title(): string { return this._title; } + get loading(): boolean { return this._loading; } + get canGoBack(): boolean { return this._canGoBack; } + get canGoForward(): boolean { return this._canGoForward; } + get viewport(): Viewport { return {...this._viewport}; } + get zoom(): number { return this._zoom; } + get editable(): boolean { return this._editable; } + get isClosed(): boolean { return this.done; } + get target(): string { return this.targetId; } + + // ---- setup ------------------------------------------------------------------------------------------------------------- + + /** + * Right after a navigation commits Chrome briefly answers "Not attached to an active page" while the renderer is swapped; the + * command works a moment later, so it is retried for up to two seconds. + */ + private async send(method: string, params: object = {}): Promise { + for (let attempt = 0;; attempt++) { + try { + return await this.host.cdp.send(method, params, {sessionId: this.sessionId}); + } catch (error) { + if (attempt >= 20 || this.done || !(error instanceof Error) || !/Not attached to an active page/u.test(error.message)) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } + + private async setup(options: OpenOptions): Promise { + const cdp = this.host.cdp; + if (this.kind === 'preview') { + this.contextId = (await cdp.send<{browserContextId: string}>('Target.createBrowserContext', {disposeOnDetach: true})).browserContextId; + await cdp.send('Browser.setDownloadBehavior', {behavior: 'deny', browserContextId: this.contextId, eventsEnabled: true}); + if (options.url) this.allowedOrigin = new URL(options.url).origin; + } + this.targetId = (await cdp.send<{targetId: string}>('Target.createTarget', {url: 'about:blank', newWindow: true, ...(this.contextId ? {browserContextId: this.contextId} : {})})).targetId; + this.sessionId = (await cdp.send<{sessionId: string}>('Target.attachToTarget', {targetId: this.targetId, flatten: true})).sessionId; + this.binding = `__nmsh${randomBytes(8).toString('hex')}`; + await Promise.all([ + this.send('Page.enable'), + this.send('Runtime.enable'), + this.send('Log.enable'), + this.send('Emulation.setFocusEmulationEnabled', {enabled: true}), + this.send('Runtime.addBinding', {name: this.binding}), + this.send('Page.addScriptToEvaluateOnNewDocument', {source: EDITABLE_SCRIPT(this.binding)}), + this.kind === 'preview' ? this.send('Fetch.enable', {patterns: [{urlPattern: '*'}]}) : undefined, + this.applyMetrics(), + this.applyMedia(), + this.scripts ? undefined : this.send('Emulation.setScriptExecutionDisabled', {value: true}) + ]); + const tree = await this.send<{frameTree: {frame: {id: string}}}>('Page.getFrameTree'); + this.mainFrameId = tree.frameTree.frame.id; + if (options.url) await this.navigate(options.url); + } + + private applyMetrics(): Promise { + const {width, height, scale} = this._viewport; + return this.send('Emulation.setDeviceMetricsOverride', { + width: Math.max(1, Math.round(width / this._zoom)), + height: Math.max(1, Math.round(height / this._zoom)), + deviceScaleFactor: scale * this._zoom, + mobile: false + }); + } + + private applyMedia(): Promise { + const features: Array<{name: string; value: string}> = []; + if (this.colorScheme) features.push({name: 'prefers-color-scheme', value: this.colorScheme}); + if (this.reducedMotion !== undefined) features.push({name: 'prefers-reduced-motion', value: this.reducedMotion ? 'reduce' : 'no-preference'}); + return this.send('Emulation.setEmulatedMedia', {media: '', features}); + } + + // ---- events ------------------------------------------------------------------------------------------------------------ + + private emit(event: PageEvent): void { + for (const listener of [...this.eventListeners]) { + try { listener(event); } catch { /* a listener's bug must not break the page */ } + } + } + + onEvent(listener: (event: PageEvent) => void): () => void { + this.eventListeners.add(listener); + return () => { this.eventListeners.delete(listener); }; + } + + private setLoading(loading: boolean): void { + if (this._loading === loading) return; + this._loading = loading; + this.emit({type: 'loading', loading}); + } + + private setEditable(editable: boolean): void { + if (this._editable === editable) return; + this._editable = editable; + this.emit({type: 'editable', editable}); + } + + private setUrl(url: string): void { + this._url = cleanForDisplay(url, MAX_URL_DISPLAY); + this.emit({type: 'navigated', url: this._url}); + } + + private block(url: string, reason: string): void { + this.emit({type: 'blocked', url: cleanForDisplay(url, MAX_URL_DISPLAY), reason: cleanForDisplay(reason, 120)}); + } + + private onCdpEvent(event: CdpEvent): void { + if (this.done) return; + const {method, params} = event; + if (event.sessionId === undefined) { + if (method === 'Target.targetInfoChanged' && params.targetInfo?.targetId === this.targetId) { + this.setTitle(String(params.targetInfo.title ?? '')); + } else if (method === 'Target.targetDestroyed' && params.targetId === this.targetId) { + void this.gone(false); + } else if (method === 'Target.targetCrashed' && params.targetId === this.targetId) { + void this.gone(true); + } else if (method === 'Browser.downloadWillBegin' && params.frameId === this.mainFrameId) { + this.emit({type: 'download-blocked', url: cleanForDisplay(String(params.url ?? ''), MAX_URL_DISPLAY)}); + } + return; + } + if (event.sessionId !== this.sessionId) return; + switch (method) { + case 'Page.frameStartedLoading': + if (params.frameId === this.mainFrameId) this.setLoading(true); + break; + case 'Page.frameStoppedLoading': + if (params.frameId === this.mainFrameId) { + this.setLoading(false); + void this.refreshTitle(); + } + break; + case 'Page.frameNavigated': + if (!params.frame.parentId) this.onMainFrameNavigated(params.frame); + break; + case 'Page.navigatedWithinDocument': + if (params.frameId === this.mainFrameId) { + this.setUrl(String(params.url)); + void this.refreshHistory(); + } + break; + case 'Page.screencastFrame': + void this.onScreencastFrame(params); + break; + case 'Page.javascriptDialogOpening': + this.emit({type: 'dialog', kind: String(params.type), message: cleanForDisplay(String(params.message ?? ''), 500)}); + void this.send('Page.handleJavaScriptDialog', {accept: params.type === 'alert'}).catch(() => undefined); + break; + case 'Page.windowOpen': + this.onWindowOpen(String(params.url ?? '')); + break; + case 'Runtime.bindingCalled': + if (params.name === this.binding) this.setEditable(params.payload === '1'); + break; + case 'Runtime.executionContextsCleared': + this.findWorld = undefined; + this.setEditable(false); + break; + case 'Fetch.requestPaused': + void this.onRequestPaused(params); + break; + case 'Log.entryAdded': + this.onLogEntry(params.entry); + break; + case 'Inspector.targetCrashed': + void this.gone(true); + break; + case 'Inspector.detached': + void this.gone(false); + break; + } + } + + private setTitle(raw: string): void { + const title = cleanForDisplay(raw, 300); + if (title === this._title) return; + this._title = title; + this.emit({type: 'title', title}); + } + + private async refreshTitle(): Promise { + if (this.done || this.titlePolling || !this.targetId) return; + this.titlePolling = true; + try { + const info = await this.host.cdp.send<{targetInfo: {title?: string}}>('Target.getTargetInfo', {targetId: this.targetId}, {timeoutMs: 3000}); + if (!this.done) this.setTitle(String(info.targetInfo.title ?? '')); + } catch { /* the page is going away */ } finally { + this.titlePolling = false; + } + } + + private onMainFrameNavigated(frame: {id: string; url: string; unreachableUrl?: string}): void { + this.mainFrameId = frame.id; + if (frame.url.startsWith('chrome-error:')) { + // Chrome's own page for a failed load: show the address the person asked for. + this.setUrl(frame.unreachableUrl ?? this._url); + } else { + const verdict = this.kind === 'preview' ? this.checkPreviewUrl(frame.url) : checkWebUrl(frame.url); + if (!verdict.ok) { + this.block(frame.url, verdict.reason); + void this.send('Page.navigate', {url: 'about:blank'}).catch(() => undefined); + return; + } + this.setUrl(frame.url); + } + void this.refreshHistory(); + } + + private checkPreviewUrl(address: string): UrlVerdict { + if (address === 'about:blank') return {ok: true, url: address}; + try { + const url = new URL(address); + if (this.allowedOrigin !== undefined && url.origin === this.allowedOrigin) return {ok: true, url: url.href}; + } catch { /* fall through */ } + return {ok: false, reason: 'a preview only shows its own document'}; + } + + private async refreshHistory(): Promise { + try { + const history = await this.send<{currentIndex: number; entries: unknown[]}>('Page.getNavigationHistory'); + this._canGoBack = history.currentIndex > 0; + this._canGoForward = history.currentIndex < history.entries.length - 1; + } catch { /* the page is going away */ } + } + + private onWindowOpen(url: string): void { + const verdict = checkWebUrl(url); + if (this.kind === 'preview') this.block(url, 'new windows are not opened from a preview'); + else if (verdict.ok && url !== 'about:blank') this.emit({type: 'popup', url: verdict.url}); + else this.block(url, verdict.ok ? 'empty window' : verdict.reason); + } + + private async onRequestPaused(params: any): Promise { + const url = String(params.request?.url ?? ''); + let allowed = false; + try { + const parsed = new URL(url); + allowed = parsed.origin === this.allowedOrigin + || (parsed.protocol === 'data:' && (params.resourceType === 'Image' || params.resourceType === 'Font')); + } catch { /* not a URL: refuse */ } + try { + if (allowed) { + await this.send('Fetch.continueRequest', {requestId: params.requestId}); + } else { + this.block(url, 'a preview loads nothing from outside its own folder'); + // A refused main-frame navigation is aborted (the current page stays); anything else fails as blocked. + const navigation = params.resourceType === 'Document' && params.frameId === this.mainFrameId; + await this.send('Fetch.failRequest', {requestId: params.requestId, errorReason: navigation ? 'Aborted' : 'BlockedByClient'}); + } + } catch { /* the request or page is already gone */ } + } + + /** Requests the page's CSP stopped never reach the network layer; Chrome reports them in the log. */ + private onLogEntry(entry: any): void { + if (this.kind !== 'preview' || entry?.source !== 'security') return; + const text = String(entry.text ?? ''); + if (!/Content Security Policy|violates the following/iu.test(text)) return; + const quoted = /'((?:https?|ftp|ws|wss|data|blob):[^']*)'/iu.exec(text); + this.block(quoted?.[1] ?? String(entry.url ?? ''), 'a preview loads nothing from outside its own folder'); + } + + // ---- screencast -------------------------------------------------------------------------------------------------------- + + onFrame(listener: (frame: Frame) => void | Promise): () => void { + this.frameListeners.add(listener); + if (this.frameListeners.size === 1) void this.startScreencast(); + return () => { + this.frameListeners.delete(listener); + if (this.frameListeners.size === 0) void this.stopScreencast(); + }; + } + + private async startScreencast(): Promise { + if (this.screencasting || this.done) return; + this.screencasting = true; + await this.send('Page.startScreencast', {format: 'png', everyNthFrame: 1}).catch(() => { this.screencasting = false; }); + } + + private async stopScreencast(): Promise { + if (!this.screencasting) return; + this.screencasting = false; + await this.send('Page.stopScreencast').catch(() => undefined); + } + + private async onScreencastFrame(params: {data: string; sessionId: number}): Promise { + if (this.frameListeners.size === 0) return this.acknowledge([params.sessionId]); + if (this.delivering) { + // A listener is still busy: this frame is skipped. Its acknowledgement waits until the listener is done, which is what + // keeps Chrome from producing frames faster than they are consumed; nothing is queued. + this.skipped.push(params.sessionId); + return; + } + this.delivering = true; + try { + const frame = await this.frameFor(params.data); + await Promise.allSettled([...this.frameListeners].map(async listener => listener(frame))); + } finally { + this.delivering = false; + const ids = [params.sessionId, ...this.skipped]; + this.skipped = []; + await this.acknowledge(ids); + } + } + + private async acknowledge(ids: number[]): Promise { + if (this.done || !this.screencasting) return; + await Promise.all(ids.map(sessionId => this.send('Page.screencastFrameAck', {sessionId}).catch(() => undefined))); + } + + /** + * A screencast frame is always in CSS pixels. At 1:1 it is the frame; otherwise (a device scale or zoom) the same moment is + * captured again at full resolution so every frame is `viewport * scale` pixels, as the contract promises. + */ + private async frameFor(screencastData: string): Promise { + if (Math.abs(this._viewport.scale * this._zoom - 1) < 0.001) return this.toFrame(screencastData); + return this.capture().catch(() => this.toFrame(screencastData)); + } + + private toFrame(base64: string): Frame { + const png = new Uint8Array(Buffer.from(base64, 'base64')); + return {png, ...pngSize(png), sequence: ++this.frameSequence}; + } + + async capture(): Promise { + const shot = await this.send<{data: string}>('Page.captureScreenshot', {format: 'png', fromSurface: true}); + return this.toFrame(shot.data); + } + + // ---- navigation -------------------------------------------------------------------------------------------------------- + + async navigate(address: string): Promise { + const verdict = this.kind === 'preview' ? this.checkPreviewUrl(address) : checkWebUrl(address); + if (!verdict.ok) { + this.block(address, verdict.reason); + return; + } + const result = await this.send<{errorText?: string}>('Page.navigate', {url: verdict.url}); + void result; // a failed load is shown by Chrome's own error page, which the frames carry + } + + async reload(options: {hard?: boolean} = {}): Promise { + await this.send('Page.reload', {ignoreCache: options.hard === true}); + } + + async back(): Promise { await this.step(-1); } + async forward(): Promise { await this.step(1); } + + private async step(delta: number): Promise { + const history = await this.send<{currentIndex: number; entries: Array<{id: number}>}>('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (entry) await this.send('Page.navigateToHistoryEntry', {entryId: entry.id}); + } + + async stop(): Promise { + await this.send('Page.stopLoading'); + } + + // ---- view -------------------------------------------------------------------------------------------------------------- + + async setViewport(viewport: Viewport): Promise { + this._viewport = {...viewport}; + await this.applyMetrics(); + } + + async setZoom(zoom: number): Promise { + this._zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number.isFinite(zoom) ? zoom : 1)); + await this.applyMetrics(); + } + + async setScripts(enabled: boolean): Promise { + if (this.scripts === enabled) return; + this.scripts = enabled; + this.setEditable(false); + await this.send('Emulation.setScriptExecutionDisabled', {value: !enabled}); + await this.reload(); + } + + // ---- input ------------------------------------------------------------------------------------------------------------- + + async mouse(event: MouseEventInput): Promise { + const x = event.x / this._zoom; + const y = event.y / this._zoom; + const modifiers = event.modifiers ?? 0; + if (event.type === 'wheel') { + await this.send('Input.dispatchMouseEvent', {type: 'mouseWheel', x, y, deltaX: event.deltaX / this._zoom, deltaY: event.deltaY / this._zoom, modifiers}); + return; + } + if (event.type === 'move') { + await this.send('Input.dispatchMouseEvent', {type: 'mouseMoved', x, y, buttons: this.mouseButtons, modifiers}); + return; + } + const mask = BUTTON_MASK[event.button]; + this.mouseButtons = event.type === 'down' ? this.mouseButtons | mask : this.mouseButtons & ~mask; + await this.send('Input.dispatchMouseEvent', { + type: event.type === 'down' ? 'mousePressed' : 'mouseReleased', x, y, button: event.button, + buttons: this.mouseButtons, clickCount: event.clickCount ?? 1, modifiers + }); + } + + async key(type: 'down' | 'up' | 'char', event: KeyEventInput): Promise { + await this.send('Input.dispatchKeyEvent', keyParameters(type, event)); + } + + async insertText(text: string): Promise { + await this.send('Input.insertText', {text}); + } + + // ---- find -------------------------------------------------------------------------------------------------------------- + + /** + * Runs in an isolated world: a DevTools evaluation is not page script, so it works on previews whose scripts are off. It counts + * case-insensitive matches in visible text, selects the next one (the selection is the highlight) and scrolls it to the middle. + */ + private async inFindWorld(expression: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (this.findWorld === undefined) { + this.findWorld = (await this.send<{executionContextId: number}>('Page.createIsolatedWorld', {frameId: this.mainFrameId, worldName: 'nmsh-find'})).executionContextId; + } + try { + const result = await this.send<{result: {value?: unknown}; exceptionDetails?: unknown}>('Runtime.evaluate', {expression, contextId: this.findWorld, returnByValue: true}); + if (result.exceptionDetails) throw new Error('find failed'); + return result.result.value; + } catch (error) { + this.findWorld = undefined; + if (attempt === 1) throw error; + } + } + } + + async find(query: string, options: {backwards?: boolean} = {}): Promise<{matches: number; current: number}> { + if (query === '') { + await this.clearFind(); + return {matches: 0, current: 0}; + } + const value = await this.inFindWorld(`(${FIND_FUNCTION})(${JSON.stringify(query)}, ${options.backwards === true})`); + return {matches: Number(value?.matches) || 0, current: Number(value?.current) || 0}; + } + + async clearFind(): Promise { + await this.inFindWorld(CLEAR_FIND).catch(() => undefined); + } + + // ---- end of life ------------------------------------------------------------------------------------------------------- + + /** The browser or target went away (or crashed) without being asked. */ + private async gone(crashed: boolean): Promise { + if (this.done) return; + if (crashed) this.emit({type: 'crashed'}); + await this.close(); + } + + /** The whole browser is gone: no commands can be sent any more. */ + browserGone(crashed: boolean): void { + if (this.done) return; + if (crashed) this.emit({type: 'crashed'}); + this.finish(); + } + + private finish(): void { + if (this.done) return; + this.done = true; + this.screencasting = false; + this.unsubscribe(); + if (this.titleTimer) clearInterval(this.titleTimer); + this.emit({type: 'closed'}); + this.frameListeners.clear(); + this.eventListeners.clear(); + this.host.released(this); + } + + close(): Promise { + this.closing ??= (async () => { + if (!this.done) { + await this.stopScreencast(); + if (this.targetId) await this.host.cdp.send('Target.closeTarget', {targetId: this.targetId}, {timeoutMs: 3000}).catch(() => undefined); + if (this.contextId) await this.host.cdp.send('Target.disposeBrowserContext', {browserContextId: this.contextId}, {timeoutMs: 3000}).catch(() => undefined); + } + this.finish(); + })(); + return this.closing; + } +} diff --git a/src/browser/engine/preview.ts b/src/browser/engine/preview.ts new file mode 100644 index 00000000..8f15696c --- /dev/null +++ b/src/browser/engine/preview.ts @@ -0,0 +1,244 @@ +import {randomBytes} from 'node:crypto'; +import fs from 'node:fs/promises'; +import {constants as fsConstants} from 'node:fs'; +import http from 'node:http'; +import type {AddressInfo} from 'node:net'; +import path from 'node:path'; +import type {PreviewDocument, PreviewHandle, PreviewServer} from '../types.js'; + +/** + * The loopback server behind a document preview. It serves the editor's HTML from memory at `http://127.0.0.1://` and + * the files beside the document under a strict rule: the path is decoded once and must stay inside the document's folder after + * `realpath`, the file must be a regular file of at most 25 MiB (checked on the descriptor that is then read), and the extension must be + * on an allow-list. Scripts, JSON and modules are served only when the document is trusted. Every response forbids sniffing, caching and + * cross-origin use; HTML gets a CSP that allows nothing remote. Nothing here lists directories or reads outside `root`. + */ +export const MAX_ASSET_BYTES = 25 * 1024 * 1024; +export const MAX_PROBLEMS = 50; +export const MAX_CONNECTIONS = 32; + +const TOKEN_BYTES = 16; +const IMAGE_SRC = "img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'none'"; +const TAIL = "frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"; +export const HTML_CSP = `default-src 'none'; ${IMAGE_SRC}; script-src 'none'; connect-src 'none'; ${TAIL}`; +export const TRUSTED_HTML_CSP = `default-src 'none'; ${IMAGE_SRC}; script-src 'self' 'unsafe-inline'; connect-src 'self'; ${TAIL}`; + +type Kind = 'plain' | 'html' | 'svg' | 'script'; +const TYPES: Readonly> = { + css: ['text/css; charset=utf-8', 'plain'], + png: ['image/png', 'plain'], + jpg: ['image/jpeg', 'plain'], + jpeg: ['image/jpeg', 'plain'], + gif: ['image/gif', 'plain'], + webp: ['image/webp', 'plain'], + avif: ['image/avif', 'plain'], + ico: ['image/x-icon', 'plain'], + svg: ['image/svg+xml', 'svg'], + woff: ['font/woff', 'plain'], + woff2: ['font/woff2', 'plain'], + ttf: ['font/ttf', 'plain'], + otf: ['font/otf', 'plain'], + html: ['text/html; charset=utf-8', 'html'], + htm: ['text/html; charset=utf-8', 'html'], + js: ['text/javascript; charset=utf-8', 'script'], + mjs: ['text/javascript; charset=utf-8', 'script'], + json: ['application/json; charset=utf-8', 'script'] +}; + +/** Strip C0, DEL, C1 and bidi controls so a hostile path cannot repaint the person's screen, and bound the length. */ +export function cleanForDisplay(text: string, max = 200): string { + return text.replace(/[\u0000-\u001f\u007f-\u009f؜‎‏‪-‮⁦-⁩]/gu, '').slice(0, max); +} + +interface Entry { + document: PreviewDocument; + html: string; + problems: Array<{path: string; reason: string}>; +} + +class Refusal extends Error { + constructor(readonly status: number, message: string, readonly record = true) { + super(message); + } +} + +function extensionOf(file: string): string { + const base = path.basename(file); + const dot = base.lastIndexOf('.'); + return dot <= 0 ? '' : base.slice(dot + 1).toLowerCase(); +} + +function inside(root: string, file: string): boolean { + return file === root || file.startsWith(root.endsWith(path.sep) ? root : root + path.sep); +} + +export function createPreviewServer(): PreviewServer { + const entries = new Map(); + let server: http.Server | undefined; + let starting: Promise | undefined; + let port = 0; + + const record = (entry: Entry, asset: string, reason: string): void => { + entry.problems.push({path: cleanForDisplay(asset), reason: cleanForDisplay(reason, 120)}); + if (entry.problems.length > MAX_PROBLEMS) entry.problems.splice(0, entry.problems.length - MAX_PROBLEMS); + }; + + const baseHeaders = (): http.OutgoingHttpHeaders => ({ + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'Cross-Origin-Resource-Policy': 'same-origin' + }); + + const fail = (res: http.ServerResponse, status: number, message: string, extra: http.OutgoingHttpHeaders = {}): void => { + const body = `${message}\n`; + res.writeHead(status, {...baseHeaders(), 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body), ...extra}); + res.end(body); + }; + + /** Resolve a decoded asset path to an open descriptor on a vetted regular file. */ + const openAsset = async (entry: Entry, segments: string[]): Promise<{handle: fs.FileHandle; size: number; type: readonly [string, Kind]}> => { + const requested = segments.join('/'); + const type = TYPES[extensionOf(requested)]; + if (!type) throw new Refusal(403, 'this kind of file is not served'); + if (type[1] === 'script' && !entry.document.trusted) throw new Refusal(403, 'scripts are off'); + let realRoot: string; + let real: string; + try { + realRoot = await fs.realpath(entry.document.root); + real = await fs.realpath(path.join(realRoot, ...segments)); + } catch { + throw new Refusal(404, 'not found'); + } + if (!inside(realRoot, real) || real === realRoot) throw new Refusal(403, 'outside the document folder'); + const relative = path.relative(realRoot, real).split(path.sep); + if (relative.some(part => part.startsWith('.'))) throw new Refusal(403, 'hidden files are not served'); + const realType = TYPES[extensionOf(real)]; + if (!realType || (realType[1] === 'script' && !entry.document.trusted)) throw new Refusal(403, realType ? 'scripts are off' : 'this kind of file is not served'); + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(real, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | (fsConstants.O_NOFOLLOW ?? 0)); + const stat = await handle.stat(); + if (!stat.isFile()) throw new Refusal(403, 'not a regular file'); + if (stat.size > MAX_ASSET_BYTES) throw new Refusal(403, 'file is too large'); + return {handle, size: stat.size, type: realType}; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof Refusal) throw error; + throw new Refusal(404, 'not found'); + } + }; + + const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise => { + if (req.method !== 'GET' && req.method !== 'HEAD') return fail(res, 405, 'method not allowed', {Allow: 'GET, HEAD'}); + const host = (req.headers.host ?? '').toLowerCase(); + if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return fail(res, 421, 'misdirected request'); + if (req.headers['sec-fetch-site'] === 'cross-site') return fail(res, 403, 'cross-site requests are not served'); + const rawPath = (req.url ?? '').split(/[?#]/u, 1)[0] ?? ''; + if (!rawPath.startsWith('/')) return fail(res, 400, 'bad request'); + const rest = rawPath.slice(1); + const slash = rest.indexOf('/'); + const token = slash === -1 ? rest : rest.slice(0, slash); + const entry = entries.get(token); + if (!entry) return fail(res, 404, 'not found'); + if (slash === -1) { + res.writeHead(308, {...baseHeaders(), Location: `/${token}/`}); + return void res.end(); + } + const tail = rest.slice(slash + 1); + const head = req.method === 'HEAD'; + + if (tail === '') { + const body = Buffer.from(entry.html, 'utf8'); + res.writeHead(200, {...baseHeaders(), 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': body.length, + 'Content-Security-Policy': entry.document.trusted ? TRUSTED_HTML_CSP : HTML_CSP}); + return void res.end(head ? undefined : body); + } + + let shown = tail; + try { + let decoded: string; + try { + decoded = decodeURIComponent(tail); // exactly once + } catch { + throw new Refusal(400, 'bad path'); + } + shown = decoded; + if (decoded.includes('\0') || decoded.includes('\\')) throw new Refusal(400, 'bad path'); + const segments = decoded.split('/'); + if (segments.some(part => part === '' || part === '.' || part === '..')) throw new Refusal(403, 'path is not allowed'); + if (segments.some(part => part.startsWith('.'))) throw new Refusal(403, 'hidden files are not served'); + const {handle: file, size, type} = await openAsset(entry, segments); + const [contentType, kind] = type; + const headers: http.OutgoingHttpHeaders = {...baseHeaders(), 'Content-Type': contentType, 'Content-Length': size}; + if (kind === 'html') headers['Content-Security-Policy'] = entry.document.trusted ? TRUSTED_HTML_CSP : HTML_CSP; + if (kind === 'svg') { + headers['Content-Security-Policy'] = 'sandbox'; + headers['Content-Disposition'] = 'inline'; + } + res.writeHead(200, headers); + if (head || size === 0) { + await file.close(); + return void res.end(); + } + const stream = file.createReadStream({start: 0, end: size - 1}); + stream.on('error', () => res.destroy()); + res.on('close', () => stream.destroy()); + stream.pipe(res); + } catch (error) { + const refusal = error instanceof Refusal ? error : new Refusal(500, 'error'); + record(entry, shown, refusal.message); + fail(res, refusal.status, refusal.message); + } + }; + + const start = (): Promise => { + starting ??= new Promise((resolve, reject) => { + const created = http.createServer((req, res) => { + handle(req, res).catch(() => { + if (!res.headersSent) fail(res, 500, 'error'); + else res.destroy(); + }); + }); + created.maxConnections = MAX_CONNECTIONS; + created.headersTimeout = 10_000; + created.requestTimeout = 15_000; + created.keepAliveTimeout = 2_000; + created.on('clientError', (_error, socket) => socket.destroy()); + created.once('error', error => { starting = undefined; reject(error); }); + created.listen(0, '127.0.0.1', () => { + server = created; + port = (created.address() as AddressInfo).port; + created.unref(); + resolve(port); + }); + }); + return starting; + }; + + return { + async serve(document) { + const listening = await start(); + const token = randomBytes(TOKEN_BYTES).toString('hex'); + const entry: Entry = {document: {...document}, html: document.html, problems: []}; + entries.set(token, entry); + return { + url: `http://127.0.0.1:${listening}/${token}/`, + update(html: string) { entry.html = html; }, + problems: () => entry.problems.map(problem => ({...problem})), + dispose() { entries.delete(token); } + } satisfies PreviewHandle; + }, + async close() { + entries.clear(); + const closing = server; + server = undefined; + starting = undefined; + if (!closing) return; + await new Promise(resolve => { + closing.close(() => resolve()); + closing.closeAllConnections(); + }); + } + }; +} diff --git a/src/browser/host.ts b/src/browser/host.ts new file mode 100644 index 00000000..224c4b41 --- /dev/null +++ b/src/browser/host.ts @@ -0,0 +1,71 @@ +import type {BrowserAvailability, BrowserEngine, PreviewServer} from './types.js'; + +/** + * NMSh's connection to the real browser, created once and used by every browser surface (the `/browser` panel and the document + * workspace's rendered preview). Nothing browser-related is imported, started or allocated until a surface asks: + * + * - the engine module (and `marked`, which turns Markdown into the preview's HTML) is loaded with a dynamic import; + * - `availability()` only looks for an installed Chrome or Chromium and checks its version; + * - the Chromium process starts on the first `open` and stops by itself when idle; the preview server listens on a loopback + * port only from the first preview and stops with `shutdown()`. + * With no browser installed, or the setting off, none of this ever runs and the rest of NMSh is unaffected. + */ +export interface BrowserHostOptions { + /** A private directory for the browser's throwaway profile (created on demand). */ + profileDir: string; + /** Milliseconds the browser stays running after its last page closed. */ + idleMs: number; + env?: NodeJS.ProcessEnv; +} + +type EngineModule = typeof import('./engine/index.js'); + +export class BrowserHost { + private module: Promise | undefined; + private loaded: EngineModule | undefined; + private browser: BrowserEngine | undefined; + private server: PreviewServer | undefined; + private available: Promise | undefined; + + constructor(private readonly options: BrowserHostOptions, private readonly load: () => Promise = () => import('./engine/index.js')) {} + + private async engineModule(): Promise { + this.module ??= this.load().then(loaded => { this.loaded = loaded; return loaded; }); + return this.module; + } + + /** Is a usable Chrome or Chromium installed? Remembered for the life of the host. */ + async availability(): Promise { + this.available ??= this.engineModule().then(module => module.locateBrowser(undefined, this.options.env?.NMSH_BROWSER)); + return this.available; + } + + async engine(): Promise { + const module = await this.engineModule(); + this.browser ??= module.createBrowserEngine({profileDir: this.options.profileDir, idleMs: this.options.idleMs, ...(this.options.env ? {env: this.options.env} : {})}); + return this.browser; + } + + async previews(): Promise { + const module = await this.engineModule(); + this.server ??= module.createPreviewServer(); + return this.server; + } + + /** The complete HTML for a document's text. Only valid after `engine()` or `previews()` resolved. */ + html(text: string, kind: 'html' | 'markdown', options: {title: string; colorScheme: 'light' | 'dark'}): string { + if (!this.loaded) throw new Error('The browser module is not loaded.'); + return this.loaded.htmlDocumentFor(text, kind, options); + } + + get running(): boolean { return Boolean(this.browser?.stats().running); } + + /** Stop the browser and the preview server now (NMSh is exiting, or the person turned the browser off). */ + async shutdown(): Promise { + const browser = this.browser; + const server = this.server; + this.browser = undefined; + this.server = undefined; + await Promise.allSettled([browser?.shutdown(), server?.close()]); + } +} diff --git a/src/browser/keymap.ts b/src/browser/keymap.ts new file mode 100644 index 00000000..955629ef --- /dev/null +++ b/src/browser/keymap.ts @@ -0,0 +1,122 @@ +import type {Key} from '../terminal/keys.js'; +import type {KeyEventInput} from './types.js'; + +/** + * What a page gets for a key the person typed while the page has focus. NMSh's `Key` is already decoded from terminal bytes, + * so only what the decoder can tell apart can be forwarded: it reports Ctrl+letter as the editing action that byte stands for + * (Ctrl+A arrives as "line home"), which is mapped back to the letter; Ctrl+C never arrives here (it closes the browser). + */ +export const ALT = 1; +export const CTRL = 2; +export const META = 4; +export const SHIFT = 8; + +const UNSHIFTED: Readonly> = { + '-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', ';': 'Semicolon', '\'': 'Quote', + ',': 'Comma', '.': 'Period', '/': 'Slash', '`': 'Backquote', ' ': 'Space', +}; +/** US layout: the shifted symbol and the key it sits on. */ +const SHIFTED: Readonly> = { + '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', '(': 'Digit9', ')': 'Digit0', + '_': 'Minus', '+': 'Equal', '{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash', ':': 'Semicolon', '"': 'Quote', + '<': 'Comma', '>': 'Period', '?': 'Slash', '~': 'Backquote', +}; + +/** The Ctrl+letter each decoded kind stands for. */ +const CTRL_LETTERS: Readonly> = { + deleteWord: 'w', deleteLineBefore: 'u', deleteLineAfter: 'k', historySearch: 'r', find: 'f', toggleDetails: 'o', queueCommand: 'q', + sendToProgram: 's', redo: 'y', suggestNext: 'n', suggestPrevious: 'p', historyDelete: 'x', latest: 'g', eof: 'd', suspend: 'z', +}; + +const NAMED: Readonly> = { + enter: {key: 'Enter', code: 'Enter', text: '\r'}, + newline: {key: 'Enter', code: 'Enter', text: '\r', modifiers: SHIFT}, + complete: {key: 'Tab', code: 'Tab'}, + focusPrevious: {key: 'Tab', code: 'Tab', modifiers: SHIFT}, + backspace: {key: 'Backspace', code: 'Backspace'}, + delete: {key: 'Delete', code: 'Delete'}, + escape: {key: 'Escape', code: 'Escape'}, + left: {key: 'ArrowLeft', code: 'ArrowLeft'}, right: {key: 'ArrowRight', code: 'ArrowRight'}, + up: {key: 'ArrowUp', code: 'ArrowUp'}, down: {key: 'ArrowDown', code: 'ArrowDown'}, + lineHome: {key: 'Home', code: 'Home'}, lineEnd: {key: 'End', code: 'End'}, + pageUp: {key: 'PageUp', code: 'PageUp'}, pageDown: {key: 'PageDown', code: 'PageDown'}, + selectLeft: {key: 'ArrowLeft', code: 'ArrowLeft', modifiers: SHIFT}, selectRight: {key: 'ArrowRight', code: 'ArrowRight', modifiers: SHIFT}, + selectUp: {key: 'ArrowUp', code: 'ArrowUp', modifiers: SHIFT}, selectDown: {key: 'ArrowDown', code: 'ArrowDown', modifiers: SHIFT}, + selectLineHome: {key: 'Home', code: 'Home', modifiers: SHIFT}, selectLineEnd: {key: 'End', code: 'End', modifiers: SHIFT}, + wordLeft: {key: 'ArrowLeft', code: 'ArrowLeft', modifiers: ALT}, wordRight: {key: 'ArrowRight', code: 'ArrowRight', modifiers: ALT}, + selectWordLeft: {key: 'ArrowLeft', code: 'ArrowLeft', modifiers: ALT | SHIFT}, selectWordRight: {key: 'ArrowRight', code: 'ArrowRight', modifiers: ALT | SHIFT}, + bufferHome: {key: 'Home', code: 'Home', modifiers: CTRL}, bufferEnd: {key: 'End', code: 'End', modifiers: CTRL}, + selectBufferHome: {key: 'Home', code: 'Home', modifiers: CTRL | SHIFT}, selectBufferEnd: {key: 'End', code: 'End', modifiers: CTRL | SHIFT}, + selectAll: {key: 'a', code: 'KeyA', modifiers: META}, + palette: {key: 'F1', code: 'F1'}, + previousFailure: {key: 'F8', code: 'F8'}, nextFailure: {key: 'F8', code: 'F8', modifiers: SHIFT}, +}; + +/** The key event for a decoded key, or undefined when the key is not a keystroke (mouse, focus, paste, interrupt). */ +export function keyToInput(key: Key): KeyEventInput | undefined { + if (key.kind === 'text') return textInput(key.value); + const letter = CTRL_LETTERS[key.kind]; + if (letter) return {key: letter, code: `Key${letter.toUpperCase()}`, modifiers: CTRL}; + const named = NAMED[key.kind]; + return named ? {...named} : undefined; +} + +function textInput(value: string): KeyEventInput | undefined { + if (!value || [...value].length !== 1) return value ? {key: value, text: value} : undefined; + if (/^[a-z]$/u.test(value)) return {key: value, code: `Key${value.toUpperCase()}`, text: value}; + if (/^[A-Z]$/u.test(value)) return {key: value, code: `Key${value}`, text: value, modifiers: SHIFT}; + if (/^[0-9]$/u.test(value)) return {key: value, code: `Digit${value}`, text: value}; + const plain = UNSHIFTED[value]; + if (plain) return {key: value, code: plain, text: value}; + const shifted = SHIFTED[value]; + if (shifted) return {key: value, code: shifted, text: value, modifiers: SHIFT}; + return {key: value, text: value}; +} + +// ---- mouse ---------------------------------------------------------------------------------------------------------- + +export interface CellPoint { + /** 1-based screen column and row, as the terminal reports them. */ + x: number; + y: number; +} + +export interface MouseGeometry { + /** The page area on screen: 1-based column and row of its top-left cell, and its size in cells. */ + area: {row: number; column: number; columns: number; rows: number}; + /** Terminal pixels per cell (the graphics decision's). */ + cell: {width: number; height: number}; + /** Device pixel ratio of the frames: a frame pixel is `scale` per CSS pixel, so CSS pixels = terminal pixels / scale. */ + scale: number; + /** The page's viewport in CSS pixels, to keep a point inside it. */ + viewport?: {width: number; height: number}; +} + +/** True when a cell lies inside the page area. */ +export function insideArea(cell: CellPoint, area: MouseGeometry['area']): boolean { + return cell.x >= area.column && cell.x < area.column + area.columns && cell.y >= area.row && cell.y < area.row + area.rows; +} + +/** + * The CSS-pixel point at the middle of a terminal cell, relative to the page's top-left, or undefined outside the page area + * (a click on the browser's own rows is never forwarded). `clamp` keeps a point that left the area on its nearest edge, for a + * button release after a drag. + */ +export function cellToViewport(cell: CellPoint, geometry: MouseGeometry, options: {clamp?: boolean} = {}): {x: number; y: number} | undefined { + const {area} = geometry; + let x = cell.x; + let y = cell.y; + if (!insideArea(cell, area)) { + if (!options.clamp) return undefined; + x = Math.min(area.column + area.columns - 1, Math.max(area.column, x)); + y = Math.min(area.row + area.rows - 1, Math.max(area.row, y)); + } + const scale = geometry.scale > 0 ? geometry.scale : 1; + let px = ((x - area.column) + 0.5) * geometry.cell.width / scale; + let py = ((y - area.row) + 0.5) * geometry.cell.height / scale; + if (geometry.viewport) { + px = Math.min(Math.max(0, geometry.viewport.width - 1), px); + py = Math.min(Math.max(0, geometry.viewport.height - 1), py); + } + return {x: Math.round(px * 100) / 100, y: Math.round(py * 100) / 100}; +} diff --git a/src/browser/session.ts b/src/browser/session.ts new file mode 100644 index 00000000..41c58e20 --- /dev/null +++ b/src/browser/session.ts @@ -0,0 +1,562 @@ +import type {FrameStream} from '../graphics/stream.js'; +import {plainText, popupAddress} from './address.js'; +import type {BrowserEngine, BrowserPage, Frame, KeyEventInput, MouseEventInput, PageEvent, PageKind, Viewport} from './types.js'; + +/** + * The tabs of one /browser panel, without any drawing. It owns the pages the engine opened, one `FrameStream` per tab, the + * latest frame of each, and everything the page tells us (title, address, loading, notices). The panel only reads `view()` and + * calls the methods here; tests drive it with a fake engine. + * + * Bounded by construction: at most MAX_TABS tabs, one notice, one pending frame per tab, one change notification per 33 ms + * however many frames arrive, and a listener that cannot keep up simply sees frames skipped (the engine's rule). + */ +export const MAX_TABS = 12; +export const ZOOM_STEPS: readonly number[] = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; +export const CHANGE_INTERVAL_MS = 33; +const RESIZE_DEBOUNCE_MS = 150; +const NOTICE_MS = 5000; +const BACKGROUND_FRAME_MS = 500; + +export interface SessionClock { + now(): number; + setTimeout(callback: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +const REAL_CLOCK: SessionClock = { + now: () => Date.now(), + setTimeout: (callback, ms) => { const handle = setTimeout(callback, ms); handle.unref?.(); return handle; }, + clearTimeout: handle => clearTimeout(handle as NodeJS.Timeout), +}; + +export interface SessionOptions { + engine: BrowserEngine; + /** One stream per tab (`GraphicsService.openStream`). */ + openStream(owner: unknown): FrameStream; + /** Terminal pixels per cell, from the graphics decision. */ + cell(): {width: number; height: number}; + /** Device pixel ratio of the page frames; `auto` follows the cell height (a Retina cell of 32 px gives 2). */ + scale?: number | 'auto'; + colorScheme?: 'light' | 'dark'; + reducedMotion?: boolean; + clock?: SessionClock; +} + +export interface FindState { + query: string; + matches: number; + current: number; +} + +export interface TabView { + id: number; + title: string; + url: string; + loading: boolean; + /** The tab cannot show a page: it crashed, closed or never opened. */ + error?: string; + editable: boolean; + canGoBack: boolean; + canGoForward: boolean; + zoom: number; + find?: FindState; +} + +export type OpenResult = {ok: true} | {ok: false; reason: string}; + +interface Tab { + id: number; + /** `preview` tabs are document previews (local only, scripts off unless trusted); the default is a web page. */ + kind?: PageKind; + scripts?: boolean; + page?: BrowserPage; + stream: FrameStream; + lines: string[]; + linesBox?: {columns: number; rows: number}; + frame?: Frame; + title: string; + url: string; + loading: boolean; + editable: boolean; + error?: string; + zoom: number; + find?: FindState; + unsubscribe: Array<() => void>; + retry?: unknown; + closed: boolean; +} + +export class BrowserSession { + private tabs_: Tab[] = []; + private active_ = 0; + private nextId = 1; + private area = {columns: 80, rows: 24}; + private cellSize: {width: number; height: number}; + private resizeTimer: unknown; + private lastBlocked_: {url: string; reason: string} | undefined; + private changeTimer: unknown; + private lastChange = -Infinity; + private listeners = new Set<() => void>(); + private noticeText: {text: string; at: number} | undefined; + private disposed = false; + private readonly clock: SessionClock; + + constructor(private readonly options: SessionOptions) { + this.clock = options.clock ?? REAL_CLOCK; + this.cellSize = options.cell(); + } + + // ---- reading --------------------------------------------------------------------------------------------------- + + get count(): number { return this.tabs_.length; } + /** The address of the last link or request a preview refused, for "open this in /browser". */ + get lastBlocked(): {url: string; reason: string} | undefined { return this.lastBlocked_; } + get activeIndex(): number { return this.active_; } + + view(): TabView[] { return this.tabs_.map(viewOf); } + + activeView(): TabView | undefined { + const tab = this.tabs_[this.active_]; + return tab ? viewOf(tab) : undefined; + } + + /** The page-area rows of the active tab, exactly as many as the last `resize` asked for. */ + pageLines(): string[] { + const tab = this.tabs_[this.active_]; + const {columns, rows} = this.area; + if (tab && tab.lines.length === rows && tab.linesBox?.columns === columns) return tab.lines; + return Array.from({length: rows}, () => ' '.repeat(columns)); + } + + /** The active page's viewport in CSS pixels, for keeping a mouse point inside it. */ + activeViewport(): {width: number; height: number} | undefined { + const viewport = this.tabs_[this.active_]?.page?.viewport; + return viewport ? {width: viewport.width, height: viewport.height} : undefined; + } + + /** True once the active tab has drawn a picture. */ + hasPicture(): boolean { + const tab = this.tabs_[this.active_]; + return Boolean(tab && tab.lines.length && tab.linesBox); + } + + notice(): string | undefined { + if (!this.noticeText) return undefined; + if (this.clock.now() - this.noticeText.at > NOTICE_MS) { this.noticeText = undefined; return undefined; } + return this.noticeText.text; + } + + setNotice(text: string): void { + this.noticeText = {text: plainText(text, 160), at: this.clock.now()}; + this.touch(); + } + + /** The page area's viewport in CSS pixels at the current size and cell. */ + viewport(): Viewport { + const scale = this.scale(); + return {width: Math.max(1, Math.round(this.area.columns * this.cellSize.width / scale)), height: Math.max(1, Math.round(this.area.rows * this.cellSize.height / scale)), scale}; + } + + private scale(): number { + const set = this.options.scale; + if (typeof set === 'number' && set > 0) return set; + return Math.max(1, Math.min(3, Math.round(this.cellSize.height / 16))); + } + + /** Changes are announced at most every 33 ms, however many frames and events arrive. */ + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + // ---- tabs ------------------------------------------------------------------------------------------------------ + + async open(url = 'about:blank', options: {background?: boolean; kind?: PageKind; scripts?: boolean} = {}): Promise { + if (this.disposed) return {ok: false, reason: 'The browser is closed.'}; + if (this.tabs_.length >= MAX_TABS) { + const reason = `${MAX_TABS} tabs are open. Close one first.`; + this.setNotice(reason); + return {ok: false, reason}; + } + const tab = this.makeTab(url); + if (options.kind) tab.kind = options.kind; + if (options.scripts !== undefined) tab.scripts = options.scripts; + this.tabs_.push(tab); + if (!options.background) this.activateTab(tab); + this.touch(); + return this.attach(tab, url); + } + + newTab(url = 'about:blank'): Promise { return this.open(url); } + + private makeTab(url: string): Tab { + return {id: this.nextId++, stream: this.options.openStream({}), lines: [], title: '', url, loading: true, editable: false, zoom: 1, unsubscribe: [], closed: false}; + } + + private async attach(tab: Tab, url: string): Promise { + let page: BrowserPage; + try { + page = await this.options.engine.open({ + kind: tab.kind ?? 'web', url, viewport: this.viewport(), + ...(tab.scripts !== undefined ? {scripts: tab.scripts} : {}), + ...(this.options.colorScheme ? {colorScheme: this.options.colorScheme} : {}), + ...(this.options.reducedMotion !== undefined ? {reducedMotion: this.options.reducedMotion} : {}), + }); + } catch (error) { + const reason = plainText(error instanceof Error ? error.message : 'The browser could not start.', 160); + if (!tab.closed) { tab.loading = false; tab.error = reason; this.touch(); } + this.setNotice(reason); + return {ok: false, reason}; + } + if (tab.closed || this.disposed) { void page.close().catch(() => undefined); return {ok: false, reason: 'The tab was closed.'}; } + tab.page = page; + tab.url = page.url || url; + tab.title = page.title; + tab.zoom = page.zoom; + tab.loading = page.loading; + delete tab.error; + tab.unsubscribe.push(page.onEvent(event => this.event(tab, event))); + tab.unsubscribe.push(page.onFrame(frame => this.frame(tab, frame))); + this.touch(); + return {ok: true}; + } + + closeTab(index = this.active_): void { + const tab = this.tabs_[index]; + if (!tab) return; + this.tabs_.splice(index, 1); + this.retire(tab); + if (index < this.active_ || this.active_ >= this.tabs_.length) this.active_ = Math.max(0, this.active_ - 1); + const now = this.tabs_[this.active_]; + if (now) this.show(now); + this.touch(); + } + + activate(index: number): void { + const tab = this.tabs_[index]; + if (!tab || index === this.active_) return; + this.activateTab(tab); + this.touch(); + } + + /** Step to the next (+1) or previous (-1) tab, wrapping. */ + cycle(delta: number): void { + if (this.tabs_.length < 2) return; + this.activate((this.active_ + delta + this.tabs_.length) % this.tabs_.length); + } + + private activateTab(tab: Tab): void { + const was = this.tabs_[this.active_]; + if (was && was !== tab) was.stream.release(); + this.active_ = this.tabs_.indexOf(tab); + this.show(tab); + } + + /** Draw the tab's latest frame into the current area (a switch, a close, a resize). */ + private show(tab: Tab): void { + if (!tab.frame) { tab.lines = []; delete tab.linesBox; return; } + this.draw(tab, tab.frame); + // The viewport may have changed while the tab was hidden. + if (tab.page && this.keyOf(tab.page.viewport) !== this.keyOf(this.viewport())) this.scheduleViewport(0); + } + + private retire(tab: Tab): void { + tab.closed = true; + for (const off of tab.unsubscribe.splice(0)) { try { off(); } catch { /* already gone */ } } + if (tab.retry !== undefined) { this.clock.clearTimeout(tab.retry); tab.retry = undefined; } + tab.stream.dispose(); + const page = tab.page; + tab.page = undefined; + if (page) void page.close().catch(() => undefined); + } + + // ---- frames and events ----------------------------------------------------------------------------------------- + + private frame(tab: Tab, frame: Frame): void | Promise { + if (tab.closed || this.disposed) return; + tab.frame = frame; + if (this.tabs_[this.active_] !== tab) return delay(this.clock, BACKGROUND_FRAME_MS); + const skipped = this.draw(tab, frame); + this.touch(); + const wait = tab.stream.retryAfter(); + if (skipped && wait > 0) { + // The stream held this frame back for rate: draw the newest one once it is due, so the last picture is never lost. + if (tab.retry === undefined) { + tab.retry = this.clock.setTimeout(() => { + tab.retry = undefined; + if (!tab.closed && !this.disposed && tab.frame && this.tabs_[this.active_] === tab) { this.draw(tab, tab.frame); this.touch(); } + }, wait); + } + return delay(this.clock, wait); + } + return undefined; + } + + /** True when the stream skipped the frame. */ + private draw(tab: Tab, frame: Frame): boolean { + const box = {...this.area}; + const output = tab.stream.update({png: frame.png, width: frame.width, height: frame.height}, box); + tab.lines = output.lines; + tab.linesBox = box; + if (output.note) this.setNotice(output.note); + return output.skipped; + } + + /** The terminal's pictures are gone (a program took the screen): every tab uploads again on its next frame. */ + forgetPictures(): void { + for (const tab of this.tabs_) tab.stream.forget(); + const tab = this.tabs_[this.active_]; + if (tab?.frame) this.draw(tab, tab.frame); + this.touch(); + } + + /** Delete the previous picture once the new one is on screen. The panel calls it after a render. */ + settle(): void { + this.tabs_[this.active_]?.stream.settle(); + } + + private event(tab: Tab, event: PageEvent): void { + if (tab.closed || this.disposed) return; + switch (event.type) { + case 'navigated': tab.url = event.url; delete tab.find; break; + case 'title': tab.title = event.title; break; + case 'loading': tab.loading = event.loading; break; + case 'editable': tab.editable = event.editable; break; + case 'dialog': this.setNotice(`The page showed ${event.kind === 'beforeunload' ? 'a leave prompt' : `an ${event.kind}`}: ${plainText(event.message, 80)}`); break; + case 'blocked': this.lastBlocked_ = {url: event.url, reason: event.reason}; this.setNotice(`Blocked: ${plainText(event.reason, 80)}`); break; + case 'download-blocked': this.setNotice('A download was blocked. NMSh does not save files from pages.'); break; + case 'popup': { + const url = popupAddress(event.url); + if (tab.kind === 'preview') { this.lastBlocked_ = {url: event.url, reason: 'a link in a preview does not open in place'}; this.setNotice('A link was not followed: the preview shows this document only.'); break; } + if (url === undefined) this.setNotice('A popup to an address that cannot open here was blocked.'); + else if (this.tabs_.length >= MAX_TABS) this.setNotice(`A popup was blocked: ${MAX_TABS} tabs are open.`); + else void this.open(url); + break; + } + case 'crashed': this.fail(tab, 'The page crashed.'); break; + case 'closed': this.fail(tab, 'The page was closed.'); break; + } + this.touch(); + } + + private fail(tab: Tab, message: string): void { + tab.error = message; + tab.loading = false; + tab.editable = false; + for (const off of tab.unsubscribe.splice(0)) { try { off(); } catch { /* gone */ } } + const page = tab.page; + tab.page = undefined; + if (page) void page.close().catch(() => undefined); + tab.stream.release(); + tab.lines = []; + delete tab.linesBox; + delete tab.frame; + } + + // ---- resizing -------------------------------------------------------------------------------------------------- + + /** + * The page area's size in cells (and the terminal's cell size, when it changed). The picture is fitted to the new area at + * once; the page's viewport follows after the size has stopped changing, and fresh frames are then requested. + */ + resize(area: {columns: number; rows: number}, cell?: {width: number; height: number}): void { + if (this.disposed) return; + const columns = Math.max(1, Math.floor(area.columns)); + const rows = Math.max(1, Math.floor(area.rows)); + const nextCell = cell ?? this.options.cell(); + if (columns === this.area.columns && rows === this.area.rows && nextCell.width === this.cellSize.width && nextCell.height === this.cellSize.height) return; + this.area = {columns, rows}; + this.cellSize = nextCell; + const tab = this.tabs_[this.active_]; + if (tab?.frame) this.draw(tab, tab.frame); + this.scheduleViewport(RESIZE_DEBOUNCE_MS); + this.touch(); + } + + private scheduleViewport(ms: number): void { + if (this.resizeTimer !== undefined) this.clock.clearTimeout(this.resizeTimer); + this.resizeTimer = this.clock.setTimeout(() => { this.resizeTimer = undefined; void this.applyViewport(); }, ms); + } + + private keyOf(viewport: Viewport): string { return `${viewport.width}x${viewport.height}@${viewport.scale}`; } + + private async applyViewport(): Promise { + if (this.disposed) return; + const viewport = this.viewport(); + const tabs = [...this.tabs_]; + for (const tab of tabs) { + if (!tab.page || tab.closed) continue; + try { await tab.page.setViewport(viewport); } catch { /* a page that went away reports it through its events */ } + } + const active = this.tabs_[this.active_]; + if (!active?.page || active.closed) return; + try { + const frame = await active.page.capture(); + if (!active.closed && !this.disposed) { active.frame = frame; this.draw(active, frame); this.touch(); } + } catch { /* the next frame the page sends will do */ } + } + + // ---- navigation ------------------------------------------------------------------------------------------------ + + private act(run: (page: BrowserPage) => Promise): Promise { + const tab = this.tabs_[this.active_]; + if (!tab?.page) return Promise.resolve(); + return run(tab.page).then(() => { this.sync(tab); }, (error: unknown) => { + this.setNotice(error instanceof Error ? error.message : 'The page did not answer.'); + }); + } + + private sync(tab: Tab): void { + if (tab.closed || !tab.page) return; + tab.zoom = tab.page.zoom; + tab.url = tab.page.url || tab.url; + this.touch(); + } + + /** Load an address in the active tab, or open one when there is no tab. */ + async navigate(url: string): Promise { + const tab = this.tabs_[this.active_]; + if (!tab) { await this.open(url); return; } + tab.url = url; + if (!tab.page) { await this.reopen(tab, url); return; } + await this.act(page => page.navigate(url)); + } + + back(): Promise { return this.act(page => page.back()); } + forward(): Promise { return this.act(page => page.forward()); } + stop(): Promise { return this.act(page => page.stop()); } + + /** Turn scripts on or off for the active page (the engine reloads it under the new rule). */ + async setScripts(enabled: boolean): Promise { + const tab = this.tabs_[this.active_]; + if (tab) tab.scripts = enabled; + await this.act(page => page.setScripts(enabled)); + } + + async reload(options: {hard?: boolean} = {}): Promise { + const tab = this.tabs_[this.active_]; + if (!tab) return; + // A tab whose page crashed or closed gets a new page at the same address. + if (!tab.page) { await this.reopen(tab, tab.url); return; } + await this.act(page => page.reload(options.hard ? {hard: true} : undefined)); + } + + hardReload(): Promise { return this.reload({hard: true}); } + + private async reopen(tab: Tab, url: string): Promise { + tab.loading = true; + delete tab.error; + this.touch(); + await this.attach(tab, url); + } + + async zoomBy(direction: 1 | -1): Promise { + const tab = this.tabs_[this.active_]; + if (!tab?.page) return; + const current = tab.page.zoom; + let index = 0; + for (let at = 0; at < ZOOM_STEPS.length; at += 1) if (Math.abs(ZOOM_STEPS[at]! - current) < Math.abs(ZOOM_STEPS[index]! - current)) index = at; + const next = ZOOM_STEPS[Math.max(0, Math.min(ZOOM_STEPS.length - 1, index + direction))]!; + await this.setZoom(next); + } + + async setZoom(zoom: number): Promise { + await this.act(page => page.setZoom(zoom)); + this.setNotice(`Zoom ${Math.round((this.tabs_[this.active_]?.zoom ?? zoom) * 100)}%`); + } + + // ---- find ------------------------------------------------------------------------------------------------------ + + async find(query: string, options: {backwards?: boolean} = {}): Promise { + const tab = this.tabs_[this.active_]; + if (!tab?.page) return; + if (!query) { await this.clearFind(); return; } + try { + const result = await tab.page.find(query, options.backwards ? {backwards: true} : undefined); + if (tab.closed) return; + tab.find = {query, matches: result.matches, current: result.current}; + if (!result.matches) this.setNotice(`No match for "${plainText(query, 40)}".`); + this.touch(); + } catch { this.setNotice('The page did not answer.'); } + } + + /** The next or previous match of the active tab's current query. */ + async findNext(backwards = false): Promise { + const query = this.tabs_[this.active_]?.find?.query; + if (query) await this.find(query, {backwards}); + } + + async clearFind(): Promise { + const tab = this.tabs_[this.active_]; + if (!tab) return; + delete tab.find; + this.touch(); + if (tab.page) { try { await tab.page.clearFind(); } catch { /* the page is gone */ } } + } + + // ---- input to the page ----------------------------------------------------------------------------------------- + + /** A key press for the page: down (carrying the text) then up. */ + async sendKey(input: KeyEventInput): Promise { + await this.act(async page => { await page.key('down', input); await page.key('up', input); }); + } + + sendMouse(event: MouseEventInput): Promise { return this.act(page => page.mouse(event)); } + insertText(text: string): Promise { return this.act(page => page.insertText(text)); } + + /** Scroll the active page with wheel events at the middle of the page. */ + scroll(amount: 'line' | 'page' | 'top' | 'bottom', direction: 1 | -1, horizontal = false): Promise { + const viewport = this.tabs_[this.active_]?.page?.viewport ?? this.viewport(); + const line = Math.max(16, Math.round(this.cellSize.height * 3 / viewport.scale)); + const distance = amount === 'line' ? line : amount === 'page' ? Math.round((horizontal ? viewport.width : viewport.height) * 0.9) : 100_000; + const delta = direction * distance; + return this.sendMouse({type: 'wheel', x: Math.round(viewport.width / 2), y: Math.round(viewport.height / 2), deltaX: horizontal ? delta : 0, deltaY: horizontal ? 0 : delta}); + } + + // ---- change notification --------------------------------------------------------------------------------------- + + private touch(): void { + if (this.disposed || this.changeTimer !== undefined || !this.listeners.size) return; + const wait = this.lastChange + CHANGE_INTERVAL_MS - this.clock.now(); + if (wait <= 0) { this.emit(); return; } + this.changeTimer = this.clock.setTimeout(() => { this.changeTimer = undefined; this.emit(); }, wait); + } + + private emit(): void { + this.lastChange = this.clock.now(); + for (const listener of [...this.listeners]) { try { listener(); } catch { /* a listener's failure is its own */ } } + } + + // ---- end ------------------------------------------------------------------------------------------------------- + + /** Close every tab, delete every picture, remove every subscription. Safe to repeat. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.resizeTimer !== undefined) this.clock.clearTimeout(this.resizeTimer); + if (this.changeTimer !== undefined) this.clock.clearTimeout(this.changeTimer); + this.resizeTimer = this.changeTimer = undefined; + for (const tab of this.tabs_.splice(0)) this.retire(tab); + this.listeners.clear(); + } +} + +function viewOf(tab: Tab): TabView { + const page = tab.page; + return { + id: tab.id, + title: plainText(tab.title, 120), + url: tab.url, + loading: tab.loading, + ...(tab.error ? {error: tab.error} : {}), + editable: tab.editable, + canGoBack: page?.canGoBack ?? false, + canGoForward: page?.canGoForward ?? false, + zoom: tab.zoom, + ...(tab.find ? {find: {...tab.find}} : {}), + }; +} + +function delay(clock: SessionClock, ms: number): Promise { + return new Promise(resolve => { clock.setTimeout(resolve, ms); }); +} diff --git a/src/browser/types.ts b/src/browser/types.ts new file mode 100644 index 00000000..ceaaf8b6 --- /dev/null +++ b/src/browser/types.ts @@ -0,0 +1,174 @@ +/** + * 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. 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; +} + +/** 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; +} diff --git a/src/clipboard/copySelection.ts b/src/clipboard/copySelection.ts index 342e0cb5..2b6fcfa2 100644 --- a/src/clipboard/copySelection.ts +++ b/src/clipboard/copySelection.ts @@ -1,5 +1,6 @@ import {serializeCopyPayload, type CompletedCommand} from '../output/OutputBuffer.js'; import {parseReportFlag, type ReportFormat} from './report.js'; +import {semanticText, type SemanticResult} from '../output/semantic.js'; /** * Which completed commands /copy takes, by recency (1 is the latest), and whether NMSh's completion status follows @@ -16,6 +17,8 @@ import {parseReportFlag, type ReportFormat} from './report.js'; * - `--status` / `--no-status` override the "Include completion status" setting for this copy only. Flags always * start with two dashes, so they never read as a negative count. * - `--report` (Markdown) or `--report=plain`: the selection as a structured report (see report.ts). + * - `--raw` (the default), `--semantic` or `--both`: the stored output, the Semantic Output reading (raw for a record + * that has none), or both under labelled NMSh separators. One of them at most; never with --report. * `/cp` is the same command: it reaches this parser with the same arguments. */ export const COPY_MAX_INDEX = 999; @@ -34,22 +37,31 @@ export interface CopyRequest { status?: boolean; /** Copy as a report in this format instead of plain output. */ report?: ReportFormat; + /** What of each record is copied; undefined is raw (the stored output). */ + content?: CopyContent; } +export type CopyContent = 'raw' | 'semantic' | 'both'; + export type CopyArgs = {ok: true; request: CopyRequest} | {ok: false; error: string}; -const USAGE = 'Use /copy, /copy latest, /copy ui, /copy N, /copy -N, /copy A-B or /copy 1,3-5, optionally with --status, --no-status or --report[=plain].'; +const USAGE = 'Use /copy, /copy latest, /copy ui, /copy N, /copy -N, /copy A-B or /copy 1,3-5, optionally with --status, --no-status, --report[=plain], or one of --raw, --semantic, --both.'; /** The text after `/copy`. Nothing is guessed: anything unrecognised is an error that names the usage. */ export function parseCopyArgs(text: string): CopyArgs { let status: boolean | undefined; let report: ReportFormat | undefined; + let content: CopyContent | undefined; const selectors: string[] = []; for (const token of text.trim().split(/\s+/u).filter(Boolean)) { const format = parseReportFlag(token); if (format === 'invalid') return {ok: false, error: `${token}: a report is --report (Markdown) or --report=plain.`}; if (format) { report = format; continue; } - if (token === '--status' || token === '--no-status') { + if (token === '--raw' || token === '--semantic' || token === '--both') { + const value = token.slice(2) as CopyContent; + if (content !== undefined && content !== value) return {ok: false, error: 'Use one of --raw, --semantic or --both.'}; + content = value; + } else if (token === '--status' || token === '--no-status') { const value = token === '--status'; if (status !== undefined && status !== value) return {ok: false, error: 'Use either --status or --no-status, not both.'}; status = value; @@ -58,10 +70,11 @@ export function parseCopyArgs(text: string): CopyArgs { } if (selectors.length > 1) return {ok: false, error: `One selection at a time (write lists with commas: /copy 1,3,5). ${USAGE}`}; if (report && status !== undefined) return {ok: false, error: 'A report always includes each command\'s status; leave out --status and --no-status.'}; - const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {})}}); + if (report && content) return {ok: false, error: 'A report has its own format; leave out --raw, --semantic and --both.'}; + const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {}), ...(content ? {content} : {})}}); const selector = selectors[0]; if (selector === undefined) return withStatus({kind: 'default'}); - if (selector === 'ui') return withStatus({kind: 'picker'}); + if (selector === 'ui') return content && content !== 'raw' ? {ok: false, error: '--semantic and --both copy directly: name the outputs (/copy 2 --semantic).'} : withStatus({kind: 'picker'}); if (selector === 'latest') return withStatus({kind: 'latest', count: 1}); const latest = /^-([1-9]\d*)$/u.exec(selector); if (latest) { @@ -130,6 +143,25 @@ export function copySelectionPayload(records: readonly CompletedCommand[], inclu .reduce((payload, text) => !payload ? text : payload.endsWith('\n') ? payload + text : `${payload}\n${text}`, ''); } +/** + * `/copy --semantic` and `--both`: per record, the Semantic Output reading as text, or (both) the raw output and the + * reading under labelled NMSh separators that cannot be mistaken for output. A record without a reading copies its raw + * output once, and is counted so the note can say so. Records keep their order and separation as in a raw copy. + */ +export function semanticCopyPayload(records: readonly CompletedCommand[], content: 'semantic' | 'both', includeStatus: boolean, + reading: (record: CompletedCommand) => SemanticResult | undefined): {payload: string; unavailable: number} { + let unavailable = 0; + const parts = records.map(record => { + const raw = recordCopyText(record, includeStatus); + const result = reading(record); + if (!result) { unavailable += 1; return raw; } + if (content === 'semantic') return semanticText(result); + return [`── NMSh · raw output ──`, raw.replace(/\r?\n$/u, ''), `── NMSh · Semantic Output ──`, semanticText(result)].join('\n'); + }).filter(Boolean); + const payload = parts.reduce((joined, text) => !joined ? text : joined.endsWith('\n') ? joined + text : `${joined}\n${text}`, ''); + return {payload, unavailable}; +} + /** The brief note after a successful copy: what was copied and how much (one output keeps the plain wording). */ export function copiedNote(count: number, stats: {characters: number; lines: number}, includeStatus: boolean): string { const what = count === 1 ? 'Copied' : `Copied ${count} outputs`; diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 331dc1bf..0979caee 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'}, @@ -39,8 +39,19 @@ 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: '/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: '/browser', insertion: '/browser', description: 'A real browser (Chromium) in NMSh: /browser · /browser https://example.com · /browser ./index.html. Needs an installed Chrome or Chromium; nothing is downloaded'}, + {name: '/view', insertion: '/view ', description: 'Read a file or a completed output in a native viewer: /view README.md · /view 2 (output 2, numbered as /copy) · /view (latest output)'}, + {name: '/edit', insertion: '/edit ', description: 'Edit a small text file in NMSh: /edit notes.md (Ctrl+S saves safely, Esc closes; vim, nano and your editor still work as usual)'}, {name: '/compare', insertion: '/compare', description: 'Compare two outputs: /compare (latest with its previous run), /compare N, /compare N M (numbers as /copy)'}, {name: '/pins', insertion: '/pins', description: 'Commands and recipes you kept: stage a pin, or review and queue a recipe (nothing runs by itself)'}, {name: '/ps', insertion: '/ps', description: 'Paste from the clipboard, reviewed first: read when you run it, never in the background'}, @@ -49,7 +60,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/appearance', insertion: '/appearance', description: 'Configure terminal appearance'}, {name: '/motion', insertion: '/motion', description: 'Motion: context transitions, command launch, completion highlight and effects (same as /appearance → Motion)'}, {name: '/prompt', insertion: '/prompt', description: 'Configure prompt provider and composer layout'}, - {name: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them'}, + {name: '/modules', insertion: '/modules', description: 'Context modules, catalog and Context Packs: show, route to a surface, search; saved as you change them (/modules explain · /modules timings)'}, {name: '/cursor', insertion: '/cursor', description: 'Text caret shape and blink while NMSh owns the composer'}, {name: '/activity', insertion: '/activity', description: 'Live activity colors for the running-command line'}, {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, @@ -126,7 +137,8 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/qc', insertion: '/qc', alias: '/queue', description: 'Review before clearing queued commands'}, {name: '/pause', insertion: '/pause', alias: '/queue', description: 'Pause the command queue'}, {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings'}, - {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume|refresh)'}, + {name: '/track', insertion: '/track ', description: 'Keep one live result of a finite command in the transcript, refreshed in place (/track df -h · /track --every 5s -- docker system df · pause|resume|refresh|stop|copy)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, @@ -168,10 +180,18 @@ 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} + /** /browser [address | path]: the real browser panel (Chromium, started on demand). */ + | {kind: 'browser'; 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'} | {kind: 'motion'} | {kind: 'prompt'} - | {kind: 'modules'} + | {kind: 'modules'; report?: 'explain' | 'timings'} | {kind: 'chroma'} | {kind: 'theme'} | {kind: 'themeBridge'} @@ -226,7 +246,7 @@ export type ParsedSlashCommand = | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} | {kind: 'llm'} | {kind: 'doctor'} - | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} + | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy'; arguments: string; /** Typed as /track: keep one live result in the transcript. */ track?: boolean} | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} @@ -263,6 +283,14 @@ 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 browser = /^\/browser(?:\s+([\s\S]*))?$/u.exec(input); + if (browser) return {kind: 'browser', target: (browser[1] ?? '').trim()}; + 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); + if (editFile) return {kind: 'editFile', target: (editFile[1] ?? '').trim()}; // /cp is /copy: one parser, one handler, every form. if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'}; const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input); @@ -270,7 +298,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/appearance\s*$/u.test(input)) return {kind: 'appearance'}; if (/^\/motion\s*$/u.test(input)) return {kind: 'motion'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; - if (/^\/modules\s*$/u.test(input)) return {kind: 'modules'}; + const modules = /^\/modules(?:\s+(explain|timings))?\s*$/u.exec(input); + if (modules) return modules[1] ? {kind: 'modules', report: modules[1] as 'explain' | 'timings'} : {kind: 'modules'}; if (/^\/chroma\s*$/u.test(input)) return {kind: 'chroma'}; if (/^\/theme\s*$/u.test(input)) return {kind: 'theme'}; if (/^\/theme-bridge\s*$/u.test(input)) return {kind: 'themeBridge'}; @@ -377,11 +406,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/configuration/paths.ts b/src/configuration/paths.ts index 815d6915..dd32f7cb 100644 --- a/src/configuration/paths.ts +++ b/src/configuration/paths.ts @@ -14,3 +14,18 @@ export function nmshConfigDirectory( export function promptConfigurationPath(env: NodeJS.ProcessEnv = process.env): string { return join(nmshConfigDirectory(env), 'config.json'); } + +/** + * Where NMSh keeps disposable caches (the real browser's throwaway profile). A launcher that isolates its configuration with + * `XDG_CONFIG_HOME` gets its own cache beside it, so QA sessions never share a profile with real use. + */ +export function nmshCacheDirectory( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + const home = [env.HOME, env.USERPROFILE].find(value => value && isAbsolute(value)) || homedir(); + if (env.XDG_CACHE_HOME && isAbsolute(env.XDG_CACHE_HOME)) return join(env.XDG_CACHE_HOME, 'nmsh'); + if (env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) return join(env.XDG_CONFIG_HOME, 'nmsh', 'cache'); + if (platform === 'darwin') return join(home, 'Library', 'Caches', 'notMyShell'); + return join(home, '.cache', 'nmsh'); +} 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/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/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/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/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/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/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/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/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 } 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/service.ts b/src/graphics/service.ts new file mode 100644 index 00000000..47de641c --- /dev/null +++ b/src/graphics/service.ts @@ -0,0 +1,369 @@ +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'; +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 {FrameStream} from './stream.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; + /** Identifies the upload behind it (Kitty placeholders), for `keepOnly`. */ + key?: string; +} + +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.'); + } + 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.'); } + if (!inside(real, realRoot)) throw outside(); + 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: safeName(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, 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); + } + + 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 { + 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 { + 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, key}; + } + + 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); + } + } + + /** 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); + 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; + } + + /** + * 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} { + return {cached: this.cache.size, cacheBytes: this.cacheSize, uploads: this.uploads.size, uploadBytes: this.uploadSize}; + } +} + +/** 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'; +} + +/** 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/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/stream.ts b/src/graphics/stream.ts new file mode 100644 index 00000000..89539854 --- /dev/null +++ b/src/graphics/stream.ts @@ -0,0 +1,255 @@ +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 = []; + } + + /** Take this stream's pictures out of the terminal now (its surface is hidden) but keep it usable: the next frame uploads again. */ + release(): void { + if (this.route === 'kitty-placeholder') { + const ids = [...this.retired, ...(this.current === undefined ? [] : [this.current])]; + if (ids.length) this.host.send(ids.map(kittyDeleteImage).join('')); + } + this.forget(); + } + + /** 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; +} 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/src/graphics/webp.ts b/src/graphics/webp.ts new file mode 100644 index 00000000..881c8b1e --- /dev/null +++ b/src/graphics/webp.ts @@ -0,0 +1,116 @@ +/** + * 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/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/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/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 { + /** 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): TerminalEditorDescriptor[] { + return TERMINAL_EDITORS.filter(editor => editor.executable && host.which(editor.executable)); +} diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts index 3294e96f..2b035819 100644 --- a/src/input/BatchReview.ts +++ b/src/input/BatchReview.ts @@ -1,24 +1,34 @@ 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'; import {classifyCommand, KIND_LABELS, primaryKind, splitCommands, type PasteKind} from './pasteGuard.js'; import {displaySafe} from './PasteReview.js'; import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; /** - * Batch Review: a pasted sequence split into the commands the shell would run, to look at before any of it is queued. - * Nothing runs because it was pasted or shown here. Enter queues exactly the commands listed, in the order listed, - * into this session's own queue (one at a time, in the same shell, pausing on a failure); every other key leaves the - * paste as text. + * 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[]; } -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. 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. */ @@ -35,16 +45,19 @@ 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; /** 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 = | {kind: 'queue'} + | {kind: 'steps'} | {kind: 'block'} | {kind: 'text'} | {kind: 'edit'} @@ -92,19 +105,35 @@ 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 = nmshCommandText(text); + 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): 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)})); + // 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}; } /** @@ -112,40 +141,56 @@ 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 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, }; } -/** Available execution choices for the given batch review state. */ +/** 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; +} + +/** 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[] { - 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)})); @@ -155,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, @@ -183,14 +229,16 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number }; const choices = batchAvailableChoices(state); - const currentChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + // 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; @@ -201,10 +249,8 @@ 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 === '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'}; @@ -220,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') { state.choice = '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; } + // 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' && state.entries.length && state.entries.length <= state.capacity && !state.refusal) 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))); @@ -275,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); @@ -294,89 +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 activeChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + const active = activeChoice(state, choices); const compact = columns < 70; - const choicePills: string[] = []; - - 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}`); - - 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 editFocused = activeChoice === 'edit'; - choicePills.push(editFocused ? `${accent}${GLYPHS.selection} [3 Edit]${reset}` : `${subtle}[3 Edit]${reset}`); - - const cancelFocused = activeChoice === '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 = activeChoice === 'block' - ? 'run as block' - : activeChoice === 'queue' - ? `queue ${count}` - : activeChoice === '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) { + + 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 (activeChoice === 'block') { - out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. The shell runs 1 outer execution.', width)}${reset}`); - } else if (activeChoice === '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') { - 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/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/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/src/input/pasteGuard.ts b/src/input/pasteGuard.ts index 7242b3c2..fe23cd4a 100644 --- a/src/input/pasteGuard.ts +++ b/src/input/pasteGuard.ts @@ -53,6 +53,8 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo if (char === '\n' || char === ';') { push(false); continue; } if (char === '&' && next === '&') { push(false); index += 1; continue; } if (char === '|' && next === '|') { push(false); index += 1; continue; } + // `>|` is a redirection (overwrite despite noclobber), not a pipe. + if (char === '|' && current.endsWith('>')) { current += char; continue; } if (char === '|') { push(true); continue; } current += char; } @@ -62,7 +64,31 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo const INTERPRETERS = /^(?:sh|bash|zsh|fish|dash|ksh|python3?|node|perl|ruby|eval|source|\.)$/u; +/** Shell text with quoted strings and escaped characters masked, so operators inside them are not read as operators. */ +export function maskQuoted(text: string): string { + return text.replace(/\\[\s\S]|"(?:\\.|[^"\\])*"|'[^']*'/gu, match => match.startsWith('\\') ? '__' : '""'); +} + +/** + * Whether shell text redirects output into a file. Every redirection is judged on its own target, so `>/dev/null` + * elsewhere in the line never excuses `> ~/.zshrc`. Descriptor duplication (`2>&1`), closing (`>&-`), the null device + * and the standard streams are not files; `>|`, `>&file`, `&>file` and `<>` (opens for writing) are. + */ +export function writesFiles(text: string): boolean { + for (const match of maskQuoted(text).matchAll(/(&>>?|\d*>>|\d*>\||\d*<>|\d*>&|\d*>)\s*([^\s;&|<>()]*)/gu)) { + const operator = match[1] ?? ''; + const target = match[2] ?? ''; + if (operator.endsWith('<>')) return true; + if (operator.endsWith('>&') && /^(?:\d+|-)$/u.test(target)) continue; + if (/^\/dev\/(?:null|stdout|stderr|fd\/[12])$/u.test(target)) continue; + return true; + } + return false; +} + /** Words of one command (quotes removed for classification only). */ +export function commandWords(text: string): string[] { return words(text); } + function words(text: string): string[] { return text.match(/"(?:\\.|[^"])*"|'[^']*'|\S+/gu)?.map(word => word.replace(/^["']|["']$/gu, '')) ?? []; } @@ -196,7 +222,7 @@ function shellKinds(text: string, pipedFrom: boolean): PasteKind[] { if (/^(?:mv|cp|mkdir|touch|ln|tee|sed|perl|install|chmod|chown|patch|unzip|tar)$/u.test(command) && !kinds.has('destructive')) { if (command !== 'sed' || /-i\b|--in-place/u.test(flags)) { kinds.delete('read'); kinds.add('modifies'); } } - if (/(?:^|[^>&])>>?\s*[^\s&|]/u.test(text.replace(/"(?:\\.|[^"])*"|'[^']*'/gu, '""')) && !/>\s*\/dev\/null/u.test(text)) kinds.add('modifies'); + if (writesFiles(text)) kinds.add('modifies'); if (!kinds.size) kinds.add('unknown'); return [...kinds]; } diff --git a/src/markdown/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 `
`, 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('/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; next: number} {
+function parseList(lines: readonly string[], start: number, depth: number, html: boolean): {block: Extract; 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/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/html.ts b/src/markdown/html.ts
new file mode 100644
index 00000000..bd228cd8
--- /dev/null
+++ b/src/markdown/html.ts
@@ -0,0 +1,260 @@
+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; self: boolean} | {kind: 'close'; name: string};
+interface Element {name: string; attributes: Record; 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 + 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(`', 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 `
  • `, `

    `, ``, a cell). */ +const IMPLIED: Record = {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(/ value.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') { + 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)}`; + else if (EMPHASIS.has(name)) out += `${inlineText(node.children)}`; + else if (STRIKE.has(name)) out += `${inlineText(node.children)}`; + else if (name === 'a' && node.attributes.href) out += `${inlineText(node.children)}`; + 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: `${text}`}] : []; + } + 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> = []; + 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..ec2d4b7f 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 `` stays text. Without it, tags are text. */ export interface InlineStyle { @@ -17,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 { @@ -38,28 +45,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 = {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 = {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 +163,9 @@ function backtickRuns(source: string): Map { return runs; } -function tokenize(source: string): Token[] { +function tokenize(source: string, options: InlineOptions): Token[] { const tokens: Token[] = []; + 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(); @@ -210,6 +212,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, closes, tokens); + continue; + } } if (char === '!' && source[index + 1] === '[' || char === '[') { const image = char === '!'; @@ -220,9 +228,9 @@ function tokenize(source: string): 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).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 +264,65 @@ function tokenize(source: string): Token[] { return tokens; } +/** + * Where `` after ``) stays linear. + */ +function closingTags(source: string): (name: string, from: number) => number { + const lower = source.toLowerCase(); + const found = new Map(); + return (name, from) => { + const known = found.get(name); + if (known !== undefined && (known < 0 || known >= from)) return known; + const at = lower.indexOf(`>, 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 = closes(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') { 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). + 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, ' '); + 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 | 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 +354,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(), ending = new Map(); for (const wrap of wraps) { starting.set(wrap.from, [...(starting.get(wrap.from) ?? []), wrap]); @@ -303,7 +366,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 +397,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 +412,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..5418750c --- /dev/null +++ b/src/markdown/markup.ts @@ -0,0 +1,136 @@ +/** + * 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 = {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; + /** 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 = Object.create(null) as Record; + 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 === "'") { + // 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 { + 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 `` stays text (`a generic `). */ +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 { + 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 4f3bcdcb..085b1b05 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -6,8 +6,10 @@ 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'; +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 @@ -16,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. */ @@ -26,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 { @@ -37,6 +70,14 @@ 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; + /** 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'; @@ -54,6 +95,11 @@ interface Context { fg: (color: RgbColor) => string; 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 { @@ -117,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); @@ -158,7 +207,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; } @@ -185,7 +237,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 --------------------------------------------------------------------------------------------------------- @@ -209,6 +261,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, html: false, maxImageRows: 1, blockIds: {next: 1}}); +} + function codeRows(block: Extract, width: number, context: Context): MarkdownRow[] { const index = context.codeIndex++; const gutter = `${context.fg(UI_COLORS.separator)}${glyph(context, '│', '|')}${RESET} `; @@ -254,11 +313,11 @@ function alignCell(text: string, width: number, align: Alignment): string { function tableRows(block: Extract, 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[] = []; @@ -266,7 +325,7 @@ function tableRows(block: Extract, 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'}); }); @@ -303,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: ['-', '*', '+']}; @@ -315,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': @@ -368,6 +476,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 = {}): 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 = { @@ -378,10 +495,15 @@ 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), + ...(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'); - 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/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index be7d481e..45916ba7 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -54,9 +54,51 @@ export type SerializedLine = SerializedCell[]; */ export const MAX_CONTROL_SEQUENCE = 8192; +/** The screen height assumed until the owner reports the PTY's rows (a terminal's classic default). */ +const DEFAULT_SCREEN_ROWS = 24; + +/** + * The live region (#388). A program may redraw its own recent rows in place, as on a terminal: cursor up/down + * (`CSI A/B/E/F`, `ESC M/D/E`), save/restore (`ESC 7/8`, `CSI s/u`) and erasing (`CSI J` 0/1, `CSI K` 0/1/2) act on + * the row the cursor is on. The cursor never moves above the fence (the line after the newest one NMSh wrote: a + * command header, a notice, an earlier command) nor more than a screen height above the newest line, because a + * terminal cannot address its scrollback either. So a multi-row progress display updates its rows instead of adding + * a copy of them per frame, and nothing outside the running program's own rows can be changed. + * + * Rows are the terminal's rows. Stored lines are logical (a long line is one line), but once the owner reports the + * PTY's width a line wider than it occupies several rows, as on the screen: moving up three rows from below a + * two-row line lands on that line's first row, not on the line above it, so a program that counts the rows it drew + * redraws exactly them (moving by lines, it erased its own earlier output at narrow widths). Carriage return, + * backspace, `CSI G/C/D` and `CSI K` act on the row the cursor is on within its line. A resize re-wraps stored lines + * at the new width, as a reflowing terminal does. + * + * Deliberately not emulated: absolute row positioning (`CSI H/f/d` rows) is not mapped, because an inline program + * cannot know where its rows are (cursor position reports are not answered); the cursor stays where it is. Scroll + * regions (`CSI r`) are not emulated either, except that text a program draws below the region it set (a reserved + * status line, such as an installer's progress row) is not stored: it would otherwise be written into the log rows. + * The stored lines are what a terminal would show at the end; intermediate frames are not kept. + */ export class AnsiOutputParser { readonly lines: StyledLine[] = []; private current: StyledLine = []; + /** The cursor's row: an index into `lines`, or `lines.length` for the current (newest) line. */ + private row = 0; + /** The lowest row the program may address (see the live region). */ + private fence = 0; + private screenRows = DEFAULT_SCREEN_ROWS; + /** The PTY's columns, once reported: rows wrap at this width. Unknown (Infinity), each line is one row. */ + private screenColumns = Number.POSITIVE_INFINITY; + /** The bottom row of a scroll region the program set above the last row, if any (1-based). */ + private regionBottom?: number; + /** The cursor was moved below that region (a status row): what is drawn there is not stored. */ + private statusRow = false; + /** + * The column at which the last character filled its row exactly: the terminal keeps the cursor on that row (a + * pending wrap) until the next character. Any move to another column ends it. + */ + private pendingWrap?: number; + /** DECSC / SCOSC saved cursor (row, column, and for ESC 7 the style). */ + private saved?: {row: number; column: number; style?: string}; private column = 0; private style = ''; private pending = ''; @@ -86,6 +128,141 @@ export class AnsiOutputParser { constructor(private readonly onClear?: () => void) {} + /** The PTY's rows: how far above the newest line a program's cursor can reach. */ + setScreenRows(rows: number): void { + this.screenRows = Math.max(1, Math.min(1000, Math.floor(rows) || DEFAULT_SCREEN_ROWS)); + } + + /** The PTY's columns: the width at which a long line occupies more than one row. */ + setScreenColumns(columns: number): void { + this.screenColumns = Number.isFinite(columns) && columns >= 1 ? Math.min(10_000, Math.floor(columns)) : Number.POSITIVE_INFINITY; + } + + /** + * Program output from here on may redraw only rows from `index` (clamped to the stored lines); NMSh-written rows + * and earlier commands stay out of reach. Used when a running command is resumed from a journal. + */ + fenceAt(index: number): void { + this.fence = Math.max(0, Math.min(index, this.lines.length)); + this.row = this.lines.length; + this.saved = undefined; + } + + /** The line the cursor is on. */ + private get target(): StyledLine { + return this.row < this.lines.length ? this.lines[this.row]! : this.current; + } + + /** The highest line the cursor may move to: the fence, or the line on the row a screen height above the newest. */ + private get topRow(): number { + if (!this.wraps) return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + let row = this.lines.length; + let used = this.rowsOf(row); + while (row > this.fence && used < this.screenRows) { row -= 1; used += this.rowsOf(row); } + return row; + } + + private get wraps(): boolean { return Number.isFinite(this.screenColumns); } + + private lineAt(row: number): StyledLine { return row < this.lines.length ? this.lines[row]! : this.current; } + + /** The rows a line occupies at the PTY's width (the cursor's line also counts the cursor). */ + private rowsOf(row: number): number { + const length = row === this.row ? Math.max(this.lineAt(row).length, this.column) : this.lineAt(row).length; + return Math.max(1, Math.ceil(length / this.screenColumns)); + } + + /** + * The row of its line the cursor is on. A row exactly filled leaves the cursor on it (the terminal's pending + * wrap) until the next character. + */ + private get subRow(): number { + if (!this.wraps) return 0; + const columns = this.screenColumns; + return this.column > 0 && this.column === this.pendingWrap ? this.column / columns - 1 : Math.floor(this.column / columns); + } + + /** Where the cursor's row starts, as a column of its line. */ + private get rowStart(): number { return this.wraps ? this.subRow * this.screenColumns : 0; } + + /** The cursor's column on its row (the last column while a wrap is pending). */ + private get screenColumn(): number { + return this.wraps ? Math.min(this.column - this.rowStart, this.screenColumns - 1) : this.column; + } + + private moveRow(delta: number): void { + this.row = Math.max(this.topRow, Math.min(this.lines.length, this.row + delta)); + } + + /** Cursor up (negative) or down by terminal rows, keeping its column on the row (or going to the row's start). */ + private moveRows(delta: number, toRowStart = false): void { + if (!this.wraps) { + this.moveRow(delta); + if (toRowStart) this.column = 0; + return; + } + const columns = this.screenColumns; + const x = toRowStart ? 0 : this.screenColumn; + let row = this.row; + let sub = this.subRow; + if (delta < 0) { + // No higher than a screen above the newest row: count the rows from the cursor down to it. + let below = this.rowsOf(row) - 1 - sub; + for (let next = row + 1; next <= this.lines.length && below < this.screenRows; next += 1) below += this.rowsOf(next); + let steps = Math.min(-delta, Math.max(0, this.screenRows - 1 - below)); + while (steps > 0) { + if (sub > 0) sub -= 1; + else if (row > this.fence) { row -= 1; sub = this.rowsOf(row) - 1; } + else break; + steps -= 1; + } + } else { + let steps = delta; + while (steps > 0) { + if (sub < this.rowsOf(row) - 1) sub += 1; + else if (row < this.lines.length) { row += 1; sub = 0; } + else break; + steps -= 1; + } + } + this.row = row; + this.column = sub * columns + x; + } + + /** Line feed: down a row (the next row of a wrapped line, or the next line), or a new line at the bottom. */ + private lineFeed(carriageReturn: boolean): void { + this.statusRow = false; + const x = this.screenColumn; + if (this.wraps && this.subRow < this.rowsOf(this.row) - 1) { + this.column = (this.subRow + 1) * this.screenColumns + (carriageReturn ? 0 : x); + return; + } + if (this.row < this.lines.length) this.row += 1; + else { + this.lines.push(this.current); + this.current = []; + this.row = this.lines.length; + } + this.column = carriageReturn ? 0 : x; + } + + /** + * Shorten a line to `length`, but never past the start of the cursor's row: that row stays (blank) on the screen, + * so it stays in the row count. + */ + private truncate(line: StyledLine, length: number): void { + line.splice(length); + const start = this.rowStart; + if (start > 0 && line.length <= start) { line.length = start + 1; line[start] = undefined; } + } + + /** NMSh wrote a line: the cursor returns below it and nothing at or above it can be redrawn. */ + private settle(): void { + this.row = this.lines.length; + this.fence = this.lines.length; + this.saved = undefined; + } + write(chunk: string): void { const input = this.pending + chunk; this.pending = ''; @@ -128,28 +305,26 @@ export class AnsiOutputParser { continue; } if (character === '\n') { - this.lines.push(this.current); - this.current = []; - this.column = 0; + this.lineFeed(true); this.ending = true; index += 1; continue; } if (character === '\r') { - this.column = 0; + this.column = this.rowStart; index += 1; continue; } if (character === '\b') { - this.column = Math.max(0, this.column - 1); + this.column = Math.max(this.rowStart, this.column - 1); index += 1; continue; } if (character === '\t') { - const spaces = 8 - (this.column % 8); + const spaces = 8 - (this.screenColumn % 8); for (let count = 0; count < spaces; count += 1) { this.put(' ', 1); - const cell = this.current[this.column - 1]!; + const cell = this.target[this.column - 1]!; cell.tab = true; if (count === 0) cell.tabSpan = spaces; } @@ -179,14 +354,19 @@ export class AnsiOutputParser { addLine(text: string, style = ''): void { this.ensureLineBoundary(); + // NMSh's own line is never a program's status row. + this.statusRow = false; this.hyperlink = undefined; this.style = style; this.write(text); + // Text with its own line breaks or row movement still ends on the newest line. + this.row = this.lines.length; this.lines.push(this.current); this.current = []; this.column = 0; this.style = ''; this.hyperlink = undefined; + this.settle(); } ensureLineBoundary(): void { @@ -195,6 +375,7 @@ export class AnsiOutputParser { this.current = []; this.column = 0; } + this.settle(); } replaceLine(index: number, text: string): void { @@ -205,6 +386,12 @@ export class AnsiOutputParser { const oldPending = this.pending; const oldHyperlink = this.hyperlink; const oldDiscarding = this.discarding; + const oldRow = this.row; + const oldFence = this.fence; + const oldSaved = this.saved; + const oldStatusRow = this.statusRow; + const oldLength = this.lines.length; + this.statusRow = false; this.current = []; this.column = 0; @@ -212,13 +399,15 @@ export class AnsiOutputParser { this.pending = ''; this.discarding = false; this.hyperlink = undefined; - + // Replacement text is one line of NMSh's own: it is written as the newest line and cannot reach other rows. + this.row = this.lines.length; + this.fence = this.lines.length; + this.saved = undefined; + this.write(text); - if (this.pending.length > 0) { - // Flush any remaining characters if write left pending state (e.g. incomplete escape) - // For simple replacement lines, this shouldn't happen unless malformed, but just in case. - } - this.lines[index] = this.current; + // A line break in replacement text would add lines; only the first one replaces the stored line. + const replaced = this.lines.length > oldLength ? this.lines.splice(oldLength)[0]! : this.current; + this.lines[index] = replaced; this.current = oldCurrent; this.column = oldColumn; @@ -226,6 +415,10 @@ export class AnsiOutputParser { this.pending = oldPending; this.hyperlink = oldHyperlink; this.discarding = oldDiscarding; + this.row = oldRow; + this.fence = oldFence; + this.saved = oldSaved; + this.statusRow = oldStatusRow; } completedCount(): number { @@ -284,12 +477,17 @@ export class AnsiOutputParser { const restored: StyledLine = line.map(cell => cell === null ? null : 'empty' in cell ? undefined : {...cell}); this.lines.push(restored); } + // A restored transcript is history; a resumed command reopens its own rows with fenceAt. + this.settle(); } trim(maxLines: number): number { if (this.lines.length <= maxLines) return 0; const removed = this.lines.length - maxLines; this.lines.splice(0, removed); + this.row = Math.max(0, this.row - removed); + this.fence = Math.max(0, this.fence - removed); + if (this.saved) this.saved = {...this.saved, row: Math.max(0, this.saved.row - removed)}; return removed; } @@ -305,7 +503,7 @@ export class AnsiOutputParser { } const params = match[1] ?? ''; const final = match[3] ?? ''; - this.applyCsi(params, final); + this.applyCsi(params, final, match[2] ?? ''); return {complete: true, next: start + match[0].length}; } if (kind === ']') { @@ -338,9 +536,23 @@ export class AnsiOutputParser { if (start + 1 + intermediates >= input.length || intermediates >= MAX_CONTROL_SEQUENCE - 1) return {complete: false, next: start}; return {complete: true, next: start + 1 + intermediates}; } + // Two-byte escapes that move within the live region; others (keypad modes, RIS…) are dropped as before. + if (kind === '7') this.saved = {row: this.row, column: this.column, style: this.style}; + else if (kind === '8') this.restoreCursor(true); + else if (kind === 'M') this.moveRows(-1); + else if (kind === 'D') { this.lineFeed(false); this.ending = true; } + else if (kind === 'E') { this.lineFeed(true); this.ending = true; } return {complete: true, next: Math.min(input.length, start + 2)}; } + private restoreCursor(withStyle: boolean): void { + this.statusRow = false; + if (!this.saved) return; + this.row = Math.max(this.topRow, Math.min(this.lines.length, this.saved.row)); + this.column = this.saved.column; + if (withStyle && this.saved.style !== undefined) this.style = this.saved.style; + } + /** * SGR in order: 0 resets where it appears, and the arguments of extended * colours (38/48/58 with 5;n or 2;r;g;b) are never read as attributes, so a @@ -368,28 +580,58 @@ export class AnsiOutputParser { if (kept.length > 0) this.style += `\u001B[${kept.join(';')}m`; } - private applyCsi(params: string, final: string): void { + private applyCsi(params: string, final: string, intermediates = ''): void { this.touch(); const values = params.replace(/^\?/u, '').split(';').map(value => Number(value || '0')); const amount = values[0] || 1; + // Row movement, save/restore and partial erases: plain forms only (a private marker or intermediate is another request). + const plain = !intermediates && !/^[<=>?]/u.test(params); if (final === 'm') { // A private marker makes it another request (`CSI > 4 ; 2 m` is xterm's modifyOtherKeys), not SGR. if (/^[<=>?]/u.test(params)) return; this.applySgr(params); } else if (final === 'K') { - if ((values[0] ?? 0) === 2) this.current = []; - else this.current.splice(this.column); + if (this.statusRow) return; + const line = this.target; + const mode = values[0] ?? 0; + // The cursor's row only: a long line's other rows are other rows on the screen. + const start = this.rowStart; + const end = this.wraps ? start + this.screenColumns : Number.POSITIVE_INFINITY; + const from = mode === 0 ? this.column : start; + const to = mode === 1 ? Math.min(this.column + 1, end) : end; + if (to >= line.length && mode !== 1) this.truncate(line, from); + else for (let position = from; position < to && position < line.length; position += 1) line[position] = undefined; + this.touch(line); + } else if (plain && (final === 'A' || final === 'B' || final === 'E' || final === 'F')) { + this.moveRows(final === 'A' || final === 'F' ? -amount : amount, final === 'E' || final === 'F'); + } else if (plain && final === 'r') { + // A scroll region ending above the last row reserves the rows below it (see statusRow); the region is not emulated. + const bottom = values[1] ?? 0; + this.regionBottom = bottom > 0 && bottom < this.screenRows ? bottom : undefined; + if (this.regionBottom === undefined) this.statusRow = false; + } else if (plain && (final === 'H' || final === 'f' || final === 'd')) { + // Absolute rows are not mapped (see the class comment); only a move below the program's scroll region matters. + this.statusRow = this.regionBottom !== undefined && (values[0] || 1) > this.regionBottom; + } else if (plain && params === '' && final === 's') { + this.saved = {row: this.row, column: this.column}; + } else if (plain && params === '' && final === 'u') { + this.restoreCursor(false); + } else if (plain && final === 'J' && (values[0] ?? 0) <= 1) { + this.eraseDisplay(values[0] ?? 0); } else if (final === 'G') { - this.column = Math.max(0, amount - 1); + this.column = this.rowStart + (this.wraps ? Math.min(amount - 1, this.screenColumns - 1) : amount - 1); } else if (final === 'C') { - this.column += amount; + this.column = this.wraps ? this.rowStart + Math.min(this.screenColumns - 1, this.screenColumn + amount) : this.column + amount; } else if (final === 'D') { - this.column = Math.max(0, this.column - amount); + this.column = this.rowStart + Math.max(0, this.screenColumn - amount); } else if (final === 'J') { if (amount === 2 || amount === 3) { this.lines.length = 0; this.current = []; this.column = 0; + this.row = 0; + this.fence = 0; + this.saved = undefined; this.onClear?.(); } } @@ -397,22 +639,48 @@ export class AnsiOutputParser { private put(text: string, width: number): void { this.ending = false; - for (let position = 0; position < width; position += 1) this.current[this.column + position] = undefined; - this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; + if (this.statusRow) return; + const line = this.target; + for (let position = 0; position < width; position += 1) line[this.column + position] = undefined; + line[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; this.touch(); - for (let position = 1; position < width; position += 1) this.current[this.column + position] = null; + for (let position = 1; position < width; position += 1) line[this.column + position] = null; this.column += width; + this.pendingWrap = this.wraps && this.column % this.screenColumns === 0 ? this.column : undefined; } - private touch(): void { revisions.set(this.current, lineRevision(this.current) + 1); } + private touch(line: StyledLine = this.target): void { revisions.set(line, lineRevision(line) + 1); } private findPreviousCell(): StyledCell | undefined { - for (let index = Math.min(this.column - 1, this.current.length - 1); index >= 0; index -= 1) { - const cell = this.current[index]; + const line = this.target; + for (let index = Math.min(this.column - 1, line.length - 1); index >= 0; index -= 1) { + const cell = line[index]; if (cell) return cell; } return undefined; } + + /** Erase in display within the live region: 0 below the cursor, 1 above it. */ + private eraseDisplay(mode: number): void { + if (this.statusRow) return; + const line = this.target; + if (mode === 0) { + this.truncate(line, this.column); + if (this.row < this.lines.length) { + // The cursor's row becomes the newest line; the program's rows below it are gone, as on the screen. + this.current = line; + this.lines.length = this.row; + } else this.touch(line); + this.touch(this.current); + return; + } + for (let row = this.topRow; row < this.row; row += 1) { + this.lines[row]!.length = 0; + this.touch(this.lines[row]!); + } + for (let position = 0; position <= this.column && position < line.length; position += 1) line[position] = undefined; + this.touch(line); + } } /** diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 1f3cff68..4ac1211d 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -13,6 +13,7 @@ import {type PromptSnapshot} from '../prompt/snapshot.js'; import {type TranscriptAppearance} from '../prompt/configuration.js'; import {TranscriptPresenter, type TranscriptView} from './TranscriptPresenter.js'; import {queueSummaryFrom, SUMMARY_LIMIT, type QueueSummary} from '../session/QueueSummary.js'; +import {recognizeOutput, type SemanticResult} from './semantic.js'; export {renderHistoricalContext} from './TranscriptPresenter.js'; @@ -155,6 +156,13 @@ export class OutputBuffer { /** How rows look; presentation state here is never serialized into transcripts. */ readonly presenter = new TranscriptPresenter(); private outputFolding: OutputFoldingMode = 'smart'; + /** Semantic Output (#386): readings of completed output, derived and never stored; details open/closed per block. */ + private semanticOutput = true; + private readonly semanticReadings = new WeakMap(); + 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(); @@ -196,6 +204,8 @@ export class OutputBuffer { private retireClearedBlocks(): void { this.completed.length = 0; this.userToggled.clear(); + this.semanticDetails.clear(); + this.trackAnchors.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -271,6 +281,8 @@ export class OutputBuffer { this.firstRun = undefined; this.parser.restore([]); this.completed.length = 0; + this.semanticDetails.clear(); + this.trackAnchors.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -352,9 +364,23 @@ export class OutputBuffer { const placed = startId >= after && outputStartId <= this.parser.completedCount() && (header || (startId === 0 && outputStartId === 0)); if (!placed) this.retireClearedBlocks(); else if (!header) this.active.cleared = true; + // The resumed command may go on redrawing its own rows (not NMSh's lines written into its block meanwhile). + let ownRows = placed ? outputStartId : 0; + for (let line = ownRows; line < this.parser.completedCount(); line += 1) if (this.lineTypes.has(line)) ownRows = line + 1; + this.parser.fenceAt(ownRows); return {cleared: Boolean(this.active.cleared)}; } + /** The PTY's rows, which bound how far a program's output can redraw rows above its newest line. */ + setScreenRows(rows: number): void { + this.parser.setScreenRows(rows); + } + + /** The PTY's columns: a program's long lines occupy rows at this width when it moves its cursor over them. */ + setScreenColumns(columns: number): void { + this.parser.setScreenColumns(columns); + } + updateCommandHighlight(startId: number, formattedLines: string[]): void { for (let i = 0; i < formattedLines.length; i++) { this.parser.replaceLine(startId + i, formattedLines[i] ?? ''); @@ -608,9 +634,65 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + // Only a transcript with /track blocks pays for the per-line lookup. + ...(this.trackAnchors.size ? {liveRows: (line: number, width: number) => { + const id = this.trackAnchors.get(line); + return id === undefined ? undefined : this.liveRowsProvider?.(id, width); + }} : {}), + semantic: record => { + const result = this.semanticFor(record); + return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; + }, }; } + setSemanticOutput(on: boolean): void { this.semanticOutput = on; } + + /** Who draws each /track block's live rows (presentation only; nothing it returns is stored). */ + setLiveRows(provider: (id: string, width: number) => readonly string[]): void { this.liveRowsProvider = provider; } + + /** + * A /track block: NMSh's lines for the command, stored once like any frontend block; the tracker's live result is + * drawn under its last line and replaced in place on every refresh. Returns that line's id. + */ + addTrackBlock(id: string, command: string, rows: readonly string[]): number { + this.addFrontendBlock(command, rows); + const anchor = this.parser.completedCount() - 1; + this.trackAnchors.set(anchor, id); + return anchor; + } + + + /** + * The block's Semantic Output reading, computed once per record from its stored output (never stored, so a resumed + * transcript reads it again lazily). Only shell commands with output are read; Off reads nothing. + */ + semanticFor(record: CompletedCommand): SemanticResult | undefined { + if (!this.semanticOutput || record.frontend || record.activities?.length || !record.output) return undefined; + let reading = this.semanticReadings.get(record); + if (reading === undefined) { + reading = recognizeOutput(record.command, record.output, record.exitCode) ?? null; + this.semanticReadings.set(record, reading); + } + // The usefulness gate: a short, passing output already reads well; a listing of failures is always worth it. + if (!reading || (reading.outcome === 'passed' && !reading.items.length && record.output.split('\n').length < 10)) return undefined; + return reading; + } + + /** Details start open when the raw output is folded (they are what it hides), closed when it is expanded. */ + semanticDetailsOpen(record: CompletedCommand, result = this.semanticFor(record)): boolean { + if (!result?.items.length) return false; + return this.semanticDetails.get(record.startId) ?? !record.expanded; + } + + toggleSemanticDetails(startId: number): boolean { + const record = this.completed.find(item => item.startId === startId); + const result = record && this.semanticFor(record); + if (!record || !result?.items.length) return false; + this.semanticDetails.set(startId, !this.semanticDetailsOpen(record, result)); + return true; + } + /** Important hidden lines of a collapsed block, by line id; computed on first draw and kept per block identity. */ private readonly highlightCache = new Map(); private foldHighlightsFor(record: CompletedCommand): readonly number[] { diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index da5f02f1..c120f96b 100644 --- a/src/output/OutputSelection.ts +++ b/src/output/OutputSelection.ts @@ -42,7 +42,7 @@ export interface OutputAnchor { offset: number; } -const rowKind = (row: WrappedRow) => row.isFoldHint ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; +const rowKind = (row: WrappedRow) => row.isFoldHint || row.derived ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; const stableRow = (row: WrappedRow | undefined) => row !== undefined && ((row.lineIndex ?? -1) >= 0 || row.blockStartId !== undefined || row.activityId !== undefined); const matchesAnchor = (row: WrappedRow, anchor: OutputAnchor) => row.lineIndex === anchor.lineIndex && row.blockStartId === anchor.blockStartId && row.activityId === anchor.activityId && rowKind(row) === anchor.kind; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index b4d4b6f2..e607b181 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -21,6 +21,8 @@ import {archiveColor, grayscaleArchiveColor} from '../prompt/snapshot.js'; import {isPromptRole, promptRoleColors} from '../prompt/prompt.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE, normalizeConnectorFade, type GitColorMode, type TranscriptAppearance} from '../prompt/configuration.js'; import type {CompletedCommand, HistoricalContextSnapshot, SecondaryActivity} from './OutputBuffer.js'; +import {semanticRows} from './semanticRows.js'; +import type {SemanticResult} from './semantic.js'; /** * History rules are UI chrome, resolved at use so themes apply. The shipped @@ -77,6 +79,10 @@ export interface TranscriptView { ownerOf: (lineIndex: number) => number | undefined; /** Line ids worth keeping in view inside a collapsed block's hidden middle (failures, diagnostics). */ foldHighlights?: (record: CompletedCommand) => readonly number[]; + /** A /track block's live rows under this stored line, replaced on every refresh; undefined for other lines. */ + liveRows?: (lineIndex: number, width: number) => readonly string[] | undefined; + /** The block's Semantic Output reading and whether its details are open (#386); undefined: raw only. */ + semantic?: (record: CompletedCommand) => {result: SemanticResult; open: boolean} | undefined; } /** Transient interaction state the app tracks; the presenter decides how it looks. */ @@ -138,7 +144,7 @@ export class TranscriptPresenter { } rows(view: TranscriptView, width: number): WrappedRow[] { - const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf} = view; + const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf, liveRows} = view; const column = this.layout === 'chat' ? chatColumn(width) : undefined; // A chat command block is right-aligned as a whole: its widest wrapped row sets the left edge. const blockWidths = new Map(); @@ -181,6 +187,9 @@ export class TranscriptPresenter { } const cmd = completed.find(c => c.outputStartId === i); + // Semantic Output: the reading's rows come first in the block's output area; the output below is unchanged. + const reading = cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId ? view.semantic?.(cmd) : undefined; + if (cmd && reading) result.push(...semanticRows(reading.result, reading.open, width, {lineIndex: cmd.outputStartId, commandIndex: completed.indexOf(cmd), startId: cmd.startId})); if (cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId) { const hiddenLines = cmd.endId - cmd.outputStartId; // Activity-bearing parents use their lifecycle row as the disclosure control below. @@ -278,6 +287,8 @@ export class TranscriptPresenter { else if (cmdIndex !== -1) row.commandIndex = cmdIndex; result.push(row); } + // A /track block's live result, under its stored line (derived: never stored, searched, selected or copied). + if (liveRows) for (const ansi of liveRows(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); } if (active) { for (const activity of active.activities) { diff --git a/src/output/TranscriptSearch.ts b/src/output/TranscriptSearch.ts index 58356794..b249a9a1 100644 --- a/src/output/TranscriptSearch.ts +++ b/src/output/TranscriptSearch.ts @@ -78,7 +78,7 @@ export function logicalLines(rows: readonly WrappedRow[], include: (row: Wrapped let current: LogicalLine | undefined; let currentIndex: number | undefined; rows.forEach((row, index) => { - if (row.isFilterHint || !include(row)) { current = undefined; currentIndex = undefined; return; } + if (row.isFilterHint || row.derived || !include(row)) { current = undefined; currentIndex = undefined; return; } if (current && row.lineIndex !== undefined && row.lineIndex === currentIndex) { current.offsets.push(current.text.length); current.rows.push(index); diff --git a/src/output/TranscriptSelection.ts b/src/output/TranscriptSelection.ts index c6fc39fe..2d20f914 100644 --- a/src/output/TranscriptSelection.ts +++ b/src/output/TranscriptSelection.ts @@ -52,7 +52,7 @@ export function selectedText(rows: readonly WrappedRow[], selection: TranscriptS let previousLine: number | undefined; for (let index = start; index <= end && index < rows.length; index += 1) { const row = rows[index]!; - if (row.isFilterHint) continue; + if (row.isFilterHint || row.derived) continue; const text = row.plain.slice(row.indent ?? 0); // Continuation rows of the same logical line are joined exactly; padding is trimmed once the line ends. if (row.lineIndex !== undefined && row.lineIndex === previousLine && lines.length) lines[lines.length - 1] += text; diff --git a/src/output/semantic.ts b/src/output/semantic.ts new file mode 100644 index 00000000..6d707f91 --- /dev/null +++ b/src/output/semantic.ts @@ -0,0 +1,293 @@ +/** + * Semantic Output (#386, first phase): a deterministic reading of a completed command's stored output, never a + * replacement for it. Each recognizer is a pure function of (command, output, exit code) that accepts only its own + * unmistakable evidence (a runner's own summary line, a compiler's own diagnostic format) and fails closed otherwise. + * Counts are the ones the tool printed, items are lines taken verbatim from the output, and nothing is inferred + * about state the output does not show. The raw output stays authoritative: this is presentation and an optional copy. + */ + +export interface SemanticItem { + /** The line as the tool printed it (trimmed), possibly joined with its location line. */ + text: string; + tone: 'failure' | 'warning'; +} + +export interface SemanticResult { + kind: 'tests' | 'diagnostics'; + /** Who produced the facts ("node --test", "pytest", "TypeScript"). */ + source: string; + outcome: 'passed' | 'failed'; + /** One line of facts, as printed ("1,797 passed · 2 failed · 25 skipped"). */ + overview: string; + /** Failing tests or diagnostics, at most MAX_ITEMS. */ + items: SemanticItem[]; + /** Items found beyond MAX_ITEMS. */ + omitted: number; + /** False when only part of a very large output was read. */ + complete: boolean; +} + +export const MAX_ITEMS = 20; +/** Output read for recognition: the end of the output, where runners and compilers summarize. */ +export const MAX_SEMANTIC_BYTES = 2 * 1024 * 1024; + +/** The last line that matches (runners print their summary at the end). */ +function findLast(lines: readonly string[], test: (line: string) => boolean): string | undefined { + for (let index = lines.length - 1; index >= 0; index -= 1) if (test(lines[index]!)) return lines[index]; + return undefined; +} + +const number = (value: string | undefined) => value === undefined ? undefined : Number(value.replace(/,/gu, '')); +const count = (value: number) => value.toLocaleString('en-US'); +const plural = (value: number, word: string) => `${count(value)} ${word}${value === 1 ? '' : 's'}`; + +function facts(parts: ReadonlyArray): string { + return parts.filter(([, value]) => value !== undefined).map(([label, value]) => `${count(value!)} ${label}`).join(' · '); +} + +/** + * Lines longer than this are not read: runner summaries and diagnostics are short, and every pattern below then runs + * in time linear in what it reads, whatever a hostile output contains. + */ +export const MAX_LINE = 2048; + +/** Items from lines `parse` accepts (it returns the item text), deduplicated and bounded. */ +function collect(lines: readonly string[], parse: (line: string) => string | undefined, tone: SemanticItem['tone'] = 'failure'): {items: SemanticItem[]; omitted: number} { + const seen = new Set(); + const items: SemanticItem[] = []; + let omitted = 0; + for (const line of lines) { + const text = parse(line)?.trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + if (items.length < MAX_ITEMS) items.push({text, tone}); else omitted += 1; + } + return {items, omitted}; +} + +/** `N word` counts in one summary line (only ever applied to the one line a recognizer picked). */ +const countIn = (line: string, word: string) => number(new RegExp(`(?:^|[^\\d,])(\\d{1,3}(?:,\\d{3})*|\\d{1,12}) ${word}\\b`, 'u').exec(line)?.[1]); + +/** `path:line:col` with a path that has no spaces; end-anchored with bounded digits, so linear. */ +const LOCATION_TAIL = /:\d{1,7}:\d{1,7}$/u; +const isLocation = (text: string) => text.length > 0 && !/\s/u.test(text) && LOCATION_TAIL.test(text); + +type Recognizer = (command: string, lines: readonly string[], exitCode: number) => Omit | undefined; + +/** Node's test runner: the spec reporter's `ℹ` summary, or TAP's `#` summary. */ +const nodeTest: Recognizer = (_command, lines) => { + for (const marker of ['ℹ', '#'] as const) { + const value = (name: string) => { + const prefix = `${marker} ${name} `; + const rest = findLast(lines, line => line.startsWith(prefix))?.slice(prefix.length).trim(); + return rest !== undefined && /^\d{1,12}$/u.test(rest) ? Number(rest) : undefined; + }; + const tests = value('tests'); + const pass = value('pass'); + const fail = value('fail'); + if (tests === undefined || pass === undefined || fail === undefined) continue; + const skipped = value('skipped'); + const todo = value('todo'); + const cancelled = value('cancelled'); + const section = lines.indexOf('✖ failing tests:'); + const failing = marker === 'ℹ' + ? collect(section >= 0 ? lines.slice(section + 1) : lines, line => line.startsWith('✖ ') && line !== '✖ failing tests:' ? line.slice(2).replace(/ \([\d.]{1,20}m?s\)$/u, '') : undefined) + : collect(lines, line => { + const head = /^not ok \d{1,9} - /u.exec(line); + if (!head) return undefined; + const text = line.slice(head[0].length); + const hash = text.indexOf(' #'); + // A TODO or SKIP directive is not a failure. + if (hash >= 0 && /^\s*(?:todo|skip)\b/iu.test(text.slice(hash + 2))) return undefined; + return hash >= 0 ? text.slice(0, hash) : text; + }); + return {kind: 'tests', source: 'node --test', outcome: fail > 0 || (cancelled ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', pass], ['failed', fail], ['skipped', skipped || undefined], ['todo', todo || undefined], ['cancelled', cancelled || undefined]]), ...failing}; + } + return undefined; +}; + +/** Jest's `Tests: … N total` line (and its `●` failure headings). */ +const jest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith('Tests:') && line.endsWith(' total') && /\d$/u.test(line.slice(0, -6))); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('● ') && !text.startsWith('● Console') ? text.slice(2) : undefined; + }); + return {kind: 'tests', source: 'Jest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** Vitest's `Tests 2 failed | 40 passed (42)` line and its FAIL lines. */ +const vitest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith(' ') && line.trimStart().startsWith('Tests ') && /\(\d{1,9}\)$/u.test(line)); + if (!summary || !lines.some(line => line.trimStart().startsWith('Test Files '))) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('FAIL ') ? text.slice(5).trimStart() : undefined; + }); + return {kind: 'tests', source: 'Vitest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** pytest's closing `=== 1 failed, 3 passed in 0.12s ===` line and its short summary. */ +const pytest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => { + if (!line.startsWith('=') || !line.endsWith('=')) return false; + const inner = line.replace(/^=+ /u, '').replace(/ =+$/u, ''); + const at = inner.lastIndexOf(' in '); + if (at < 0 || !/^[\d.]{1,12}s(?: \([\d:]{1,12}\))?$/u.test(inner.slice(at + 4))) return false; + return inner.slice(0, at).split(', ').every(part => /^\d{1,9} \w{1,20}$/u.test(part)); + }); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const errors = countIn(summary, 'errors?'); + const failing = collect(lines, line => { + const head = line.startsWith('FAILED ') ? 7 : line.startsWith('ERROR ') ? 6 : 0; + return head ? line.slice(head) : undefined; + }); + return {kind: 'tests', source: 'pytest', outcome: (failed ?? 0) + (errors ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['errors', errors], ['skipped', countIn(summary, 'skipped')], ['xfailed', countIn(summary, 'xfailed')]]), ...failing}; +}; + +/** cargo test's `test result:` lines (one per test binary; summed). */ +const cargoTest: Recognizer = (_command, lines) => { + const results = lines.map(line => /^test result: (?:ok|FAILED)\. (\d{1,9}) passed; (\d{1,9}) failed; (\d{1,9}) ignored;/u.exec(line)).filter(match => match !== null); + if (!results.length) return undefined; + const sum = (index: number) => results.reduce((total, match) => total + Number(match[index]), 0); + const failing = collect(lines, line => line.startsWith('test ') && line.endsWith(' ... FAILED') ? line.slice(5, -11) : undefined); + return {kind: 'tests', source: 'cargo test', outcome: sum(2) > 0 ? 'failed' : 'passed', + overview: facts([['passed', sum(1)], ['failed', sum(2)], ['ignored', sum(3) || undefined]]), ...failing}; +}; + +/** go test's per-package `ok` / `FAIL` lines and `--- FAIL:` tests. */ +const goTest: Recognizer = (command, lines) => { + if (!/^\s*(?:\S+\/)?go\s+test\b/u.test(command)) return undefined; + const ok = lines.filter(line => /^ok\s+\S+\s+(?:[\d.]+s|\(cached\))/u.test(line)).length; + const failedPackages = lines.filter(line => /^FAIL\s+\S+\s+(?:[\d.]+s|\[build failed\]|\[setup failed\])/u.test(line)).length; + if (!ok && !failedPackages) return undefined; + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('--- FAIL: ') ? text.slice(10).split(' ')[0] : undefined; + }); + return {kind: 'tests', source: 'go test', outcome: failedPackages > 0 ? 'failed' : 'passed', + overview: `${plural(ok, 'package')} ok · ${plural(failedPackages, 'package')} failed${failing.items.length ? ` · ${plural(failing.items.length + failing.omitted, 'failing test')}` : ''}`, ...failing}; +}; + +/** Commands that compile or check code: their diagnostics are the point even when they exit 0. */ +const BUILD = /^\s*(?:\S+\/)?(?:tsc|npx\s+tsc|vue-tsc|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|make|ninja|cmake|cargo\s+(?:build|check|clippy|run|test)|rustc|swift\s+build|xcodebuild|go\s+(?:build|vet)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck|check|lint|compile|tsc))\b/u; + +/** One TypeScript diagnostic, pretty (`file:1:2 - error TS…: …`) or plain (`file(1,2): error TS…: …`). */ +function typescriptDiagnostic(line: string): string | undefined { + for (const severity of ['error', 'warning'] as const) { + const pretty = line.indexOf(` - ${severity} TS`); + if (pretty > 0 && isLocation(line.slice(0, pretty))) { + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(pretty + severity.length + 4)); + if (rest) return `${line.slice(0, pretty)} ${rest[1]}: ${rest[2]}`; + } + const plain = line.indexOf(`): ${severity} TS`); + if (plain > 0) { + const head = /^(\S+)\((\d{1,7}),(\d{1,7})$/u.exec(line.slice(0, plain)); + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(plain + severity.length + 4)); + if (head && rest) return `${head[1]}:${head[2]}:${head[3]} ${rest[1]}: ${rest[2]}`; + } + } + return undefined; +} + +/** TypeScript diagnostics, and tsc's `Found N errors` line. */ +const typescript: Recognizer = (command, lines, exitCode) => { + const found = findLast(lines, line => /^Found \d{1,9} errors?(?: in .+)?\.?$/u.test(line)); + if (!found && !lines.some(line => typescriptDiagnostic(line) !== undefined)) return undefined; + if (!found && exitCode === 0 && !BUILD.test(command)) return undefined; + const items = collect(lines, typescriptDiagnostic); + const errors = number(found ? /^Found (\d{1,9}) errors?/u.exec(found)?.[1] : undefined) ?? items.items.length + items.omitted; + const files = found ? /in (\d{1,9}) files/u.exec(found)?.[1] : undefined; + return {kind: 'diagnostics', source: 'TypeScript', outcome: errors > 0 ? 'failed' : 'passed', + overview: `${plural(errors, 'error')}${files ? ` in ${files} files` : ''}`, ...items}; +}; + +/** The location of a Rust ` --> file:line:col` line, or undefined. */ +function rustLocation(line: string | undefined): string | undefined { + const text = line?.trim(); + return text?.startsWith('--> ') && isLocation(text.slice(4)) ? text.slice(4) : undefined; +} + +/** Rust: `error[E0308]: …` / `warning: …` headings with their ` --> file:line:col` location. */ +const rust: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => rustLocation(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + const items: SemanticItem[] = []; + let omitted = 0; + let errors = 0; + let warnings = 0; + for (let index = 0; index < lines.length; index += 1) { + const heading = /^(error|warning)(\[E\d{4}\])?: (.+)$/u.exec(lines[index]!); + if (!heading || /^(?:could not compile|aborting due to|`.+` \(.+\) generated \d+ warnings?)/u.test(heading[3]!)) continue; + const location = rustLocation(lines[index + 1]); + if (!location) continue; + if (heading[1] === 'error') errors += 1; else warnings += 1; + const text = `${location} ${heading[1]}${heading[2] ?? ''}: ${heading[3]}`; + if (items.length < MAX_ITEMS) items.push({text, tone: heading[1] === 'error' ? 'failure' : 'warning'}); else omitted += 1; + } + if (!errors && !warnings) return undefined; + return {kind: 'diagnostics', source: 'Rust', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), items, omitted}; +} + +/** One GCC/Clang diagnostic `file:line:col: error: …` as [location, severity, message]. */ +function cDiagnostic(line: string): [string, string, string] | undefined { + for (const severity of ['fatal error', 'error', 'warning'] as const) { + const at = line.indexOf(`: ${severity}: `); + if (at > 0 && isLocation(line.slice(0, at))) return [line.slice(0, at), severity, line.slice(at + severity.length + 4)]; + } + return undefined; +} + +/** GCC and Clang diagnostics. */ +const cCompilers: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => cDiagnostic(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + let errors = 0; + let warnings = 0; + for (const line of lines) { + const diagnostic = cDiagnostic(line); + if (diagnostic) { if (diagnostic[1] === 'warning') warnings += 1; else errors += 1; } + } + const failures = collect(lines, line => { + const diagnostic = cDiagnostic(line); + return diagnostic && `${diagnostic[0]} ${diagnostic[1]}: ${diagnostic[2]}`; + }); + for (const item of failures.items) if (/ warning: /u.test(item.text)) item.tone = 'warning'; + return {kind: 'diagnostics', source: 'GCC/Clang', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), ...failures}; +}; + +/** Most specific first: a runner's summary wins over diagnostics that happen to appear in its output. */ +const RECOGNIZERS: readonly Recognizer[] = [nodeTest, jest, vitest, pytest, cargoTest, goTest, typescript, rust, cCompilers]; + +/** The semantic reading of a completed output, or undefined when no recognizer is sure. */ +export function recognizeOutput(command: string, output: string, exitCode: number): SemanticResult | undefined { + if (!output) return undefined; + const complete = output.length <= MAX_SEMANTIC_BYTES; + const text = complete ? output : output.slice(-MAX_SEMANTIC_BYTES); + // trimEnd is linear (a `\s+$` pattern is not), and over-long lines are not read at all. + const lines = text.split('\n').map(line => line.length > MAX_LINE ? '' : line.trimEnd()); + const shown = command.length > 1024 ? command.slice(0, 1024) : command; + for (const recognizer of RECOGNIZERS) { + const result = recognizer(shown, lines, exitCode); + if (result) return {...result, complete}; + } + return undefined; +} + +/** Plain text of a reading, for `/copy --semantic` and `--both`: everything, whatever is folded on screen. */ +export function semanticText(result: SemanticResult): string { + const lines = [`${result.source}: ${result.overview}`, ...result.items.map(item => item.text)]; + if (result.omitted) lines.push(`… ${count(result.omitted)} more in the raw output`); + if (!result.complete) lines.push('(read from the last 2 MiB of a longer output)'); + return lines.join('\n'); +} diff --git a/src/output/semanticRows.ts b/src/output/semanticRows.ts new file mode 100644 index 00000000..4f17a87a --- /dev/null +++ b/src/output/semanticRows.ts @@ -0,0 +1,47 @@ +import {colorLevel} from '../presentation/capabilities.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateText} from '../util/text.js'; +import type {SemanticResult} from './semantic.js'; +import type {WrappedRow} from './viewport.js'; + +/** + * How a Semantic Output reading is drawn inside its block (#386): one overview row, then, when the reading lists + * failures or diagnostics, a disclosure row and (open) the items. The rows are derived presentation: never stored + * transcript lines, never in raw search, selection or /copy (which copy the output as it was). + */ + +const RESET = '\u001b[0m'; +const inert = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, '·'); + +function itemLabel(result: SemanticResult): string { + const total = result.items.length + result.omitted; + if (result.kind === 'tests') return `${total.toLocaleString('en-US')} failing test${total === 1 ? '' : 's'}`; + return `${total.toLocaleString('en-US')} diagnostic${total === 1 ? '' : 's'}`; +} + +export function semanticRows(result: SemanticResult, open: boolean, width: number, owner: {lineIndex: number; commandIndex: number; startId: number}): WrappedRow[] { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const passed = result.outcome === 'passed'; + const icon = passed ? GLYPHS.success : GLYPHS.failure; + const base = {lineIndex: owner.lineIndex, commandIndex: owner.commandIndex, derived: true as const}; + const overviewPlain = truncateText(` ${icon} ${result.source} · ${result.overview}${result.complete ? '' : ' · from the end of the output'}`, width); + const rows: WrappedRow[] = [{...base, plain: overviewPlain, + ansi: `${paint(passed ? 'success' : 'failure')}${overviewPlain.slice(0, 2 + icon.length + 1)}${RESET}${paint('secondary')}${overviewPlain.slice(2 + icon.length + 1)}${RESET}`}]; + if (!result.items.length) return rows; + const marker = open ? (getCurrentGlyphMode() === 'nerd' ? '⌄' : 'v') : GLYPHS.selection; + const disclosure = truncateText(` ${marker} ${itemLabel(result)}${open ? '' : ' · click or Actions to show'}`, width); + rows.push({...base, plain: disclosure, ansi: `${paint('subtle')}${disclosure}${RESET}`, semanticToggle: owner.startId}); + if (!open) return rows; + for (const item of result.items) { + const mark = item.tone === 'failure' ? GLYPHS.failure : '!'; + const plain = truncateText(` ${mark} ${inert(item.text)}`, width); + rows.push({...base, plain, ansi: `${paint(item.tone === 'failure' ? 'failure' : 'warning')}${plain.slice(0, 4 + mark.length)}${RESET}${plain.slice(4 + mark.length)}`}); + } + if (result.omitted) { + const plain = truncateText(` … ${result.omitted.toLocaleString('en-US')} more in the output below`, width); + rows.push({...base, plain, ansi: `${paint('subtle')}${plain}${RESET}`}); + } + return rows; +} diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 1dabbbab..89e4e46c 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,6 +20,11 @@ export interface WrappedRow { isFilterHint?: boolean; /** A row of the first-run welcome (0 is Vespyr's block above it), so the ending can reveal it line by line. */ firstRunLine?: number; + /** Derived presentation (a Semantic Output reading, a /track live result): never transcript content, so search, + * selection and copy skip it. */ + derived?: boolean; + /** Clicking this row opens or closes the Semantic Output details of the block with this startId. */ + semanticToggle?: number; } export interface StickyHeader { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7c174942..f5ecf371 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'; @@ -144,10 +145,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 @@ -586,6 +591,19 @@ 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'; +/** 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']; +/** The real browser (an installed Chromium, started only when /browser or a rendered preview needs it): Auto, or Off. */ +export type BrowserSetting = 'auto' | 'off'; +export const BROWSER_SETTINGS: readonly BrowserSetting[] = ['auto', 'off']; +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`). */ @@ -619,6 +637,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. */ @@ -669,6 +689,19 @@ 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; + /** 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; + /** Use an installed Chromium for /browser and rendered document previews; Off keeps NMSh's own renderer and disables /browser. */ + browser: BrowserSetting; + /** 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. */ @@ -689,6 +722,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: { @@ -865,6 +900,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { liveSessionMultiple: 'ask', notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', + semanticOutput: true, welcome: 'vespyr', suggestions: 'nmsh', history: 'native', @@ -883,6 +919,10 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { sessionNotices: true, copy: {...DEFAULT_COPY}, askRecord: true, + askCommandSuggestions: 'off', + graphicsMode: 'auto', + textSizing: 'auto', + browser: 'auto', askPresentation: 'chat' as const, localUnderstanding: {mode: 'auto', ask: true, folding: false}, agentActivity: true, @@ -895,6 +935,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: []}, @@ -986,6 +1027,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) @@ -1002,10 +1044,11 @@ 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, 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', browser: BROWSER_SETTINGS.includes(value.browser as BrowserSetting) ? value.browser as BrowserSetting : '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', + 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' @@ -1069,7 +1112,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 +1172,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, + glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } diff --git a/src/prompt/fitReport.ts b/src/prompt/fitReport.ts new file mode 100644 index 00000000..6304236b --- /dev/null +++ b/src/prompt/fitReport.ts @@ -0,0 +1,13 @@ +/** + * What a surface's width fitting did to routed modules in one live render (#365): which it left out and which it + * shortened. `/modules explain` reads the last frame's report; nothing is measured or collected to produce it. + */ +export interface FitReport {dropped: Set; 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 new file mode 100644 index 00000000..35663f30 --- /dev/null +++ b/src/prompt/moduleReport.ts @@ -0,0 +1,58 @@ +import {moduleDefinition} from '../context/modules.js'; +import {CORE_CAPABILITIES} from '../context/registry.js'; +import {routeModule, surfaceAvailability} from '../context/surfaceRouter.js'; +import {MODULE_SURFACE_LABELS, type ContextModuleConfig, type ModuleSurface, type PromptConfiguration} from './configuration.js'; +import {whyText, type ModulesContext} from './ModulesPanel.js'; + +/** + * Plain-text module diagnostics for the transcript (#365), in the spirit of `starship explain` and `starship timings`: + * `/modules explain` says, per configured module, where it is routed, whether it is on screen and why, and the state of + * the capabilities it reads; `/modules timings` lists the slowest last collections. Both read the engine's snapshots + * and the settings only: nothing is collected, run or read from disk to produce them, and no module value is shown + * (only states, ages, durations and the capabilities' own evidence summaries). + */ + +type Status = NonNullable>>; + +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(), + fit?: (id: string, surface: string) => 'dropped' | 'compacted' | undefined): string[] { + const rows: string[] = []; + for (const module of modules) { + const definition = moduleDefinition(module.id); + if (!definition) { rows.push(`${inert(module.id, 64)} · its Context Pack is not installed or enabled; nothing is collected`); continue; } + const surface = routeModule(module); + const availability = surface === 'hidden' ? undefined : surfaceAvailability(surface, configuration); + const sourced = [...definition.facts.keys()].map(id => ({id, status: context.status?.(id)})).filter((entry): entry is {id: string; status: Status} => Boolean(entry.status)); + const statuses = sourced.map(entry => entry.status); + const why = !module.visible ? 'turned off in Modules' + : availability && !availability.available ? availability.reason + : context.conditionMet?.(module) === false ? 'hidden: its condition does not match here' + : whyText(module, surface, statuses, definition.triggers); + const where = surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]; + // Width: what the last frame's fitting did on its surface (higher priorities are kept, then shortened, longest). + const fitted = module.visible && surface !== 'hidden' && (!availability || availability.available) ? fit?.(module.id, surface) : undefined; + const width = fitted === 'dropped' ? ' · left out for width in the last frame (higher-priority modules kept)' + : fitted === 'compacted' ? ' · shortened to fit the width in the last frame' : ''; + rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}${width}`); + const sources = sourced.map(({id, status}) => `${id} ${STATE[status.state] ?? status.state}${age(status, now)}${took(status)}${status.evidence ? ` (${inert(status.evidence, 80)})` : ''}`); + if (sources.length) rows.push(` reads ${sources.join('; ')}`); + } + if (!rows.length) rows.push('No modules are configured.'); + rows.push('States come from this window\'s Context Engine; producing this report collected nothing.'); + return rows; +} + +export function moduleTimings(status: (id: string) => Status | undefined, now = Date.now(), limit = 15): string[] { + const measured = CORE_CAPABILITIES.map(capability => ({id: capability.id, ...status(capability.id)})).filter((entry): entry is Status & {id: string} => entry.durationMs !== undefined) + .sort((a, b) => b.durationMs! - a.durationMs!); + if (!measured.length) return ['No module data has been collected in this window yet.']; + const rows = measured.slice(0, limit).map(entry => `${String(entry.durationMs! < 1 ? '<1' : Math.round(entry.durationMs!)).padStart(6)} ms ${entry.id} · ${STATE[entry.state] ?? entry.state}${age(entry, now)}`); + if (measured.length > limit) rows.push(`… ${measured.length - limit} faster ones not listed`); + rows.push('Wall time of each capability\'s last collection, excluding time queued; nothing was collected to show this.'); + return rows; +} diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index 906bc8d9..69e0c6fa 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -1,4 +1,5 @@ import {colorLevel} from '../presentation/capabilities.js'; +import type {FitReport} from './fitReport.js'; import {promptFacts, moduleFactContext, safeContextText, factAllowed} from '../context/facts.js'; import {routeModule} from '../context/surfaceRouter.js'; import {moduleDefinition} from '../context/modules.js'; @@ -644,7 +645,7 @@ export function buildContextLine( } /** Resolve appearance through the existing semantic theme context, restoring it after a chosen-theme render. */ -export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): string[] { +export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0, report?: FitReport): string[] { const rail = configuration.contextRail; if (configuration.provider !== 'nmsh' || rail.mode === 'off') return []; const saved = themeContext(); @@ -682,10 +683,15 @@ export function buildContextRail(context: PromptContext, width: number, configur compact[0] = true; } } + if (report) for (const group of groups) { + const index = rows.findIndex(row => row.includes(group[0]!)); + if (index === -1) report.dropped.add(group[0]!.id); else if (compact[index]) report.compacted.add(group[0]!.id); + } const paintedRows = rows.map((blocks, index) => { if (!blocks.length || width <= 0) return ''; const painted = paint(blocks, compact[index]!); if (displayWidth(painted) <= width) return painted; + for (const block of blocks) report?.compacted.add(block.id); if (mirrored) return fitRightPowerlineBlocks([...blocks].reverse(), width, fitted => paintPhysical(fitted, true)); return fitPowerlineBlocks(blocks, 0, 0, width, nmsh.endStyle, false, nmsh.startStyle, nmsh.connector, resolveConnectorFade('off', nmsh.connector), nmsh.connectorFadeColors, promptRenderExtras(draft, time)); diff --git a/src/prompt/railComposition.ts b/src/prompt/railComposition.ts index 0225491e..0eb5026e 100644 --- a/src/prompt/railComposition.ts +++ b/src/prompt/railComposition.ts @@ -1,6 +1,7 @@ import {colorLevel} from '../presentation/capabilities.js'; import type {PromptContext} from '../shell/ShellContext.js'; import type {PromptConfiguration} from './configuration.js'; +import type {FitReport} from './fitReport.js'; import {buildContextParts, buildContextRail, buildInlineContextPrefix, buildRightContext} from './prompt.js'; import {railPresentation, type RailPresentation} from './railLayout.js'; import {planScreen, regionOf, type ScreenPlan} from '../app/screenPlan.js'; @@ -12,11 +13,11 @@ const RESET = '\u001b[0m'; export interface PreparedRail {presentation: RailPresentation; content: string[]; time: number} /** Facts arrive resolved. This projection never requests or collects a value. */ -export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): PreparedRail { +export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0, report?: FitReport): PreparedRail { const enabled = configuration.provider === 'nmsh' && configuration.contextRail.mode !== 'off'; const parts = buildContextParts(context, columns, configuration, time); const budget = railPresentation(configuration, enabled ? configuration.contextRail.rows : 0, columns, parts); - const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time) : []; + const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time, report) : []; return {content, time, presentation: railPresentation(configuration, content.length, columns, parts)}; } diff --git a/src/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/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 2ab9d635..20373afd 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'; @@ -60,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); }); } @@ -78,11 +80,9 @@ export class InProcessSessionClient extends EventEmitter im } interrupt(): void { this.dispatcher.onInterrupt(); this.shell.interrupt(); } queue(change: QueueOp): void { this.dispatcher.request(change); } - async lookInput(): Promise { + async lookInput(): Promise { 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 b3d64110..b31b5ad5 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 @@ -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?.(); @@ -69,6 +76,8 @@ const defaultTimer = (callback: () => void, ms: number) => { export class InputWatch extends EventEmitter { 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; @@ -110,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). */ @@ -126,8 +136,24 @@ export class InputWatch extends EventEmitter { 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; + // 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 { this.reader = false; + this.done = false; this.closeWait(at, false); this.running = {command, since: at}; this.waitedMs = 0; @@ -165,6 +191,7 @@ export class InputWatch extends EventEmitter { /** 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; @@ -178,16 +205,27 @@ export class InputWatch extends EventEmitter { * 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 { + async lookNow(): Promise { + 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 || this.done) break; + 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 || this.done) break; await new Promise(done => { this.setTimer(done, FOLLOW_UP_MS); }); } + 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 { @@ -221,7 +259,7 @@ export class InputWatch extends EventEmitter { 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); @@ -229,32 +267,35 @@ export class InputWatch extends EventEmitter { 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 { - 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 { + if (this.disposed || !this.running || this.done || 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; + // 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; } /** 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); @@ -282,14 +323,24 @@ export class InputWatch extends EventEmitter { 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}; + // 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; - 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) 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 +354,26 @@ export class InputWatch extends EventEmitter { 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/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/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 { */ 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; + lookInput?(): Promise; /** Optional capabilities of whatever owns the shell (the service's welcome, or this build in-process). */ readonly features: ReadonlySet; /** Build of the session service, when it reported one. */ diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 638da23d..0244e760 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,9 +114,12 @@ 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} - | {type: 'input-state'; terminalOwner?: number; since?: number; confidence?: string; mode?: string; prompt?: string; program?: string; waitedMs: number; waits: 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; 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). */ @@ -203,7 +206,7 @@ const SHAPES: Record = { 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'}, @@ -222,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?'}, @@ -319,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)} : {})} : {})}; } @@ -329,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. */ @@ -354,7 +358,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); @@ -381,6 +387,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 { @@ -388,7 +398,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 8f506b39..0c269865 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': @@ -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 507d474d..c9d144fb 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'; @@ -143,6 +144,21 @@ 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) 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, 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. ' + 'Its live sessions keep running; once they end, the next nmsh launch starts the current service.'; @@ -219,7 +235,7 @@ export class SocketSessionClient extends EventEmitter imple features: ReadonlySet = new Set(); serviceBuild?: string; private pendingSwitch?: {resolve: (value: {shell: ShellId; pid: number}) => void; reject: (error: Error) => void}; - private looks = new Map void>(); + private looks = new Map void>(); private nextLook = 1; switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { @@ -249,7 +265,7 @@ export class SocketSessionClient extends EventEmitter 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 +286,7 @@ export class SocketSessionClient extends EventEmitter 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 +300,9 @@ export class SocketSessionClient extends EventEmitter 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 { + lookInput(): Promise { // 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..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; } @@ -47,7 +52,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/setup/SetupCat.ts b/src/setup/SetupCat.ts index 348a2e88..0b422235 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -264,8 +264,11 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ routeRow('setupPresets', 'Starting point', 'NMSh Recommended, Minimal Terminal, Customize Everything or Keep Existing · /startup', 'setupQuick', 'General'), {row: configRow('panelPosition'), note: draft => draft.panelPosition === 'top' ? 'Panels like this one sit at the top of the screen' : 'Panels like this one sit at the bottom of the screen'}, ]}, - {id: 'terminal', title: 'Terminal', intro: ['Glyphs your terminal font can draw.'], rows: [ + {id: 'terminal', title: 'Terminal', intro: ['Glyphs, images, sized headings and the browser your terminal can show.'], rows: [ {row: configRow('glyphStyle'), note: draft => draft.glyphStyle === 'nerd' ? 'Needs a Nerd Font in your terminal' : 'Works with any terminal font'}, + {row: configRow('graphicsMode')}, + {row: configRow('textSizing')}, + {row: configRow('browser')}, ]}, // Look: the theme first, then what depends on it (text, chrome), then Chroma, the one place it is chosen, with its // own rows under it. Motion-wide switches (Reduced Motion, Decorative effects) are on the Motion step. @@ -369,6 +372,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, @@ -414,6 +418,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')}, @@ -430,6 +435,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)), @@ -500,6 +508,7 @@ export const SETUP_EQUIVALENTS: Readonly> = { 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. */ @@ -507,7 +516,7 @@ export const SETUP_ENTRY_COVERAGE: Readonly> = { 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/src/shell/ConfiguredCompletion.ts b/src/shell/ConfiguredCompletion.ts index 582f5ea7..ecedeef1 100644 --- a/src/shell/ConfiguredCompletion.ts +++ b/src/shell/ConfiguredCompletion.ts @@ -4,6 +4,7 @@ import {mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, statSync} import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {removeDirectoryNow} from '../util/removeDirectory.js'; import {commandIdentity, completionLabel, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js'; const MAX_BYTES = 1024 * 1024; @@ -124,7 +125,8 @@ export class ConfiguredCompletionSource implements CompletionSource { } this.wait?.resolve(false); this.wait = undefined; - if (this.root) rmSync(this.root, {recursive: true, force: true}); + // The helper's own cleanup may be removing it, and a shell may still write its pid file, at this moment. + if (this.root) removeDirectoryNow(this.root); this.root = undefined; this.output = ''; } diff --git a/src/shell/SemanticService.ts b/src/shell/SemanticService.ts index e094f215..5ee2b819 100644 --- a/src/shell/SemanticService.ts +++ b/src/shell/SemanticService.ts @@ -46,6 +46,13 @@ export function parseCompletionFacts(line: string): CompletionFacts | undefined return {zshCompletions: zc === '1', fzfTab: ft === '1', completionSystem: cs === '1'}; } +/** How many times a lookup superseded by newer shell knowledge is asked again before it settles as unknown. */ +const REASKS = 3; +/** How long the helper may take to source the user's configuration before it is given up on. */ +const STARTUP_MS = 20_000; +/** How long one lookup may take once the helper reads requests. */ +const LOOKUP_MS = 1500; + export class SemanticService { private child: ReturnType; private pending = new Map void>(); @@ -65,8 +72,10 @@ export class SemanticService { this.shellNamesComplete = text.split('\n').includes('complete'); this.cache.clear(); for (const [name, type] of this.shellNames) this.cache.set(name, type); - for (const resolve of this.pending.values()) resolve('unknown'); + // Lookups in flight were asked against the old knowledge; each one asks again against this one (see lookup). + const superseded = [...this.pending.values()]; this.pending.clear(); + for (const resolve of superseded) resolve('unknown'); for (const resolve of this.rawPending.values()) resolve(''); this.rawPending.clear(); } @@ -130,7 +139,12 @@ PS1="" this.child.stdin!.on('error', () => { /* ignore EPIPE */ }); + // Lookups are timed from when the helper reads them: sourcing a slow configuration (nvm, a large framework) took + // longer than one lookup's limit, and the first lookup's timeout then ended the helper for the whole session. + this.startupTimer = setTimeout(() => { if (!this.ready) this.kill(); }, STARTUP_MS); + this.startupTimer.unref?.(); this.child.stdin!.write(` +builtin printf 'ready\\n' while builtin read -r id mode cmd; do if [[ $mode == source ]]; then nmsh_kind=$(builtin whence -w -- "$cmd" 2>/dev/null) @@ -178,6 +192,7 @@ done\n`); this.buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; + if (line === 'ready') { this.markReady(); continue; } const space = line.indexOf(' '); if (space === -1) continue; const idStr = line.substring(0, space); @@ -205,8 +220,32 @@ done\n`); } private isDead = false; + private ready = false; + private startupTimer?: NodeJS.Timeout; + /** Time limits of lookups sent before the helper was ready, started once it is. */ + private readonly whenReady: Array<() => void> = []; + + private markReady(): void { + if (this.ready) return; + this.ready = true; + clearTimeout(this.startupTimer); + for (const arm of this.whenReady.splice(0)) arm(); + } + + private armWhenReady(arm: () => void): void { + if (this.ready) arm(); else this.whenReady.push(arm); + } async classifyCommand(cmd: string): Promise { + 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]; @@ -220,18 +259,22 @@ done\n`); const id = this.nextId++; const generation = this.generation; return new Promise((resolve) => { - const timer = setTimeout(() => { - this.pending.delete(id); - this.kill(); - resolve('unknown'); - }, 1500); + let timer: NodeJS.Timeout | undefined; this.pending.set(id, (res) => { clearTimeout(timer); - if (generation !== this.generation) { resolve('unknown'); return; } + if (generation !== this.generation) { resolve(reasked < REASKS ? this.lookup(cmd, reasked + 1) : 'unknown'); return; } if (this.cache.size >= 8192) this.cache.delete(this.cache.keys().next().value!); this.cache.set(cmd, res); resolve(res); }); + this.armWhenReady(() => { + if (!this.pending.has(id)) return; + timer = setTimeout(() => { + this.pending.delete(id); + this.kill(); + resolve('unknown'); + }, LOOKUP_MS); + }); // Safety: cmd should not contain newlines or null bytes const safeCmd = cmd.replace(/[\r\n\0]/g, ''); try { @@ -250,9 +293,13 @@ done\n`); if (this.isDead || !this.child.stdin?.writable || this.rawPending.size >= 32) return Promise.resolve(undefined); const id = this.nextId++; return new Promise(resolve => { - const timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, 1500); - timer.unref?.(); + let timer: NodeJS.Timeout | undefined; this.rawPending.set(id, line => { clearTimeout(timer); resolve(line || undefined); }); + this.armWhenReady(() => { + if (!this.rawPending.has(id)) return; + timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, LOOKUP_MS); + timer.unref?.(); + }); try { this.child.stdin!.write(`${id} ${mode} ${word.replace(/[\r\n\0\s]/gu, '')}\n`); } catch { clearTimeout(timer); this.rawPending.delete(id); resolve(undefined); } }); @@ -279,6 +326,8 @@ done\n`); kill() { this.isDead = true; + clearTimeout(this.startupTimer); + this.whenReady.length = 0; try { this.child.stdin?.end(); } catch (e) { /* ignore */ } diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index 7e5455ea..d0574df7 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -10,7 +10,17 @@ 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} + /** + * 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}; @@ -81,6 +91,13 @@ 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 03b1594f..5a8b989a 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'; @@ -15,6 +15,9 @@ import {InputWatch} from '../session/InputWatch.js'; import type {InputState} from '../session/inputState.js'; import {systemProbe, type TerminalProbe} from '../session/terminalProbe.js'; +/** How long typing held after a command is done waits for the rest of its line before it is held as it is. */ +const HELD_LINE_SETTLE_MS = 250; + interface SessionEvents { data: [string]; prompt: [ShellMarker]; @@ -25,6 +28,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 +40,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 +70,23 @@ 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 = ''; + /** + * Typing that arrived after the command was done (#426): input reaches the session in whatever pieces the terminal + * delivered it, so it is put back together as the lines the person typed before it is held. A partial line waits for + * the rest of it, and is held as it is when the prompt comes or the person stops typing. + */ + private heldAfterDone = ''; + private heldTimer: NodeJS.Timeout | undefined; 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 +101,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,11 +198,42 @@ 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.holdAfterDone(data); 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); } + private holdAfterDone(data: string): void { + this.heldAfterDone += data; + if (this.heldTimer) { clearTimeout(this.heldTimer); this.heldTimer = undefined; } + const end = Math.max(this.heldAfterDone.lastIndexOf('\r'), this.heldAfterDone.lastIndexOf('\n')); + if (end >= 0) { + const lines = this.heldAfterDone.slice(0, end + 1); + this.heldAfterDone = this.heldAfterDone.slice(end + 1); + this.emit('unread', lines, this.program); + } + if (!this.heldAfterDone) return; + this.heldTimer = setTimeout(() => this.releaseHeldAfterDone(), HELD_LINE_SETTLE_MS); + this.heldTimer.unref?.(); + } + + private releaseHeldAfterDone(): void { + if (this.heldTimer) { clearTimeout(this.heldTimer); this.heldTimer = undefined; } + const text = this.heldAfterDone; + this.heldAfterDone = ''; + if (text && !this.exited) this.emit('unread', text, this.program); + } + interrupt(): void { // Input still held behind a terminal answer is discarded, as a terminal's Ctrl+C discards pending input: // nothing typed before the interrupt may reach the program after it. @@ -182,7 +243,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'); } @@ -211,6 +276,8 @@ export class ShellSession extends EventEmitter { this.order.dispose(); this.inputWatch.dispose(); if (this.startupTimer) { clearTimeout(this.startupTimer); this.startupTimer = undefined; } + if (this.heldTimer) { clearTimeout(this.heldTimer); this.heldTimer = undefined; } + this.heldAfterDone = ''; try { this.pty.kill(); } finally { this.cleanup(); } } @@ -227,8 +294,15 @@ 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.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) { @@ -246,7 +320,10 @@ export class ShellSession extends EventEmitter { this.pendingInput = ''; if (pending && !this.exited) this.pty.write(pending); } else { + this.releaseHeldAfterDone(); 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 79b6ca4f..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'; @@ -106,6 +106,16 @@ __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')} + # 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 d4ef5f6f..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'; @@ -29,12 +29,33 @@ 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']); -function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { +/** + * 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 = `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(context: LaunchContext, originalTerm: string | undefined): string { + const {token, knowledgePath, bridgeEnvPath} = context; 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 +# 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 @@ -86,6 +107,15 @@ 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')} + # 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 ca7fde3b..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 @@ -82,6 +82,22 @@ 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' + # 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 +} + function nmsh_precmd { local nmsh_status=$? nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} @@ -94,7 +110,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 +132,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/src/status/StatusStrip.ts b/src/status/StatusStrip.ts index 5297df13..40a48d6e 100644 --- a/src/status/StatusStrip.ts +++ b/src/status/StatusStrip.ts @@ -1,4 +1,5 @@ import {linuxBattery, parseMeminfo, parsePmset, parseVmStat, type SystemStats} from './systemStats.js'; +import type {FitReport} from '../prompt/fitReport.js'; import type {StatusStripSettings, StripZone} from '../prompt/configuration.js'; import type {ContextFacts} from '../context/facts.js'; import {safeContextText} from '../context/facts.js'; @@ -97,6 +98,8 @@ interface Entry { tone: 'secondary' | 'failure' | 'accent'; /** Keep Awake narrows only after everything else has gone (it outranks every other item). */ last?: boolean; + /** The routed module this entry shows, for the fit report. */ + id?: string; } /** Hostile module text loses whole control sequences, then is bounded like every other display string. */ @@ -105,7 +108,7 @@ const clean = (text: string) => safeContextText(stripTerminalControls(text), 120 function entries(settings: StatusStripSettings, stats: SystemStats, now: Date | undefined, awake: StripAwake | undefined, modules: readonly StripModuleItem[]): Entry[] { // Routed modules outlast the strip's own items; among them, higher module priority stays longest. const routed: Entry[] = modules.map(module => ({zone: module.zone ?? 'right', forms: [...new Set([clean(module.text), ...(module.compact ?? []).map(clean)])].filter(Boolean), - form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const})).filter(entry => entry.forms.length); + form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const, ...(module.id ? {id: module.id} : {})})).filter(entry => entry.forms.length); const native: Entry[] = stripItems(settings, stats, now).map(item => { const icon = item.icon ? semanticIcon(item.icon) : ''; return {zone: settings.nativeZone, forms: item.compact ? [item.text, item.compact] : [item.text], form: 0, ...(icon ? {icon} : {}), rank: item.priority, tone: 'secondary' as const}; @@ -191,10 +194,17 @@ function fit(all: Entry[], settings: StatusStripSettings, columns: number): Entr * room and otherwise pushed between its neighbours. */ export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats & {now?: number}, columns: number, now?: Date, awake?: StripAwake, - modules: readonly StripModuleItem[] = []): string { - if (!settings.enabled || columns < STRIP_MIN_COLUMNS) return ''; + modules: readonly StripModuleItem[] = [], report?: FitReport): string { + if (!settings.enabled) return ''; + if (columns < STRIP_MIN_COLUMNS) { for (const module of modules) if (module.id) report?.dropped.add(module.id); return ''; } const time = now ?? (stats.now !== undefined ? new Date(stats.now) : undefined); - const list = fit(entries(settings, stats, time, awake, modules), settings, columns); + const candidates = entries(settings, stats, time, awake, modules); + const list = fit(candidates, settings, columns); + if (report) for (const entry of candidates) { + if (!entry.id) continue; + const kept = list.find(item => item.id === entry.id); + if (!kept) report.dropped.add(entry.id); else if (kept.form > 0) report.compacted.add(entry.id); + } if (!list.length) return ''; const group = (zone: StripZone) => list.filter(entry => entry.zone === zone); const [left, center, right] = ZONES.map(zone => ({zone, painted: paintZone(zone, group(zone), settings), width: zoneWidth(zone, group(zone), settings)})); diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index f8461968..55f4f9f4 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -84,3 +84,30 @@ 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`; +} + +/** + * 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. + */ +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/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..fa7b0d15 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -3,22 +3,24 @@ import {renderControls} from '../ui/controls.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; +import {trackMark} from './trackBlock.js'; import type {WatchTask} from './WatchTasks.js'; -/** /watch: one live block per watch (current, previous, what changed), never a pile of repeated output. */ +/** /track and /watch share one live snapshot view: no duplicated transcript output. */ export interface WatchPanelState { selected: number; /** Showing the selected watch's current output. */ output: boolean; /** A watch waiting for the person's explicit Yes (unknown command, or a network target). */ - confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'}; + confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'; /** Started as /track: it gets a live transcript block. */ track?: boolean}; message?: string; } -export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now'; id: string} | {kind: 'confirm'}; +export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now' | 'copy'; id: string} | {kind: 'confirm'}; const formatEvery = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; const ago = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +const runStatus = (run: WatchTask['current']): string => !run ? 'waiting for first result' : run.timedOut ? 'timed out' : run.error ? 'process error' : run.signal ? `signal ${run.signal}` : `exit ${run.exitCode}`; export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonly WatchTask[]): WatchPanelAction | undefined { if (state.confirm) { @@ -37,6 +39,7 @@ export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonl const letter = key.value.toLowerCase(); if (letter === 'p') return {kind: watch.status === 'paused' ? 'resume' : 'pause', id: watch.id}; if (letter === 'r') return {kind: 'now', id: watch.id}; + if (letter === 'c') return {kind: 'copy', id: watch.id}; if (letter === 's') return {kind: 'stop', id: watch.id}; } return undefined; @@ -47,10 +50,20 @@ export function watchRow(watch: WatchTask, now: number): string { const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); const reset = '\u001b[0m'; - const state = watch.current ? (watch.current.exitCode === 0 ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; - const mark = watch.status === 'paused' ? '‖' : '◉'; - return `${state}${mark}${reset} ${secondary}Watching · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` - + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : ` · ${ago(now - watch.startedAt)}`}${reset}`; + const state = watch.current ? (watch.current.exitCode === 0 && !watch.current.timedOut && !watch.current.error ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; + const mark = trackMark(watch.status === 'paused' ? 'paused' : 'live'); + const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago · ${runStatus(watch.current)}` : ' · first run pending'; + return `${state}${mark}${reset} ${secondary}Track · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` + + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : last}${reset}`; +} + +/** The rows above the composer for active watches: one each for up to two, else one summary (each /track block and the manager show the rest). */ +export function watchNoticeRows(watches: readonly WatchTask[], now: number): string[] { + if (watches.length <= 2) return watches.map(watch => watchRow(watch, now)); + const failing = watches.filter(watch => watch.current && (watch.current.exitCode !== 0 || watch.current.timedOut || watch.current.error)).length; + const paused = watches.filter(watch => watch.status === 'paused').length; + const parts = [`${watches.length} trackers`, ...(failing ? [`${failing} failing`] : []), ...(paused ? [`${paused} paused`] : []), '/track opens the manager']; + return [`${foreground(failing ? UI_COLORS.failure : UI_COLORS.success)}${trackMark('live')}\u001b[0m ${foreground(UI_COLORS.secondary)}${parts.join(' · ')}\u001b[0m`]; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,17 +72,21 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const reset = '\u001b[0m'; - const rows = [`${primary} Watch${reset}`]; + const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { + if (columns < 40 || rowsAvailable < 10) { + return [`${primary} Resize to review tracked command (40 columns, 14 rows)${reset}`, + `${secondary} Confirmation is disabled at this size. Esc cancels.${reset}`].map(row => truncateAnsi(row, columns)); + } const yes = state.confirm.choice === 'yes'; - rows.push('', ` ${primary}Watch ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, + rows.push('', ` ${primary}${state.confirm.track ? 'Track' : 'Watch'} ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, ` ${subtle}It runs in ${state.confirm.cwd}, every ${formatEvery(state.confirm.intervalMs ?? 5000)}, until you stop it.${reset}`, '', ` ${!yes ? `${accent}${GLYPHS.selection} No${reset}` : `${subtle} No${reset}`} ${yes ? `${accent}${GLYPHS.selection} Yes${reset}` : `${subtle} Yes${reset}`}`, '', renderControls([['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']])); return rows.map(row => truncateAnsi(row, columns)); } if (!watches.length) { - rows.push('', ` ${secondary}Nothing is being watched.${reset}`, ` ${subtle}/watch git status · /watch --every 10s npm test · stop with /watch stop${reset}`); + rows.push('', ` ${secondary}No active command trackers.${reset}`, ` ${subtle}/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df${reset}`); if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); rows.push('', renderControls([['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); @@ -80,14 +97,14 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch if (state.output) { const lines = (watch.current?.output ?? '').split('\n'); const room = Math.max(4, rowsAvailable - rows.length - 3); - rows.push(` ${subtle}Current output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); + rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''} · ${runStatus(watch.current)}${watch.current?.truncated ? ' · output truncated' : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); } else { - rows.push(` ${subtle}Current${reset} ${secondary}${watch.summary ?? (watch.current ? `exit ${watch.current.exitCode}` : 'running…')}${reset}`); + rows.push(` ${subtle}Current${reset} ${secondary}${watch.current?.timedOut || watch.current?.error || watch.current?.signal ? runStatus(watch.current) : watch.summary ?? (watch.current ? runStatus(watch.current) : 'running…')}${watch.current?.truncated ? ' · truncated' : ''}${reset}`); if (watch.previous) rows.push(` ${subtle}Previous${reset} ${secondary}${watch.previousSummary ?? `exit ${watch.previous.exitCode}`}${reset}`); rows.push(` ${subtle}Changed${reset} ${watch.changes.length ? '' : `${secondary}nothing since the last run${reset}`}`, ...watch.changes.map(line => ` ${line.startsWith('✗') || line.startsWith('-') ? foreground(UI_COLORS.failure) : line.startsWith('✓') || line.startsWith('+') ? foreground(UI_COLORS.success) : secondary}${line}${reset}`)); rows.push(` ${subtle}Runs ${watch.runs} · ${ago(now - watch.startedAt)}${watch.network ? ' · repeated network access' : ''}${reset}`); } if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['S', 'stop'], ['Esc', 'close']])); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['C', 'copy latest'], ['S', 'stop'], ['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index d051bab1..ca021c43 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,9 @@ import {spawn, type ChildProcess} from 'node:child_process'; -import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {statSync} from 'node:fs'; +import {StringDecoder} from 'node:string_decoder'; +import {classifyCommand, maskQuoted, splitCommands, writesFiles, type PasteKind} from '../input/pasteGuard.js'; +import {reachesRemote, repeatRisk} from './repeatSafety.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -19,39 +23,75 @@ const REFUSED: ReadonlySet = new Set(['install', 'modifies', 'destruc export const DEFAULT_INTERVAL_MS = 5000; export const MIN_LOCAL_MS = 2000; export const MIN_REMOTE_MS = 15000; +export const MAX_INTERVAL_MS = 3_600_000; +export const MAX_ACTIVE_WATCHES = 8; +const MAX_COMMAND = 4096; const MAX_OUTPUT = 200_000; +const STOP_GRACE_MS = 1500; +const isDirectory = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } }; export function watchSafety(command: string): WatchSafety { + if (!command.trim() || command.length > MAX_COMMAND || /[\u0000-\u001f\u007f]/u.test(command)) { + return {kind: 'refused', reason: 'A tracked command must be nonempty, single-line and at most 4096 characters.'}; + } + // A harmless-looking first word is not evidence that embedded shell execution is safe. + // Fail closed rather than scheduling substitutions, backticks or process substitutions. + if (/\$\(|`|[<>]\(/u.test(command)) { + return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; + } + // Detached shell jobs would outlive the observed sample and multiply on every tick; heredocs also need a different + // stdin/input-ownership contract. `&&` and descriptor duplication (`2>&1`, `>&2`) are not background operators; `&>` + // is one for the POSIX sh that runs samples (dash reads `cmd &>file` as `cmd &` then `>file`), so it stays refused. + if (/(?:^|[^&<>])&(?!&)|< REFUSED.has(kind)); - if (bad) return {kind: 'refused', reason: `/watch won't repeatedly run a command that ${bad === 'navigation' ? 'changes directory' : bad === 'install' ? 'installs packages' : bad === 'privilege' ? 'runs as root' : bad === 'pipeline' ? 'runs downloaded code' : bad === 'destructive' ? 'deletes or destroys things' : 'modifies files'} (${part.text}).`}; + if (bad) return {kind: 'refused', reason: `NMSh won't repeatedly run a command that ${bad === 'navigation' ? 'changes directory' : bad === 'install' ? 'installs packages' : bad === 'privilege' ? 'runs as root' : bad === 'pipeline' ? 'runs downloaded code' : bad === 'destructive' ? 'deletes or destroys things' : 'modifies files'} (${part.text}).`}; + // Repetition has its own, stricter gate (repeatSafety.ts): a refusal there ends it; a doubt asks first. + const risk = repeatRisk(part.text, kinds); + if (risk?.kind === 'refused') return {kind: 'refused', reason: `NMSh won't repeatedly run a command that ${risk.reason}.`}; + if (risk) { unknown = true; reason ??= risk.reason; } if (kinds.includes('network')) { network = true; - const target = /https?:\/\/([^/:\s]+)/u.exec(part.text)?.[1] ?? /\s([\w.-]+\.[a-z]{2,})(?:\s|$)/iu.exec(part.text)?.[1]; - if (target && !/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/u.test(target)) remote = true; + if (reachesRemote(part.text, kinds)) remote = true; } - if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; + // Allow only Docker's explicit read-only disk report. Other Docker verbs (including + // 'system prune') still require confirmation, or are refused by the classifier. + const dockerDiskReport = /^docker\s+system\s+df(?:\s+(?:-v|--verbose))?\s*$/u.test(part.text); + if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !dockerDiskReport && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; } - return unknown ? {kind: 'confirm', reason: 'NMSh can\'t tell whether this command changes anything; it will run every interval.', network, remote} : {kind: 'allowed', network, remote}; + return unknown ? {kind: 'confirm', reason: `${reason ?? 'NMSh can\'t tell whether this command changes anything.'} It will run every interval.`, network, remote} : {kind: 'allowed', network, remote}; } -/** "--every 5s", "every 10 seconds", "-n 2" → milliseconds (clamped to the minimum for the command). */ -export function parseWatch(text: string): {command: string; intervalMs?: number} { - const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|minutes?|ms)?\s+/u.exec(text.trim()); - if (!every) return {command: text.trim()}; +/** Parse a finite interval strictly. Invalid cadence never falls through as shell text. */ +export function parseWatch(text: string): {command: string; intervalMs?: number; error?: string} { + const input = text.trim(); + const marker = /^(?:--every|-n|every)(?:\s+|$)/u; + if (!marker.test(input)) return {command: input.replace(/^--(?:\s+|$)/u, '').trim()}; + const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(ms|seconds?|secs?|s|minutes?|mins?|m)?(?:\s+|$)/u.exec(input); + if (!every) return {command: '', error: 'Invalid interval. Use /track --every 5s -- .'}; const value = Number(every[1]); const unit = every[2] ?? 's'; - const intervalMs = unit === 'ms' ? value : /^m/u.test(unit) && unit !== 'ms' ? value * 60_000 : value * 1000; - return {command: text.trim().slice(every[0].length).trim(), intervalMs}; + const multiplier = unit === 'ms' ? 1 : /^(?:m|min|minute)/u.test(unit) ? 60_000 : 1000; + const intervalMs = value * multiplier; + if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS) { + return {command: '', error: 'Interval must be positive and at most 60 minutes.'}; + } + const command = input.slice(every[0].length).replace(/^--(?:\s+|$)/u, '').trim(); + if (!command) return {command: '', error: 'Provide a finite command after the interval.'}; + return {command, intervalMs}; } -export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number} +export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number; truncated?: boolean; timedOut?: boolean; error?: string; signal?: string | null} export interface WatchTask { id: string; @@ -109,9 +149,19 @@ export class WatchTasks { readonly watches: WatchTask[] = []; private readonly timers = new Map(); 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>(); - constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv = process.env) {} + /** + * `env` may be a function, read at every sample: the window passes its launch environment with the live shell's + * PATH, so a tracked command finds what the shell finds (never its aliases, functions or unexported variables). + */ + constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv | (() => NodeJS.ProcessEnv) = process.env) {} + + /** Whether a sample of this watch is running now. */ + sampling(id: string): boolean { return this.children.has(id); } onChange(listener: (watch: WatchTask, transition?: 'pass' | 'fail') => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private emit(watch: WatchTask, transition?: 'pass' | 'fail'): void { for (const listener of this.listeners) listener(watch, transition); } @@ -121,6 +171,17 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude): WatchTask { + if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); + // Stopped rows do not appear in the manager; do not retain unlimited stale records. + if (this.watches.length >= 128) { + for (let index = 0; index < this.watches.length && this.watches.length > 64;) { + if (this.watches[index]?.status === 'stopped') this.watches.splice(index, 1); + else index += 1; + } + } + if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { + throw new Error('Invalid tracking interval.'); + } counter += 1; const minimum = safety.remote ? MIN_REMOTE_MS : MIN_LOCAL_MS; const watch: WatchTask = {id: `watch-${counter}`, command, cwd, intervalMs: Math.max(minimum, intervalMs ?? DEFAULT_INTERVAL_MS), startedAt: this.now(), runs: 0, @@ -131,30 +192,93 @@ export class WatchTasks { } /** Run once now (skipped while a run is still in flight: runs never overlap). */ - runNow(id: string): void { + runNow(id: string): boolean { const watch = this.get(id); - if (!watch || watch.status === 'stopped' || this.children.has(id)) return; + if (!watch || watch.status === 'stopped' || this.children.has(id)) return false; + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); const started = this.now(); let output = ''; + let receivedBytes = 0; + let truncated = false; + let timedOut = false; + let spawnError: string | undefined; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); let child: ChildProcess; try { - child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...this.env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); - } catch (error) { this.record(watch, {output: String(error), exitCode: -1, at: started, durationMs: 0}); return; } + const env = typeof this.env === 'function' ? this.env() : this.env; + child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); + } catch (error) { + this.record(watch, {output: stripTerminalControls(String(error), MAX_OUTPUT), exitCode: -1, at: started, durationMs: 0, error: 'spawn failed'}); + return true; + } this.children.set(id, child); - const take = (chunk: Buffer) => { if (output.length < MAX_OUTPUT) output += chunk.toString().slice(0, MAX_OUTPUT - output.length); }; - child.stdout?.on('data', take); - child.stderr?.on('data', take); - // A run never outlives a few intervals (bounded at a minute). - const limit = setTimeout(() => { try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, Math.min(60_000, watch.intervalMs * 3)); + const take = (decoder: StringDecoder, chunk: Buffer) => { + const kept = Math.min(chunk.length, Math.max(0, MAX_OUTPUT - receivedBytes)); + if (kept) { receivedBytes += kept; output += decoder.write(chunk.subarray(0, kept)); } + if (kept < chunk.length) truncated = true; + }; + child.stdout?.on('data', (chunk: Buffer) => take(stdoutDecoder, chunk)); + child.stderr?.on('data', (chunk: Buffer) => take(stderrDecoder, chunk)); + // Timeout is not a successful completion, even when the shell exits on a signal. + const limit = setTimeout(() => { + timedOut = true; + this.signalGroup(child, 'SIGKILL'); + // A detached descendant may retain pipe FDs after the tracked shell dies. + // Stop consuming it; the sample must not wait on an unrelated pipe holder. + child.stdout?.destroy(); + child.stderr?.destroy(); + }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); - const done = (code: number | null) => { + child.on('error', error => { + // Node names the shell when the working directory is what is missing; say which one it is. + spawnError = (error as NodeJS.ErrnoException).code === 'ENOENT' && !isDirectory(watch.cwd) ? `the directory ${watch.cwd} is no longer available` : String(error); + }); + // Only 'close' guarantees the captured stdio has finished, unlike 'exit'. + child.on('close', (code, signal) => { clearTimeout(limit); + const killTimer = this.killTimers.get(id); + if (killTimer) clearTimeout(killTimer); + this.killTimers.delete(id); + this.endStragglers(child); if (this.children.get(id) !== child) return; this.children.delete(id); - this.record(watch, {output: output.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, ''), exitCode: code, at: started, durationMs: this.now() - started}); - }; - child.on('error', () => done(-1)); - child.on('exit', code => done(code)); + output += stdoutDecoder.end() + stderrDecoder.end(); + const sanitized = output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'); + this.record(watch, {output: sanitized, exitCode: code ?? -1, at: started, durationMs: this.now() - started, + ...(truncated ? {truncated} : {}), ...(timedOut ? {timedOut} : {}), + ...(spawnError ? {error: stripTerminalControls(spawnError, 300)} : {}), + ...(signal ? {signal} : {})}); + }); + // The presenter shows `refreshing…` over the previous result from now on; told only once the sample is wired, so + // a listener that throws cannot leave a child without its handlers. + this.emit(watch); + return true; + } + + /** + * A finished sample's process group must be empty: anything left (a child that ignored TERM, or one that closed its + * output and kept running) outlived its sample and would pile up on every refresh. End it with TERM, then KILL after + * the grace period. The KILL timer keeps the window alive that long when it is closing, so nothing is left behind. + */ + private endStragglers(child: ChildProcess): void { + const group = child.pid; + if (!group) return; + try { process.kill(-group, 0); } catch { return; } + try { process.kill(-group, 'SIGTERM'); } catch { return; } + const timer = setTimeout(() => { + this.stragglers.delete(timer); + try { process.kill(-group, 'SIGKILL'); } catch { /* already gone */ } + }, STOP_GRACE_MS); + this.stragglers.add(timer); + } + + private signalGroup(child: ChildProcess, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch { try { child.kill(signal); } catch { /* already exited */ } } } private record(watch: WatchTask, run: WatchRun): void { @@ -200,15 +324,28 @@ export class WatchTasks { return true; } - /** Stop: no more runs, the in-flight run's process group is ended, nothing is left behind. */ + /** Stop scheduling, signal the owned process group, and bound graceful termination. */ stop(id: string): boolean { const watch = this.get(id); if (!watch || watch.status === 'stopped') return false; watch.status = 'stopped'; this.schedule(watch); const child = this.children.get(id); - this.children.delete(id); - if (child?.pid) { try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } } + if (child) { + this.signalGroup(child, 'SIGTERM'); + const pending = this.killTimers.get(id); + if (pending) clearTimeout(pending); + const killTimer = setTimeout(() => { + this.killTimers.delete(id); + if (this.children.get(id) === child) { + this.signalGroup(child, 'SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + } + }, STOP_GRACE_MS); + killTimer.unref(); + this.killTimers.set(id, killTimer); + } this.emit(watch); return true; } diff --git a/src/tasks/repeatSafety.ts b/src/tasks/repeatSafety.ts new file mode 100644 index 00000000..865818c6 --- /dev/null +++ b/src/tasks/repeatSafety.ts @@ -0,0 +1,189 @@ +import {commandWords, type PasteKind} from '../input/pasteGuard.js'; + +/** + * /track and /watch run a command again and again with nobody reading each run. The shared classifier says what a + * command is; this is the stricter gate for repeating it: whether it may start at once, needs an explicit Yes, or is + * never repeated. When it cannot tell, it asks rather than allows. Network commands start at once only when they are + * read-only probes (ping, dig, `nc -z`, a curl GET or a wget to standard output); everything that sends data, saves + * files, signs in, publishes or runs another command asks first. + */ +export type RepeatRisk = {kind: 'refused' | 'confirm'; reason: string}; + +const LOOPBACK = /^(?:localhost|localhost\.localdomain|ip6-localhost|127(?:\.\d{1,3}){3}|::1|0\.0\.0\.0)$/u; +const PROBES = new Set(['ping', 'ping6', 'dig', 'nslookup', 'host', 'traceroute', 'traceroute6', 'mtr']); +const NETCAT = new Set(['nc', 'ncat', 'netcat']); +const REMOTE_SESSIONS = new Set(['ssh', 'sftp', 'telnet', 'ftp', 'mosh']); +const HTTP_CLIENTS = new Set(['http', 'https', 'xh', 'httpie']); +const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']); +const PACKAGE_QUERIES = new Set(['view', 'info', 'show', 'search', 'outdated', 'fund', 'audit']); +const POWER = /^(?:shutdown|reboot|halt|poweroff)$/u; +const POWER_VERBS = /^(?:reboot|poweroff|halt|suspend|hibernate|hybrid-sleep|kexec|soft-reboot)$/u; +/** curl short options that take a value (the rest of the cluster, or the next word). */ +const CURL_VALUE = new Set([...'oDcKXdFTHwAmueEbrxYyzCPUQt']); +/** wget short options that take a value. */ +const WGET_VALUE = new Set([...'OoaPtTweiBUQlARDIX']); +const ASSIGNMENT = /^[A-Za-z_]\w*=/u; + +const refused = (reason: string): RepeatRisk => ({kind: 'refused', reason}); +const confirm = (reason: string): RepeatRisk => ({kind: 'confirm', reason}); +const standardStream = (value: string | undefined) => value === '-' || value === '/dev/null' || value === '/dev/stdout'; +const cluster = (arg: string) => /^-[A-Za-z0-9]+$/u.test(arg) ? arg.slice(1) : ''; + +/** The command's name and arguments, after any leading `NAME=value` assignments. */ +function parts(text: string): {assignments: string[]; name: string; args: string[]} { + const all = commandWords(text); + let index = 0; + while (ASSIGNMENT.test(all[index] ?? '')) index += 1; + const [command = '', ...args] = all.slice(index); + return {assignments: all.slice(0, index), name: command.split('/').pop() ?? command, args}; +} + +/** What repeating one command (one part of a command line) would risk beyond what the classifier already refuses. */ +export function repeatRisk(text: string, kinds: readonly PasteKind[]): RepeatRisk | undefined { + const {assignments, name, args} = parts(text); + if (POWER.test(name) || (/^(?:systemctl|loginctl|launchctl)$/u.test(name) && args.some(arg => POWER_VERBS.test(arg))) || (name === 'init' && /^[06]$/u.test(args[0] ?? ''))) { + return refused(`shuts down or restarts the computer (${text})`); + } + const writes = writesThroughOptions(name, args, text); + if (writes) return refused(writes); + if (args.some(arg => /^--watch(?:All)?(?:=true)?$/u.test(arg))) return refused(`runs until it is stopped (${text}); /track repeats a command that finishes`); + if (assignments.length) return confirm(`It sets ${assignments.map(word => `${word.split('=')[0]}=…`).join(' ')} for the command, which can change what it runs.`); + if (name === 'env') { + const inner = envCommand(args); + if (inner) return confirm(`It runs another command (env … ${inner}).`); + } + if (name === 'git') return gitRisk(args, kinds); + if (PROBES.has(name)) return undefined; + if (NETCAT.has(name)) return args.some(arg => cluster(arg).includes('z')) ? undefined : confirm('It opens a network connection every interval.'); + if (name === 'curl') return curlRisk(args); + if (name === 'wget') return wgetRisk(args); + if (REMOTE_SESSIONS.has(name)) return confirm('It connects to another computer every interval.'); + if (HTTP_CLIENTS.has(name)) return confirm('It sends HTTP requests that may change data.'); + if (PACKAGE_MANAGERS.has(name) && kinds.includes('network')) { + const sub = args.find(arg => !arg.startsWith('-')) ?? ''; + if (PACKAGE_QUERIES.has(sub) && !(sub === 'audit' && args.some(arg => arg === 'fix' || arg === '--fix'))) return undefined; + return confirm(`It publishes, signs in, changes packages or downloads and runs one (${name} ${sub}).`); + } + if (kinds.includes('network') && !/^(?:npx|pnpx|bunx)$/u.test(name)) return confirm('It contacts the network every interval and NMSh can\'t tell what it sends.'); + return undefined; +} + +/** Options that make an otherwise read-only command write files, delete them or update what it checks. */ +function writesThroughOptions(name: string, args: readonly string[], text: string): string | undefined { + const has = (pattern: RegExp) => args.some(arg => pattern.test(arg)); + if (name === 'git' && has(/^--output(?:=|$)/u)) return `writes files (${text})`; + if (name === 'sort' && (has(/^--output(?:=|$)/u) || args.some(arg => cluster(arg).includes('o')))) return `writes files (${text})`; + if (name === 'find' && has(/^-(?:fprint0?|fprintf|fls|delete|exec|execdir|ok|okdir)$/u)) return `writes, deletes or runs things (${text})`; + if (name === 'tree' && has(/^-o$/u)) return `writes files (${text})`; + if (name === 'yq' && has(/^(?:-i|--inplace)$/u)) return `modifies files (${text})`; + if (name === 'scp') return `copies files (${text})`; + if (name === 'rsync' && !(has(/^--(?:dry-run|list-only)$/u) || args.some(arg => cluster(arg).includes('n')))) return `copies files (${text})`; + if (has(/^(?:--fix|--write|--update|--update-?snapshots?|--updateSnapshot|--snapshot-update)(?:=.*)?$/u)) return `rewrites files (${text})`; + if (args.includes('-u') && /\b(?:jest|vitest)\b|^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/u.test(text)) return `updates snapshots (${text})`; + return undefined; +} + +/** The command `env` would run, if any (options and assignments alone only print the environment). */ +function envCommand(args: readonly string[]): string | undefined { + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (arg === '--') return args[at + 1]; + if (/^-[uC]$/u.test(arg)) { at += 1; continue; } + if (/^-\w*S|^--split-string/u.test(arg)) return arg; + if (arg.startsWith('-') || ASSIGNMENT.test(arg)) continue; + return arg; + } + return undefined; +} + +function gitRisk(args: readonly string[], kinds: readonly PasteKind[]): RepeatRisk | undefined { + // Global options come before the subcommand; `-c` there sets configuration (which can name programs to run). + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (!arg.startsWith('-')) break; + if (arg === '-c' || /^--(?:config-env|exec-path)(?:=|$)/u.test(arg)) return confirm('It sets git options that can run other programs.'); + if (arg === '-C') at += 1; + } + if (args.includes('--ext-diff')) return confirm('It runs an external diff program.'); + if (kinds.includes('network') && args.find(arg => !arg.startsWith('-')) === 'fetch') return confirm('It fetches from a remote and updates its branches every interval.'); + return undefined; +} + +function curlRisk(args: readonly string[]): RepeatRisk | undefined { + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + const scheme = /^([a-z][a-z0-9+.-]*):\/\//iu.exec(arg)?.[1]?.toLowerCase(); + if (scheme && scheme !== 'http' && scheme !== 'https') return confirm(`It uses ${scheme}://, which can send commands, not only read.`); + if (arg.startsWith('--')) { + const equals = arg.indexOf('='); + const option = equals === -1 ? arg : arg.slice(0, equals); + const value = equals === -1 ? args[at + 1] : arg.slice(equals + 1); + if (/^--(?:data|form|upload-file|json|remote-name|remote-header-name|output-dir|create-dirs|cookie-jar|config|trace|libcurl|etag-save|hsts|alt-svc|mail-|quote|post30)/u.test(option)) { + return confirm(`It sends data or saves files (${option}).`); + } + if (option === '--request' && !/^(?:GET|HEAD)$/iu.test(value ?? '')) return confirm(`It sends ${value ?? 'other'} requests.`); + if ((option === '--output' || option === '--dump-header') && !standardStream(value)) return confirm(`It saves files (${option}).`); + continue; + } + if (!/^-[^-]/u.test(arg)) continue; + for (let index = 1; index < arg.length; index += 1) { + const letter = arg[index]!; + if ('dFTJOKQ'.includes(letter)) return confirm(`It sends data or saves files (-${letter}).`); + if (!CURL_VALUE.has(letter)) continue; + const value = index + 1 < arg.length ? arg.slice(index + 1) : args[at + 1]; + if (letter === 'X' && !/^(?:GET|HEAD)$/iu.test(value ?? '')) return confirm(`It sends ${value ?? 'other'} requests.`); + if ('oDc'.includes(letter) && !standardStream(value)) return confirm(`It saves files (-${letter}).`); + if (index + 1 >= arg.length) at += 1; + break; + } + } + return undefined; +} + +function wgetRisk(args: readonly string[]): RepeatRisk | undefined { + let toOutput = false; + for (let at = 0; at < args.length; at += 1) { + const arg = args[at]!; + if (arg.startsWith('--')) { + const equals = arg.indexOf('='); + const option = equals === -1 ? arg : arg.slice(0, equals); + const value = equals === -1 ? args[at + 1] : arg.slice(equals + 1); + if (option === '--spider') toOutput = true; + else if (option === '--output-document') toOutput = standardStream(value); + else if (/^--(?:post-|body-|method|recursive|mirror|page-requisites|convert-links|timestamping|continue|force-directories|save-cookies|warc-|backups)/u.test(option)) return confirm(`It sends data or saves files (${option}).`); + else if (/^--(?:output-file|append-output)$/u.test(option) && !standardStream(value)) return confirm(`It writes a log file (${option}).`); + continue; + } + if (!/^-[^-]/u.test(arg)) continue; + for (let index = 1; index < arg.length; index += 1) { + const letter = arg[index]!; + if ('rmpkNxc'.includes(letter)) return confirm(`It saves files (-${letter}).`); + if (!WGET_VALUE.has(letter)) continue; + const value = index + 1 < arg.length ? arg.slice(index + 1) : args[at + 1]; + if (letter === 'O') toOutput = standardStream(value); + if ('oa'.includes(letter) && !standardStream(value)) return confirm(`It writes a log file (-${letter}).`); + if (index + 1 >= arg.length) at += 1; + break; + } + } + return toOutput ? undefined : confirm('It saves what it downloads to a file every interval.'); +} + +/** + * Whether a network command reaches beyond this computer: only loopback targets are local. Registries and git + * remotes are remote; a command whose target NMSh cannot find is treated as remote. + */ +export function reachesRemote(text: string, kinds: readonly PasteKind[]): boolean { + const {name, args} = parts(text); + if (/^(?:npx|pnpx|bunx)$/u.test(name)) return false; + if (PACKAGE_MANAGERS.has(name) || name === 'git' || name === 'gh') return kinds.includes('network'); + const hosts: string[] = []; + for (const arg of args) { + if (arg.startsWith('-')) continue; + const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]*\]|[^:/?#]*)/iu.exec(arg); + if (url) { hosts.push(url[1] ?? ''); continue; } + const host = /^@?(?:[^@\s]+@)?(\[[0-9a-fA-F:]+\]|[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)(?::\S*)?$/u.exec(arg)?.[1]; + if (host && !/^\d+(?:\.\d+)?$/u.test(host)) hosts.push(host); + } + return !hosts.length || hosts.some(host => !LOOPBACK.test(host.replace(/^\[|\]$/gu, '').toLowerCase())); +} diff --git a/src/tasks/trackBlock.ts b/src/tasks/trackBlock.ts new file mode 100644 index 00000000..74bdf048 --- /dev/null +++ b/src/tasks/trackBlock.ts @@ -0,0 +1,97 @@ +import {displaySafe} from '../input/PasteReview.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi, truncateText} from '../util/text.js'; +import type {WatchRun, WatchTask} from './WatchTasks.js'; + +/** + * A /track block's live rows (#393), drawn under its stored lines and replaced on every refresh: a status row that + * says only what is known (first run pending, refreshing, when the last result completed and how, paused, stale, + * stopped), then the latest completed result. The result keeps a stable height (the largest seen, at most + * MAX_TRACK_ROWS) so a shorter refresh never moves what is below it; a longer one says how much is not shown. + */ + +export const MAX_TRACK_ROWS = 12; +const RESET = '\u001b[0m'; + +/** A tracker's state mark; Safe glyphs use ASCII. */ +const MARKS = {live: ['◉', '*'], stopped: ['■', '#'], paused: ['‖', '='], pending: ['◌', 'o']} as const; +export const trackMark = (state: keyof typeof MARKS): string => MARKS[state][getCurrentGlyphMode() === 'nerd' ? 0 : 1]; + +const clock = (at: number) => new Date(at).toLocaleTimeString('en-GB', {hour12: false}); +const every = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; +const span = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; + +/** How a completed sample ended, in words: never a healthy result when it was not one. */ +export function runOutcome(run: WatchRun): {text: string; ok: boolean} { + if (run.timedOut) return {text: `timed out after ${span(run.durationMs)}`, ok: false}; + if (run.error) return {text: `could not run: ${run.error}`, ok: false}; + if (run.signal) return {text: `ended by ${run.signal}`, ok: false}; + return run.exitCode === 0 ? {text: 'exit 0', ok: true} : {text: `exit ${run.exitCode}`, ok: false}; +} + +export function trackStatus(watch: WatchTask, now: number, sampling: boolean): string { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const run = watch.current; + const outcome = run && runOutcome(run); + const finished = run ? run.at + run.durationMs : undefined; + const parts: string[] = []; + let mark: string; + let role: keyof typeof UI_COLORS = 'subtle'; + if (watch.status === 'stopped') { mark = trackMark('stopped'); parts.push('stopped'); } + else if (watch.status === 'paused') { mark = trackMark('paused'); parts.push('paused'); } + else if (!run) { mark = trackMark('pending'); parts.push(sampling ? 'first run…' : 'first run pending'); } + else { mark = trackMark('live'); role = outcome!.ok ? 'success' : 'failure'; parts.push('live'); } + parts.push(`every ${every(watch.intervalMs)}`); + if (run && outcome) parts.push(`updated ${clock(finished!)}`, outcome.text); + if (run?.truncated) parts.push('output cut at 200 kB'); + if (watch.status === 'running' && run && now - finished! > watch.intervalMs * 2 + 5000) parts.push(`no new result for ${span(now - finished!)}`); + if (watch.status === 'running' && run && sampling) parts.push('refreshing…'); + parts.push(`${watch.runs} run${watch.runs === 1 ? '' : 's'}`); + return `${paint(role)}${mark}${RESET} ${paint('secondary')}${parts.join(' · ')}${RESET}`; +} + +/** A result's first rows (inert, cut far past any terminal width) and its line count: once per result, not per frame. */ +const resultCache = new WeakMap(); +function resultLines(run: WatchRun): {head: string[]; total: number} { + let cached = resultCache.get(run); + if (!cached) { + const lines = run.output.replace(/\n$/u, '').split('\n'); + cached = {head: lines.slice(0, MAX_TRACK_ROWS).map(line => displaySafe(line.slice(0, 4096))), total: lines.length}; + resultCache.set(run, cached); + } + return cached; +} + +export function trackBlockRows(watch: WatchTask | undefined, width: number, now: number, reserved: number, sampling = false): {rows: string[]; reserved: number} { + const color = colorLevel() !== 'none'; + const subtle = color ? foreground(UI_COLORS.subtle) : ''; + const fit = (text: string) => truncateText(text, Math.max(1, width - 4)); + if (!watch) return {rows: [` ${subtle}${trackMark('stopped')} not running: trackers stop with the window that started them${RESET}`], reserved: 0}; + const status = truncateAnsi(` ${trackStatus(watch, now, sampling)}`, width); + // Stopped by the person: the final result was written into the transcript; the live rows end here. + if (watch.status === 'stopped') return {rows: [status], reserved: 0}; + const {head, total} = watch.current ? resultLines(watch.current) : {head: [], total: 0}; + const height = Math.max(reserved, Math.min(MAX_TRACK_ROWS, Math.max(1, total))); + const body: string[] = []; + if (!watch.current) body.push(` ${subtle}waiting for the first result…${RESET}`); + else { + const take = total > height ? height - 1 : total; + for (const line of head.slice(0, take)) body.push(` ${fit(line)}`); + if (take < total) body.push(` ${subtle}${fit(`… ${total - take} more lines · /track copy copies them all`)}${RESET}`); + } + while (body.length < height) body.push(''); + return {rows: [status, ...body], reserved: height}; +} + +/** The final result written into the transcript when the person stops a tracker: bounded, plain, said once. */ +export function finalResultRows(watch: WatchTask, maxLines = 200): string[] { + const run = watch.current; + if (!run) return [`Stopped before the first result · ${watch.runs} runs`]; + const lines = run.output.replace(/\n$/u, '').split('\n'); + const kept = lines.slice(0, maxLines).map(line => displaySafe(line)); + return [`Stopped · last result ${clock(run.at + run.durationMs)} · ${runOutcome(run).text} · ${watch.runs} run${watch.runs === 1 ? '' : 's'}`, ...kept, + ...(lines.length > maxLines ? [`… ${lines.length - maxLines} more lines were not kept`] : [])]; +} diff --git a/src/terminal/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/terminal/keys.ts b/src/terminal/keys.ts index c10baa2f..a1a73f08 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -8,7 +8,9 @@ export type Key = | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ - | 'queueCommand' | 'sendToProgram'} & {x?: number; y?: number}; + | 'queueCommand' | 'sendToProgram' + /** Ctrl+Y: redo in NMSh's text editor (the composer ignores it, as it ignored the unmapped byte before). */ + | 'redo'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ @@ -106,6 +108,7 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001B[112;5u', 'suggestPrevious'], // Kitty Ctrl+P ['\u001B[113;5u', 'queueCommand'], // Kitty Ctrl+Q ['\u001B[115;5u', 'sendToProgram'], // Kitty Ctrl+S + ['\u001B[121;5u', 'redo'], // Kitty Ctrl+Y ['\u001B[111;5u', 'toggleDetails'], // Kitty Ctrl+O (lowercase o) ['\u001B[79;5u', 'toggleDetails'], // Kitty Ctrl+O (uppercase O) ]; @@ -180,6 +183,7 @@ export function decodeKeys(input: string): Key[] { else if (value === '\u000F') keys.push({kind: 'toggleDetails'} as Key); // Ctrl+O else if (value === '\u0011') keys.push({kind: 'queueCommand'} as Key); // Ctrl+Q: queue (the TTY is raw; no flow control) else if (value === '\u0013') keys.push({kind: 'sendToProgram'} as Key); // Ctrl+S: send to the running program + else if (value === '\u0019') keys.push({kind: 'redo'} as Key); // Ctrl+Y: redo in the NMSh editor else if (value === '\u000E') keys.push({kind: 'suggestNext'} as Key); // Ctrl+N else if (value === '\u0010') keys.push({kind: 'suggestPrevious'} as Key); // Ctrl+P else if (codePoint >= 0x20 && codePoint !== 0x7f) keys.push({kind: 'text', value} as Key); diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index ab7ec107..a6e7bba4 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -5,7 +5,7 @@ import {displayWidth} from '../util/text.js'; import {recordCopyText} from '../clipboard/copySelection.js'; import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'view' | 'details' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; /** One registry for both pointer-opened and keyboard-opened block palettes. */ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: string}[] = [ {id: 'copyCommand', label: 'Copy command', detail: 'Stored plain command'}, @@ -13,6 +13,8 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'copyBoth', label: 'Copy command + output', detail: 'Stored plain command and full output'}, {id: 'copyReport', label: 'Copy as report…', detail: 'Command, status, exit code, duration and output as Markdown or plain text; reviewed first'}, {id: 'compare', label: 'Compare output…', detail: 'With the previous run of this command, or another block you choose'}, + {id: 'details', label: 'Show or hide details', detail: 'The failing tests or diagnostics Semantic Output read from this output; the output is unchanged'}, + {id: 'view', label: 'View output', detail: "Full stored output in NMSh's viewer: find, line numbers, wrap; read-only"}, {id: 'pager', label: 'Open in pager', detail: 'Command and full stored output in your pager (less), read-only'}, {id: 'rerun', label: 'Rerun command', detail: 'Explicitly submit a visible command in the current shell directory'}, {id: 'edit', label: 'Edit & rerun', detail: 'Put command in composer; Enter executes it'}, @@ -24,10 +26,11 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'explain', label: 'Explain failure', detail: "Why it failed, from this block's own output (read-only)"}, ]; -export function blockPaletteItems(record: CompletedCommand): PaletteItem[] { - // Explain failure is offered only on failed blocks, and references only when the output has some. +export function blockPaletteItems(record: CompletedCommand, options: {details?: boolean} = {}): PaletteItem[] { + // Explain failure is offered only on failed blocks, references only when the output has some, and details only when + // Semantic Output listed failures or diagnostics for this block. const hasReferences = findOutputReferences(record).length > 0; - return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, + return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences) && (action.id !== 'details' || options.details)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, detail: action.detail, category: 'Transcript', action: {kind: 'block', id: action.id, startId: record.startId}})); } diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 035aac0a..69f62897 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'; @@ -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 {BROWSER_SETTINGS, 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'; @@ -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 { @@ -339,6 +340,15 @@ 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: 'browser', label: 'Real browser', description: 'Use an installed Chrome or Chromium for /browser and for rendered Markdown and HTML previews. It starts only when needed and stops when idle; nothing is downloaded. Off keeps NMSh\'s own renderer and disables /browser', category: 'General', + values: BROWSER_SETTINGS, labels: ['Auto', 'Off'], + get: config => config.browser, set: (config, browser) => ({...config, browser})}), 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})}), @@ -411,6 +421,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})}), @@ -480,6 +492,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})}), @@ -495,6 +510,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', @@ -672,14 +689,14 @@ 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', browser: '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', - 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', @@ -706,17 +723,17 @@ 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', 'browser', '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', 'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', 'localUnderstanding', - 'askRecord', 'askPresentation', 'outputFolding', + '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/ui/textSizing.ts b/src/ui/textSizing.ts new file mode 100644 index 00000000..aa508c4b --- /dev/null +++ b/src/ui/textSizing.ts @@ -0,0 +1,138 @@ +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; + if (request.segments?.some(segment => !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; + 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; + 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; +} + +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/src/ui/typography.ts b/src/ui/typography.ts new file mode 100644 index 00000000..ed288f82 --- /dev/null +++ b/src/ui/typography.ts @@ -0,0 +1,94 @@ +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; + /** + * 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. */ +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/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 = {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 { 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 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 = 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 { + 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 { 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; async infer(task: 'fold', input: FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise; - async infer(task: 'intent' | 'fold', input: IntentRequest | FoldRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise { + async infer(task: 'command', input: CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise; + async infer(task: 'intent' | 'fold' | 'command', input: IntentRequest | FoldRequest | CommandRequest, options: {priority: Priority; mode: LocalUnderstandingMode; model: LocalModelChoice; timeoutMs: number; reasoning?: Reasoning}): Promise { 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..6835e7dc 100644 --- a/src/understanding/tasks.ts +++ b/src/understanding/tasks.ts @@ -125,3 +125,51 @@ 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; + if (!exactKeys(value, ['command', 'explanation'], ['command', '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(); + 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/src/util/removeDirectory.ts b/src/util/removeDirectory.ts new file mode 100644 index 00000000..f49454fb --- /dev/null +++ b/src/util/removeDirectory.ts @@ -0,0 +1,50 @@ +import {mkdtempSync, readdirSync, renameSync, rmdirSync, rmSync} from 'node:fs'; +import {basename, dirname, join} from 'node:path'; + +/** + * Remove a private directory that helper processes may still be writing into or removing themselves. `rmSync` alone is + * not enough then: a file created during its walk made it throw ENOTEMPTY, and on Linux a concurrent `rm -rf` made it + * return with the directory still there. Renaming the directory away first (atomic, same filesystem) leaves no path to + * it for anyone to write into, so the path is gone when this returns; the renamed copy is then removed. Never throws. + */ +export function removeDirectoryNow(path: string): void { + let grave: string | undefined; + try { + grave = mkdtempSync(join(dirname(path), `${basename(path)}-gone-`)); + renameSync(path, join(grave, 'directory')); + } catch { /* Already gone, or not movable: removed where it is below. */ } + for (const target of grave ? [grave, path] : [path]) { + if (!removeTree(target)) { + setTimeout(() => { try { rmSync(target, {recursive: true, force: true}); } catch { /* Best effort. */ } }, 100).unref(); + } + } +} + +/** + * Remove a directory tree while another remover may be deleting inside it. A remover that already holds the directory + * open (`rm -rf` works through directory descriptors, so it follows a rename) makes entries vanish mid-walk, and one + * rmSync then stops early without an error. Each pass removes what is listed, tolerating what vanished, and retries + * the directory itself while it is not empty; nothing can add entries (its path is gone), so every pass shrinks it. + */ +function removeTree(target: string): boolean { + for (let pass = 0; pass < 50; pass += 1) { + let names: string[]; + try { names = readdirSync(target); } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return true; + if (code !== 'ENOTDIR') return false; + try { rmSync(target, {force: true}); return true; } catch { return false; } + } + for (const name of names) { + try { rmSync(join(target, name), {recursive: true, force: true}); } catch { /* Vanished or retried next pass. */ } + } + try { rmdirSync(target); return true; } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return true; + if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EBUSY') return false; + } + } + return false; +} diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts new file mode 100644 index 00000000..29c014ff --- /dev/null +++ b/src/viewer/DocumentWorkspace.ts @@ -0,0 +1,207 @@ +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; + /** The preview is reading text (a find prompt): every key, Esc included, belongs to it. */ + capturing(): boolean; + /** Show the buffer's text now (the app builds the page from it); never reads or writes the file. */ + refresh(text: string): Promise; + /** Scripts on or off for this file (a trusted project). */ + setTrusted(trusted: boolean, text: string): Promise; +} + +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)); + } + + // A find prompt in the browser preview owns its keys. + if (state.mode === 'preview' && state.preview?.capturing()) { state.preview.key(key); return undefined; } + // 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/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/ImagePanel.ts b/src/viewer/ImagePanel.ts new file mode 100644 index 00000000..4936e233 --- /dev/null +++ b/src/viewer/ImagePanel.ts @@ -0,0 +1,188 @@ +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; + /** 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; + /** 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/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts new file mode 100644 index 00000000..98a81a4d --- /dev/null +++ b/src/viewer/ViewerPanel.ts @@ -0,0 +1,571 @@ +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 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'; +import {editText} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {formatBytes, inert, type ViewDocument} from './document.js'; + +/** + * The native read-only viewer (#397): one document in a full-height panel, never the transcript. Drawing is bounded + * to what is visible: only the lines on screen are laid out, one wrapped line takes at most MAX_WRAP_ROWS rows, syntax + * roles cover the first HIGHLIGHT_LINES lines and Markdown 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; +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; block?: RowBlock} + +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; 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; + /** 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; + /** 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 { + 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. */ +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[] { + 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; +} + +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 ?? '', ...(row?.block ? {block: row.block} : {})}; + } + 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, look); + 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' && 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 ?? ''}; + 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. + 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; +} + +// ---- 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 === '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}`; +} + +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 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; +} + +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] : []), + ...(!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, look: ViewerLook): number { + 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 { + 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, look); + clampTop(state, width, body, look); + const out = headerRows(state, columns, height, look); + 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) { + 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}`); + 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, 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]; +} + +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..3ef6ef77 --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,187 @@ +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; + +/** 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} + | {kind: 'output'; command: string; number: number; exitCode?: number}; + +export interface ViewDocument { + /** What the header names: the path as given (home shortened) or the command. */ + title: string; + origin: DocumentOrigin; + lines: string[]; + /** The text copying the whole document gives: the decoded file up to the limits, or the record's output. */ + text: string; + format: DocumentFormat; + /** A grammar id for syntax roles, when the file type is known. */ + language?: string; + /** Limits that applied, said plainly (truncation, encoding). */ + notes: string[]; + lineEnding: 'lf' | 'crlf' | 'mixed' | 'none'; + /** False when the text was cut at a read or line limit. */ + complete: boolean; +} + +export type LoadResult = {ok: true; document: ViewDocument} | {ok: false; reason: string}; + +/** `~` and `~/…` name the home directory; other relative paths are relative to the shell's directory. */ +export function resolveViewPath(input: string, cwd: string, home = homedir()): string { + const trimmed = input.trim(); + if (trimmed === '~') return home; + if (trimmed.startsWith('~/')) return join(home, trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); +} + +/** Text from a file name or path made inert for display: controls and invisible format characters become marks. */ +export function inert(text: string): string { + return text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, character => character === '\u001b' ? '␛' : '·'); +} + +/** The path as people read it (under home it starts with ~), safe to draw: a file name can hold control characters. */ +export function displayPath(path: string, home = homedir()): string { + return inert(home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); +} + +const FILE_NAMES: Record = {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, 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} : {})}; +} + +/** 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)}; 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 /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); + 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; 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'); + } + let realPath = path; + if (symlink) { try { realPath = await realpath(path); } catch { /* the link changed meanwhile; the descriptor read is what is shown */ } } + const {format, language} = formatForPath(path); + return {ok: true, document: {title: shown, origin: {kind: 'file', path, realPath, symlink, bytes: info.size, mtimeMs: info.mtimeMs}, lines, text, format, + ...(language ? {language} : {}), notes, lineEnding, complete}}; + } catch (error) { + return {ok: false, reason: describe(error, shown)}; + } finally { + await handle.close().catch(() => undefined); + } +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} diff --git a/src/viewer/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/src/viewer/fileEdit.ts b/src/viewer/fileEdit.ts new file mode 100644 index 00000000..e6160361 --- /dev/null +++ b/src/viewer/fileEdit.ts @@ -0,0 +1,205 @@ +import {createHash, randomBytes} from 'node:crypto'; +import {constants} from 'node:fs'; +import {access, link, lstat, open, readFile, realpath, rename, stat, unlink} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {basename, dirname, join} from 'node:path'; +import {displayPath, resolveViewPath} from './document.js'; + +/** + * Opening and saving a small text file for the native editor (#402). Only files that can be saved back exactly and + * safely are opened for editing; anything else is refused with the reason (and stays readable in /view): + * + * - a regular UTF-8 text file of at most MAX_EDIT_BYTES, owned by this user, with one hard link, whose file and + * directory are writable, with one kind of line ending (LF or CRLF); a BOM and the final newline are kept; + * - a path that does not exist yet, in a writable directory, opens empty and is created on save (never over a file + * that appeared meanwhile). + * + * Saving writes a new file beside the original (exclusive create, the original's mode, group when permitted), syncs it, + * checks that the original is still the file that was opened (device, inode, size, time and a content hash) and + * renames it over the original's real path. A changed, replaced or deleted original is a conflict for the person to + * decide; nothing is overwritten silently. Extended attributes and ACLs are not carried over by the rename. + */ + +export const MAX_EDIT_BYTES = 1024 * 1024; +const BOM = Buffer.from([0xef, 0xbb, 0xbf]); + +export interface FileIdentity {dev: number; ino: number; size: number; mtimeMs: number; hash: string} + +export interface EditableFile { + /** The path as given, resolved (it may be a symlink). */ + path: string; + /** The file written: the symlink's target. */ + realPath: string; + title: string; + /** The original's permission bits; undefined for a new file (it gets the default mode under the umask). */ + mode?: number; + gid?: number; + /** Undefined for a file that does not exist yet. */ + identity?: FileIdentity; + lineEnding: 'lf' | 'crlf'; + bom: boolean; + finalNewline: boolean; + lines: string[]; +} + +export type OpenResult = {ok: true; file: EditableFile} | {ok: false; reason: string}; +export type SaveConflict = 'changed' | 'replaced' | 'deleted' | 'exists'; +export type SaveResult = {ok: true; file: EditableFile} | {ok: false; reason: string; conflict?: SaveConflict}; + +const hash = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); +const code = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code; + +function failure(error: unknown, action: string, shown: string): string { + switch (code(error)) { + case 'EACCES': case 'EPERM': return `Could not ${action} ${shown}: permission denied.`; + case 'EROFS': return `Could not ${action} ${shown}: the file system is read-only.`; + case 'ENOSPC': return `Could not ${action} ${shown}: the disk is full.`; + case 'EDQUOT': return `Could not ${action} ${shown}: over the disk quota.`; + case 'ENOENT': return `Could not ${action} ${shown}: it no longer exists.`; + case 'ELOOP': return `Could not ${action} ${shown}: a symlink loop.`; + case 'EISDIR': return `Could not ${action} ${shown}: it is a directory.`; + default: return `Could not ${action} ${shown}${code(error) ? ` (${code(error)})` : ''}.`; + } +} + +async function writable(path: string): Promise { + 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.`}; + // The real path it will have (the folder may be reached through a symlink, as macOS /tmp is): every later save + // checks the file against realpath() of the path as given, which is this once the file exists. + let realDirectory: string; + try { realDirectory = await realpath(directory); } catch (error) { return {ok: false, reason: failure(error, 'open', shown)}; } + return {ok: true, file: {path, realPath: join(realDirectory, basename(path)), title: shown, lineEnding: 'lf', bom: false, finalNewline: true, lines: ['']}}; + } + let handle; + try { handle = await open(realPath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0) | (constants.O_NOCTTY ?? 0)); } + catch (error) { return {ok: false, reason: failure(error, 'open', shown)}; } + try { + const info = await handle.stat(); + if (info.isDirectory()) return {ok: false, reason: `${shown} is a directory.`}; + if (!info.isFile()) return {ok: false, reason: `${shown} is not a regular file.`}; + const maxBytes = options.maxBytes ?? MAX_EDIT_BYTES; + if (info.size > maxBytes) return {ok: false, reason: `${shown} is larger than ${Math.round(maxBytes / 1024)} KiB; the NMSh editor is for small files. Use your editor, or /view to read it.`}; + if (uid !== undefined && info.uid !== uid) return {ok: false, reason: `${shown} belongs to another user; NMSh edits only your own files. /view reads it.`}; + if (info.nlink > 1) return {ok: false, reason: `${shown} has other hard links, which saving by replacement would separate. Use your editor, or /view to read it.`}; + const bytes = await handle.readFile(); + if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) return {ok: false, reason: `${shown} is UTF-16 text; the NMSh editor edits UTF-8 only.`}; + if (bytes.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes).`}; + const bom = bytes.subarray(0, 3).equals(BOM); + let text: string; + try { text = new TextDecoder('utf-8', {fatal: true}).decode(bom ? bytes.subarray(3) : bytes); } + catch { return {ok: false, reason: `${shown} is not valid UTF-8; saving could change bytes it holds. /view reads it.`}; } + const finalNewline = text.endsWith('\n'); + const raw = text.split('\n'); + if (finalNewline) raw.pop(); + const terminated = finalNewline ? raw.length : raw.length - 1; + const crlf = raw.slice(0, terminated).filter(line => line.endsWith('\r')).length; + if (crlf && crlf !== terminated) return {ok: false, reason: `${shown} mixes LF and CRLF line endings; saving would change them. Use your editor, or /view to read it.`}; + const lines = crlf ? raw.map((line, index) => index < terminated ? line.slice(0, -1) : line) : raw; + if (!(await writable(realPath))) return {ok: false, reason: `${shown} is read-only. /view reads it.`}; + if (!(await writable(dirname(realPath)))) return {ok: false, reason: `The folder holding ${shown} is not writable, so it cannot be saved safely. /view reads it.`}; + return {ok: true, file: {path, realPath, title: shown, mode: info.mode & 0o7777, gid: info.gid, + identity: {dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, hash: hash(bytes)}, + lineEnding: crlf ? 'crlf' : 'lf', bom, finalNewline: finalNewline || text === '', lines: lines.length ? lines : ['']}}; + } catch (error) { + return {ok: false, reason: failure(error, 'open', shown)}; + } finally { await handle.close().catch(() => undefined); } +} + +/** The bytes a buffer's lines save as: the file's line ending, BOM and final newline kept. */ +export function encodeLines(file: Pick, 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/ansiLiveRegion.test.ts b/tests/ansiLiveRegion.test.ts new file mode 100644 index 00000000..64c267d9 --- /dev/null +++ b/tests/ansiLiveRegion.test.ts @@ -0,0 +1,260 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {AnsiOutputParser, plainLine} from '../src/output/AnsiOutputParser.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {ShellSession} from '../src/shell/ShellSession.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * #388: programs that redraw several of their own rows in place (multi-line progress) update those rows instead of + * leaving a copy per frame, and can never reach rows outside their live region. + */ +const plain = (parser: AnsiOutputParser) => parser.allLines().map(line => plainLine(line)); +const ESC = '\u001b'; +const up = (rows: number) => `${ESC}[${rows}A`; +const clearLine = `${ESC}[2K`; + +test('the #388 fixture: a one-row redraw with cursor-up and erase-line leaves one line, not one per frame', () => { + const parser = new AnsiOutputParser(); + for (let step = 0; step <= 10; step += 1) parser.write(`${step ? `${up(1)}${clearLine}` : ''}Downloading ${step * 10}%\r\n`); + assert.deepEqual(plain(parser), ['Downloading 100%']); + // Carriage-return rewriting, the existing control, still works. + parser.write('10%\r20%\r100%\r\n'); + assert.deepEqual(plain(parser), ['Downloading 100%', '100%']); +}); + +test('a two-row progress display interleaved with log lines keeps every log line and only the final progress rows', () => { + const parser = new AnsiOutputParser(); + const frame = (a: number, b: number) => `${clearLine}layer a ${a}%\r\n${clearLine}layer b ${b}%\r\n`; + parser.write(frame(0, 0)); + for (let step = 1; step <= 5; step += 1) { + if (step === 3) parser.write(`${up(2)}${ESC}[Jpulled base image\r\n`); + else parser.write(up(2)); + parser.write(frame(step * 20, step * 10)); + } + assert.deepEqual(plain(parser), ['pulled base image', 'layer a 100%', 'layer b 50%']); +}); + +test('the cursor never leaves the live region: NMSh lines, earlier commands and rows beyond a screen height stay untouched', () => { + const parser = new AnsiOutputParser(); + parser.addLine('$ previous'); + parser.write('previous output\r\n'); + parser.addLine('$ current'); + parser.write('own 1\r\nown 2\r\n'); + parser.write(`${up(50)}X`); + assert.deepEqual(plain(parser), ['$ previous', 'previous output', '$ current', 'Xwn 1', 'own 2'], 'clamped at the first own row'); + + const tall = new AnsiOutputParser(); + tall.setScreenRows(3); + for (let line = 0; line < 10; line += 1) tall.write(`line ${line}\r\n`); + tall.write(`${up(9)}X\r\n`); + assert.deepEqual(plain(tall).slice(6), ['line 6', 'line 7', 'Xine 8', 'line 9'], 'no further up than a screen height'); +}); + +test('an NMSh line written mid-redraw fences the rows above it: later frames continue below it', () => { + const parser = new AnsiOutputParser(); + parser.write('progress 1\r\n'); + parser.addLine('· copied'); + for (let step = 2; step <= 4; step += 1) parser.write(`${up(1)}${clearLine}progress ${step}\r\n`); + assert.deepEqual(plain(parser), ['progress 1', '· copied', 'progress 4']); +}); + +test('save/restore, next/previous line, reverse index and partial erases act within the region', () => { + const parser = new AnsiOutputParser(); + parser.write(`${ESC}7first\r\nsecond\r\n${ESC}8${ESC}[1mF${ESC}[m\r\n`); + assert.deepEqual(plain(parser), ['First', 'second']); + parser.write(`${ESC}[2Fx${ESC}[1Ey\r\n`); + assert.deepEqual(plain(parser), ['xirst', 'yecond']); + parser.write(`${ESC}Mz`); + assert.deepEqual(plain(parser), ['xirst', 'zecond']); + parser.write(`${ESC}[s${up(1)}${ESC}[3G${ESC}[1K${ESC}[u!`); + assert.deepEqual(plain(parser), [' st', 'z!cond']); + parser.write(`${up(1)}\r${ESC}[J`); + assert.deepEqual(plain(parser), [], 'erase below removes the program\'s rows under the cursor'); +}); + +test('sequences split across reads still redraw in place', () => { + const parser = new AnsiOutputParser(); + parser.write('one\r\n'); + for (const chunk of [`${ESC}`, '[', '1', 'A', `${ESC}[2`, 'Ktwo\r', '\n']) parser.write(chunk); + assert.deepEqual(plain(parser), ['two']); +}); + +test('absolute row positioning is not mapped: it appends as before instead of guessing which row is meant', () => { + const parser = new AnsiOutputParser(); + parser.write('a\r\nb\r\n'); + parser.write(`${ESC}[1;1Hc\r\n`); + assert.deepEqual(plain(parser), ['a', 'b', 'c']); +}); + +/** An indicatif-style display: it moves up by the terminal rows it drew (it measures wrapping), erases below, redraws. */ +function wrappedProgress(columns: number, frames = 10): string { + const bar = (n: number) => `Downloading big-archive-name.tar.gz [${'='.repeat(n)}${' '.repeat(frames - n)}] ${n * 10}%`; + const rowsOf = (text: string) => Math.max(1, Math.ceil(text.length / columns)); + let out = 'LOG kept line one\r\nLOG kept line two\r\n'; + let drawn = 0; + for (let n = 0; n <= frames; n += 1) { + if (drawn) out += `\r${up(drawn)}${ESC}[J`; + const frame = [bar(n), `eta ${frames - n}s`]; + out += `${frame.join('\r\n')}\r\n`; + drawn = frame.reduce((sum, line) => sum + rowsOf(line), 0); + } + return `${out}LOG done\r\n`; +} + +test('rows are the terminal\'s rows: a display that moves over its own wrapped line never erases earlier output', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(40); + parser.write(wrappedProgress(40)); + assert.deepEqual(plain(parser), ['LOG kept line one', 'LOG kept line two', 'Downloading big-archive-name.tar.gz [==========] 100%', 'eta 0s', 'LOG done']); + // The same bytes at a width where nothing wraps behave the same. + const wide = new AnsiOutputParser(); + wide.setScreenRows(24); + wide.setScreenColumns(120); + wide.write(wrappedProgress(120)); + assert.deepEqual(plain(wide), plain(parser)); +}); + +test('on a wrapped line, carriage return, erase-line and line feed act on the cursor\'s row, as on the screen', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(10); + parser.write('0123456789abcdefghij'); // two rows, cursor at the end (pending wrap) + parser.write('\rABC'); // back to the start of the second row + assert.deepEqual(plain(parser), ['0123456789ABCdefghij']); + parser.write(`${up(1)}${ESC}[2K`); // up to the first row and clear that row only + assert.deepEqual(plain(parser), [' ABCdefghij']); + parser.write('\r\nxy'); // line feed from the first row goes to the line's second row + assert.deepEqual(plain(parser), [' xyCdefghij']); + parser.write(`\r${ESC}[K`); // erase to the end of the second row: the row stays, blank + parser.write(`${up(1)}first\r\nnext\r\n`); + // The second row is still the same (wrapped) line, as on the screen: drawn at 10 columns it reads "first" / "next". + assert.deepEqual(plain(parser), ['first next']); +}); + +test('a resize re-wraps: the rows a program counts at the new width are the ones it reaches', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(40); + parser.write('kept\r\n'); + parser.write(`${'x'.repeat(30)}\r\n`); + parser.setScreenColumns(20); // the 30-column line now takes two rows + parser.write(`${up(2)}${ESC}[J${'y'.repeat(5)}\r\n`); + assert.deepEqual(plain(parser), ['kept', 'yyyyy']); +}); + +test('a status row below a scroll region the program set is not stored; NMSh lines and log rows are', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(80); + const status = (percent: number) => `${ESC}7${ESC}[24;0f${ESC}[42mProgress: [${percent}%]${ESC}[0m${ESC}8`; + parser.write(`\r\n${ESC}7${ESC}[0;23r${ESC}8${up(1)}`); + for (let step = 1; step <= 3; step += 1) parser.write(`Unpacking package-${step}\r\n${status(step * 30)}`); + parser.addLine('NMSh note while the status row is reserved'); + parser.write(`Setting up package-3\r\n${ESC}7${ESC}[0;24r${ESC}8${ESC}[J`); + const lines = plain(parser); + assert.equal(lines.some(line => /Progress/u.test(line)), false, 'the status row never enters the transcript'); + assert.deepEqual(lines.filter(line => line), ['Unpacking package-1', 'Unpacking package-2', 'Unpacking package-3', 'NMSh note while the status row is reserved', 'Setting up package-3']); + // Without a region, an absolute move is not mapped: the text stays where the cursor is. + const plainParser = new AnsiOutputParser(); + plainParser.write(`a\r\n${ESC}[5;1Hb\r\n`); + assert.deepEqual(plain(plainParser), ['a', 'b']); +}); + +test('a resumed command keeps redrawing its own rows, and a command-line recolor never moves its cursor', () => { + const buffer = new OutputBuffer(); + const startId = buffer.beginCommand('pull', ['$ pull']); + buffer.write('layer 1%\r\n'); + buffer.updateCommandHighlight(startId, ['$ pull']); + buffer.write(`${up(1)}${clearLine}layer 50%\r\n`); + const transcript = buffer.transcript(); + const restored = new OutputBuffer(); + restored.restoreTranscript(transcript); + restored.resumeActive('pull', startId, startId + 1); + restored.write(`${up(1)}${clearLine}layer 100%\r\n`); + const record = restored.complete(0)!; + assert.equal(record.output, 'layer 100%'); +}); + +test('ten thousand frames of a three-row display stay three rows and parse in bounded time', () => { + const buffer = new OutputBuffer(); + buffer.beginCommand('build', ['$ build']); + const started = performance.now(); + for (let step = 0; step < 10_000; step += 1) { + buffer.write(`${step ? up(3) : ''}${clearLine}a ${step}\r\n${clearLine}b ${step}\r\n${clearLine}c ${step}\r\n`); + } + const elapsed = performance.now() - started; + const record = buffer.complete(0)!; + assert.equal(record.output, 'a 9999\nb 9999\nc 9999'); + assert.equal(record.endId - record.outputStartId, 3); + assert.ok(elapsed < 5000, `took ${Math.round(elapsed)}ms`); +}); + +test('a real PTY at 40 columns: a display that counts its wrapped rows keeps the log lines above it', {timeout: 60_000}, async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-wrapped-'))); + const shell = new ShellSession(home, 40, 30, home, {...process.env, HOME: home}); + let output = ''; + try { + await once(shell, 'prompt'); + shell.on('data', data => { output += data; }); + const done = once(shell, 'prompt'); + // The program measures its rows with the PTY's own width, as progress libraries do. + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + shell.submit(`${JSON.stringify(process.execPath)} -e '${script}'`); + await done; + const parser = new AnsiOutputParser(); + parser.setScreenRows(30); + parser.setScreenColumns(40); + parser.write(output); + const lines = plain(parser); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two') && lines.includes('LOG done'), lines.join('\n')); + assert.deepEqual(lines.filter(line => line.startsWith('Downloading')), ['Downloading big-archive-name.tar.gz [==========]']); + } finally { + shell.kill(); + rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +}); + +test('a real PTY: the #388 fixture run by zsh leaves one progress line', {timeout: 60_000}, async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-redraw-'))); + const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home}); + let output = ''; + try { + await once(shell, 'prompt'); + shell.on('data', data => { output += data; }); + const done = once(shell, 'prompt'); + const script = 'for (let i=0;i<=10;i++){process.stdout.write((i?"\\x1b[1A\\x1b[2K":"")+"Downloading "+i*10+"%\\n")}'; + shell.submit(`${JSON.stringify(process.execPath)} -e '${script}'`); + await done; + const parser = new AnsiOutputParser(); + parser.setScreenRows(30); + parser.write(output); + assert.deepEqual(plain(parser).filter(line => line.includes('Downloading')), ['Downloading 100%']); + } finally { + shell.kill(); + rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +}); + +test('live: through the real frontend at 40 columns, the wrapped display redraws in place and the log lines stay in the transcript', {timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 40, rows: 30}); + try { + await frontend.waitFor(/❯/u); + await frontend.run('echo READY', /READY/u); + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + await frontend.run(`${JSON.stringify(process.execPath)} -e '${script}'`, /LOG done/u); + await until(async () => (await sandbox.transcripts().list()).some(session => JSON.stringify(session.transcript).includes('LOG done')), 15_000, 'the journal'); + const sessions = await sandbox.transcripts().list(); + const lines = sessions.flatMap(session => (session.transcript.lines as Array>).map(line => line.map(cell => cell?.text ?? ' ').join('').trimEnd())); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two'), lines.join('\n')); + assert.equal(lines.filter(line => line.startsWith('Downloading')).length, 1, 'one final progress row, not one per frame'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/arcadeDashGame.test.ts b/tests/arcadeDashGame.test.ts new file mode 100644 index 00000000..6bc3ce09 --- /dev/null +++ b/tests/arcadeDashGame.test.ts @@ -0,0 +1,338 @@ +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, Trail, 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 {createDashGame, readRecord} from '../src/arcade/games/dash/game.js'; +import {dashGame} from '../src/arcade/games/dash/index.js'; +import {phaseTint, phaseTones, MIN_COLUMNS, MIN_ROWS} from '../src/arcade/games/dash/scene.js'; +import {playerRows} from '../src/arcade/games/dash/sprites.js'; +import {DashWorld, PHASE_PX} from '../src/arcade/games/dash/world.js'; +import {HB_X, PW, type Piece} from '../src/arcade/games/dash/physics.js'; +import {catPixels} from '../src/idle/catSprite.js'; +import {displayWidth} from '../src/util/text.js'; +import {createPilot} from './support/dashBot.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: DashWorld) => void; particles?: ParticleSystem; trail?: Trail; +} + +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 ?? 20}, 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 DashWorld(context.rng.fork('course')); + options.world?.(world); + const parts = {world, ...(options.particles ? {particles: options.particles} : {}), ...(options.trail ? {trail: options.trail} : {})}; + const game = createDashGame(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: DashWorld): void => { + world.course.pieces.length = 0; + (world.course as unknown as {ensure(x: number): void}).ensure = () => {}; +}; +let ids = 900; +const put = (world: DashWorld, kind: Piece['kind'], ahead: number, w: number, level = 0): void => { + world.course.pieces.push({id: ++ids, kind, x: world.distance + HB_X + PW + ahead, w, level, unit: 1}); +}; +const spikeAhead = (ahead: number) => (world: DashWorld) => { emptyRoad(world); put(world, 'spike', ahead, 6); }; +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); } }; +const text = (hint: ReturnType) => hint.map(item => `${item.keys} ${item.action}`).join(' | '); + +test('definition: id, aliases, title, kind, rates, hints, summary and preview', () => { + assert.equal(dashGame.id, 'dash'); + assert.deepEqual([...dashGame.aliases], ['vespyr-dash']); + assert.equal(dashGame.title, 'Vespyr Dash'); + assert.equal(dashGame.kind, 'action'); + const {game} = make(); + assert.equal(game.stepsPerSecond, 60); + assert.equal(game.refreshMs, 100); + game.key(SPACE); + assert.match(text(game.hints()), /Space\/Up jump \| p pause \| Esc menu/u); + assert.equal(dashGame.summary({}), 'Not played yet'); + assert.equal(dashGame.summary({best: 420, bestDistance: 210, attempts: 3}), 'Best 420 · 210 m'); + const preview = new Canvas(30, 6); + for (let frame = 0; frame < 40; frame += 1) dashGame.preview(preview, frame, palette); + assert.ok(preview.plain().join('').trim().length > 0); +}); + +test('state machine: ready until Space or Up; a press while grounded jumps, a mid-air press only buffers', () => { + const {game, world} = make({world: emptyRoad}); + 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); + assert.equal(game.key({kind: 'up'}), 'handled'); + assert.equal(game.status, 'playing'); + assert.equal(world.airborne, true, 'the first press also jumps'); + const vy = world.body.vy; + game.key(SPACE); + assert.equal(world.body.vy, vy, 'a press in the air changes nothing now'); + run(game, 80); + assert.equal(game.key({kind: 'text', value: 'x'}), 'ignored'); +}); + +test('scoring: score is distance plus cleared patterns, shown as a counter that lights at 100', () => { + const {game, world, draw, canvas} = make({world: emptyRoad}); + game.key(SPACE); + run(game, 60); + assert.equal(world.score, Math.floor(world.distance / 5)); + assert.match(draw()[0]!, new RegExp(`\\b${String(world.score).padStart(5, '0')}\\b`, 'u')); + assert.match(draw()[0]!, /HI \d{5}/u); + assert.match(draw()[1]!, /\d+ m +\d\.\d\dx/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); + const cleared = make(); + cleared.world.cleared = 3; + assert.equal(cleared.world.score, 30); +}); + +test('crash: a spike ends the run after a held beat with a result, details and a record; dying is frozen', () => { + const {game, world, record} = make({world: spikeAhead(30)}); + game.key(SPACE); + game.step(1000 / 60); + // Land the jump early and hit the spike on foot. + run(game, 200); + assert.equal(world.alive, false); + const before = world.steps; + run(game, 100); + assert.equal(game.status, 'over'); + assert.equal(world.steps, before); + const result = game.result!; + assert.equal(result.record, true); + assert.deepEqual(result.details, [`Distance ${world.meters} m`, `Best ${world.score}`, 'Attempts 1']); + assert.deepEqual(record(), {best: world.score, bestDistance: world.meters, attempts: 1}); + assert.equal(game.key(SPACE), 'ignored'); +}); + +test('records: validated on read, best only grows, abandoned runs are not attempts', () => { + assert.deepEqual(readRecord({best: 'lots', bestDistance: -4, attempts: Number.NaN}), {best: 0, bestDistance: 0, attempts: 0}); + assert.deepEqual(readRecord({best: 12.9, bestDistance: 1e30, attempts: 3}), {best: 12, bestDistance: 1e9, attempts: 3}); + assert.deepEqual(readRecord({best: null, bestDistance: {a: 1}, attempts: [1]} as never), {best: 0, bestDistance: 0, attempts: 0}); + const higher = make({record: {best: 99999, bestDistance: 5000, attempts: 4}, world: spikeAhead(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, attempts: 5}); + const junk = make({record: {best: 'x', attempts: -2, stray: 1} as never, world: spikeAhead(20)}); + junk.game.key(SPACE); + run(junk.game, 400); + assert.equal(junk.game.result!.details[2], 'Attempts 1'); + const abandoned = make({record: {best: 3, bestDistance: 1, attempts: 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, attempts: 7}); +}); + +test('persistence: a finished run survives a restart of the store', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-dash-')); + try { + const first = new ArcadeStore({directory: dir}); + const context: GameContext = {size: {columns: 60, rows: 14}, seed: 2, rng: createRng(2), motion: {effects: 'normal', reduced: false}, color: 'none', safeGlyphs: false, record: first.record('dash'), now: () => 0}; + const world = new DashWorld(context.rng.fork('course')); spikeAhead(150)(world); + const game = createDashGame(context, {world}); + game.key(SPACE); + run(game, 3000); + assert.equal(game.status, 'over'); + assert.ok(first.flush()); + const second = new ArcadeStore({directory: dir}).record('dash').get(); + assert.deepEqual(second, {best: world.score, bestDistance: world.meters, attempts: 1}); + assert.match(dashGame.summary(second), /^Best \d+ · \d+ m$/u); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('pause and restart through the session; a restart makes a fresh course, the same seed repeats it', () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'arcade-dash-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, [dashGame], {columns: 80, rows: 22}, dashGame); + try { + const first = session.game!; + assert.equal(session.key({kind: 'up'}), 'handled'); + session.frame(200); + session.key({kind: 'text', value: 'p'}); + assert.equal(session.screen, 'paused'); + session.frame(1000); + assert.match(session.render().rows.join('\n'), /Paused/u); + session.key({kind: 'enter'}); + assert.equal(session.screen, 'playing'); + for (let i = 0; i < 80 && first.status === 'playing'; i += 1) session.frame(100); + assert.equal(first.status, 'over'); + now += 1000; seed = 11; + session.key({kind: 'enter'}); + assert.notEqual(session.game, first); + assert.equal(session.game!.status, 'ready'); + } finally { session.dispose(); rmSync(dir, {recursive: true, force: true}); } + const courseOf = (s: number) => make({seed: s}).world.course.pieces.slice(0, 8).map(p => `${p.kind}@${p.x}`).join(','); + assert.equal(courseOf(21), courseOf(21)); + assert.notEqual(courseOf(21), courseOf(22)); + assert.equal(make({seed: 5, effects: 'off'}).world.hash(), make({seed: 5, effects: 'rich'}).world.hash()); +}); + +test('pause: the game does not advance without steps and animate never moves the world', () => { + const f = make({world: emptyRoad}); + f.game.key(SPACE); + run(f.game, 20); + const hash = f.world.hash(); + f.game.pause(); + for (let i = 0; i < 30; i += 1) f.game.animate(16.7); + assert.equal(f.world.hash(), hash); +}); + +test('color phases: a new tint every PHASE_METERS, blended smoothly with mixRgb, all from palette roles', () => { + const tones = phaseTones(palette); + const roles = [palette.accent, palette.ok, palette.warn, palette.accentSoft, palette.bad]; + assert.deepEqual(tones, roles); + assert.deepEqual(phaseTint(palette, 10), tones[0]); + const before = phaseTint(palette, PHASE_PX - 1); + const start = phaseTint(palette, PHASE_PX); + const mid = phaseTint(palette, PHASE_PX + 70); + const end = phaseTint(palette, PHASE_PX + 300); + assert.deepEqual(before, tones[0]); + assert.deepEqual(start, tones[0], 'the blend starts from the old tone'); + assert.notDeepEqual(mid, tones[0]); assert.notDeepEqual(mid, tones[1]); + assert.deepEqual(end, tones[1]); +}); + +test('sprites: the player is cut from the approved cat pixels; poses keep ears and eyes', () => { + const idle = catPixels('idle'); + for (const pose of ['ride', 'air', 'land', 'hit', 'blink'] as const) { + const rows = playerRows(pose); + for (const row of rows) assert.equal(row.length, 7, `${pose} ${row}`); + assert.equal(rows[0], `.${idle[0]!.slice(1, 6)}.`.replace(/L/gu, 'L')); + } + assert.match(playerRows('ride')[2]!, /E/u); + assert.doesNotMatch(playerRows('hit')[2]!, /E/u); + assert.equal(playerRows('land').length, 5); +}); + +test('effects: trail and particles follow the effects level; reduced motion drops flash, shake and pulses', () => { + const peak = (effects: EffectsLevel) => { + const particles = new ParticleSystem(createRng(2), 120, effects); + const trail = new Trail(effects === 'off' ? 0 : 14, effects); + const f = make({effects, particles, trail, world: emptyRoad}); + f.draw(); + f.game.key(SPACE); + let top = 0; let trailTop = 0; + for (let i = 0; i < 160; i += 1) { f.game.animate(16.7); f.game.step(1000 / 60); top = Math.max(top, particles.count); trailTop = Math.max(trailTop, trail.size); } + assert.ok(top <= 120); + return {top, trailTop}; + }; + assert.ok(peak('normal').top > 3); + assert.ok(peak('normal').trailTop > 5); + assert.equal(peak('off').top, 0); + assert.equal(peak('off').trailTop, 0); + const crash = (reduced: boolean) => { + const f = make({reduced, world: spikeAhead(70)}); + f.game.key(SPACE); + let calm: unknown; + for (let i = 0; i < 300 && f.world.alive; i += 1) { f.draw(); calm = f.canvas.cell(2, 4)?.bg; f.game.animate(16.7); f.game.step(1000 / 60); } + f.draw(); + return {calm, after: f.canvas.cell(2, 4)?.bg}; + }; + 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); }; + const normal = crash(false); const reduced = crash(true); + assert.ok(delta(normal.calm, normal.after) > 10, 'the impact flashes'); + assert.ok(delta(reduced.calm, reduced.after) < 6); + // Reduced motion: the sky does not pulse on the beat while running. + const skyAt = (reducedMotion: boolean) => { + const f = make({reduced: reducedMotion, world: emptyRoad}); + f.game.key(SPACE); + const colors = new Set(); + for (let i = 0; i < 120; i += 1) { run(f.game, 1); f.draw(); colors.add(JSON.stringify(f.canvas.cell(2, 6)?.bg)); } + return colors.size; + }; + assert.ok(skyAt(false) > skyAt(true), 'a beat pulse exists and reduced motion removes it'); + assert.equal(skyAt(true), 1); +}); + +test('NO_COLOR and Safe Glyphs: ground, platforms, spikes and the cat stay distinct', () => { + const rows = 16; + const setup = (world: DashWorld) => { emptyRoad(world); put(world, 'spike', 14, 6); put(world, 'block', 28, 12, 6); put(world, 'gap', 50, 14); }; + const f = make({color: 'none', rows, world: setup}); + const lines = f.draw(); + assert.match(lines[rows - 3]!, /▲{6}/u, 'spikes are triangles, not blocks'); + assert.ok(lines.slice(0, rows - 2).some(line => line.includes('/\\_/\\')), 'the ASCII cat'); + assert.ok(lines[rows - 2]!.includes('█'), 'solid ground line'); + assert.match(lines[rows - 2]!, /█ {6,}/u, 'a gap in it'); + assert.ok(lines.slice(0, rows - 3).some(line => /█{4}/u.test(line.slice(30, 60))), 'platform blocks'); + assert.doesNotMatch(lines.slice(0, rows - 2).join(''), /[░▒]/u); + const safe = make({color: 'none', safe: true, rows, world: setup}); + 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.match(ascii[rows - 3]!, /\^{6}/u); + 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})[5]!.trim(), '', 'no backdrop in the sky'); +}); + +test('resize: sizes from 20x8 to 200x60 draw without throwing, every plain row fits, and small sizes show a note', () => { + for (const [color, safe] of [['truecolor', false], ['none', false], ['256', true]] as const) { + for (const columns of [20, 33, 40, 80, 121, 200]) for (const rows of [8, 11, 12, 16, 30, 60]) { + for (const status of ['ready', 'playing', 'over'] as const) { + const f = make({columns, rows, color, safe, world: status === 'over' ? spikeAhead(12) : undefined}); + 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}: "${line}"`); + } + if (columns < MIN_COLUMNS || rows < MIN_ROWS) assert.match(f.draw().join('\n'), /Needs at least 40x12|Needs at/u); + } + } + } + const f = make({world: emptyRoad}); + f.game.key(SPACE); run(f.game, 30); + for (const [columns, rows] of [[50, 13], [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}); + for (const line of small.draw()) assert.ok(displayWidth(line) <= columns); + small.game.key(SPACE); run(small.game, 5); + } +}); + +test('a pilot pressing keys on the real game plays long and a game that never acts ends with the card', () => { + const f = make({seed: 8}); + const pilot = createPilot('mid', createRng(1)); + f.game.key(SPACE); + for (let i = 0; i < 4000 && f.game.status === 'playing'; i += 1) { if (pilot(f.world)) f.game.key(SPACE); f.game.animate(16.7); f.game.step(1000 / 60); } + assert.equal(f.game.status, 'playing'); + assert.ok(f.world.meters > 300); + const idle = make({seed: 8}); + idle.game.key(SPACE); + run(idle.game, 5000); + assert.equal(idle.game.status, 'over'); + idle.game.dispose(); +}); diff --git a/tests/arcadeDashPhysics.test.ts b/tests/arcadeDashPhysics.test.ts new file mode 100644 index 00000000..eeee8773 --- /dev/null +++ b/tests/arcadeDashPhysics.test.ts @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + AIR_STEPS, APEX, MIN_GAP_PX, GAP_MAX, GAP_MIN, GRAVITY, HB_X, JUMP_V, JUMP_PROFILE, PH, PLATFORM_LEVELS, PW, REACT_STEPS, SPEEDS, SPEED_MAX, SPIKE_H, SPIKE_HIT_H, + SPIKE_HIT_W, SPIKE_W, WINDOW_STEPS, difficultyAt, jumpStats, maxGapAt, newBody, speedAt, stepBody, stepPx, takeoff, type Body, type Piece, +} from '../src/arcade/games/dash/physics.js'; +import {Course, REST_PX, gapSpec, buildPieces, islandSpec, spikesSpec} from '../src/arcade/games/dash/course.js'; +import {solve, solvePhases} from '../src/arcade/games/dash/solver.js'; +import {DashWorld, METER_PX, SCORE_PX} from '../src/arcade/games/dash/world.js'; +import {createRng} from '../src/arcade/rng.js'; +import {passable} from './support/dashCheck.js'; +import {playBot, replayableCourse} from './support/dashBot.js'; + +let ids = 0; +const piece = (kind: Piece['kind'], x: number, w: number, level = 0): Piece => ({id: ++ids, kind, x, w, level, unit: 1}); +/** Run a body over `pieces` at the given speed from hitbox-left `start`; `presses` are step numbers at which to jump. */ +function drive(pieces: Piece[], speed: number, start: number, steps: number, presses: number[] = []) { + const body = newBody(); + let left = start; + let landedY = -1; + for (let step = 0; step < steps; step += 1) { + if (presses.includes(step) && body.grounded) takeoff(body); + left += speed / 60; + const fate = stepBody(body, left, pieces); + if (body.landed) landedY = Math.max(landedY, body.y); + if (fate !== 'alive') return {fate, body, step, left, landedY}; + } + return {fate: 'alive', body, step: steps, left, landedY}; +} + +test('jump arc: apex, airtime and reach at every speed come from the real profile', () => { + assert.ok(Math.abs(APEX - (JUMP_V ** 2) / (2 * GRAVITY)) < 1); + assert.ok(Math.abs(AIR_STEPS - (2 * JUMP_V / GRAVITY) * 60) <= 1.5); + assert.equal(JUMP_PROFILE[0], 0); + assert.equal(JUMP_PROFILE.at(-1), 0); + assert.ok(APEX >= Math.max(...PLATFORM_LEVELS) + 1.5, 'the apex clears the highest platform with room'); + for (const speed of SPEEDS) { + const stats = jumpStats(speed); + assert.equal(stats.airSteps, AIR_STEPS); + assert.equal(stats.reach, AIR_STEPS * speed / 60); + const run = drive([], speed, 0, 200, [0]); + assert.equal(run.fate, 'alive'); + assert.equal(run.body.grounded, true); + // Touchdown happens exactly AIR_STEPS steps after takeoff, i.e. `reach` px later. + const body = newBody(); takeoff(body); + let left = 0; let n = 0; + do { left += speed / 60; n += 1; stepBody(body, left, []); } while (!body.grounded); + assert.equal(n, AIR_STEPS); + assert.ok(Math.abs(left - stats.reach) < 1e-9); + } +}); + +test('speed phases ramp monotonically to a cap, in quarter pixel steps', () => { + let last = 0; + for (let distance = 0; distance < 20000; distance += 7) { + const speed = speedAt(distance); + assert.ok(speed >= last); + assert.ok(speed <= SPEED_MAX); + assert.equal(stepPx(distance) * 4, Math.round(stepPx(distance) * 4)); + last = speed; + } + assert.equal(speedAt(0), SPEEDS[0]); assert.equal(speedAt(1e9), SPEED_MAX); + assert.ok(difficultyAt(0) === 0 && difficultyAt(1e9) === 1); +}); + +test('platforms: landing on top is safe, the side is a wall, a corner within a pixel is forgiven', () => { + const block = (level: number) => [piece('block', 100, 30, level)]; + // Running into a 4 px block from the ground is a wall. + assert.equal(drive(block(4), 90, 0, 200).fate, 'wall'); + // Jumping in time lands on top and keeps running along it. + let landedOn = false; + for (let press = 0; press < 60; press += 1) { + const run = drive(block(8), 90, 60, 80, [press]); + if (run.landedY === 8) landedOn = true; + } + assert.ok(landedOn, 'some takeoff lands on a level 8 platform'); + // A wall taller than the apex cannot be climbed. + for (let press = 0; press < 60; press += 1) assert.notEqual(drive(block(13), 90, 60, 80, [press]).landedY, 13); + // Corner forgiveness: feet 1 px below the top lands, 1.5 px does not. + const near: Body = {y: 3, vy: 0, grounded: true, landed: false, support: -1}; + assert.equal(stepBody(near, 100 - PW + 1, block(4)), 'alive'); + assert.equal(near.y, 4); + const far: Body = {y: 2.5, vy: 0, grounded: true, landed: false, support: -1}; + assert.equal(stepBody(far, 100 - PW + 1, block(4)), 'wall'); +}); + +test('spikes: the hitbox is smaller than the drawing (fair, slightly forgiving) and exact at its edges', () => { + assert.ok(SPIKE_HIT_W < SPIKE_W && SPIKE_HIT_H < SPIKE_H); + const spike = [piece('spike', 100, SPIKE_W)]; + const at = (left: number, y: number) => { const body: Body = {y, vy: 0, grounded: y === 0, landed: false, support: -1}; return stepBody(body, left, spike); }; + assert.equal(at(100 - PW + 0.5, 6), 'alive', 'above the tip'); + assert.equal(at(100 - PW + 0.5, 0), 'alive', 'touching only the drawn outer pixel'); + assert.equal(at(100 + 1 - PW + 0.5, 0), 'spike', 'overlapping the core'); + assert.equal(at(100 + 1 + SPIKE_HIT_W - 0.5, 0), 'spike'); + assert.equal(at(100 + 1 + SPIKE_HIT_W + 0.1, 0), 'alive'); + assert.equal(at(101, SPIKE_HIT_H + 0.2), 'alive', 'feet above the core clear it'); + assert.equal(at(101, SPIKE_HIT_H - 0.3), 'spike'); +}); + +test('gaps: falling, crossing, and a gap narrower than the cat is no gap', () => { + const gap = (w: number) => [piece('gap', 100, w)]; + const fall = drive(gap(20), 90, 60, 200); + assert.ok(fall.fate === 'fall' || fall.fate === 'wall'); + assert.equal(drive(gap(4), 90, 60, 200).fate, 'alive'); + let crossed = false; + for (let press = 0; press < 60; press += 1) if (drive(gap(20), 90, 60, 200, [press]).fate === 'alive') crossed = true; + assert.ok(crossed); + const tooWide = maxGapAt(90) + 20; + for (let press = 0; press < 80; press += 1) assert.notEqual(drive(gap(tooWide), 90, 60, 200, [press]).fate, 'alive'); +}); + +test('gap maths: widest gap has the declared window at every speed, GAP_MAX leaves margin at the cap', () => { + assert.ok(GAP_MAX <= maxGapAt(SPEED_MAX) - 4); + for (const speed of SPEEDS) { + const from = SPEEDS.indexOf(speed) * 1800; + const widest = maxGapAt(speed); + const measure = (width: number) => { + const pieces = [piece('gap', from + 300, width)]; + const tables = solvePhases(pieces, from + 100, from + 300 + width + 200); + return tables !== undefined; + }; + assert.ok(measure(widest), `${speed}: gap ${widest} is robust`); + assert.ok(!measure(widest + 6), `${speed}: gap ${widest + 6} is not`); + assert.ok(measure(Math.min(GAP_MAX, widest))); + assert.ok(GAP_MIN > PW); + } +}); + +test('patterns: every template is proven at every phase of every speed, with a window of at least WINDOW_STEPS', () => { + const specs = [spikesSpec(1, 0), spikesSpec(2, 4), gapSpec(14), islandSpec(14, 40, 4, 14)]; + for (const speed of SPEEDS) { + const from = SPEEDS.indexOf(speed) * 1800; + for (const spec of specs) { + const pieces = buildPieces(spec, from + 400, 1, () => ++ids); + const tables = solvePhases(pieces, from + 100, from + 400 + spec.length + 200); + if (!tables) continue; + for (const table of tables) assert.equal(table.ok[0], 1); + } + } + // A platform hop needs the apex to clear the platform and the landing spot to exist. + const island = buildPieces(islandSpec(14, 40, 6, 14), 1000, 1, () => ++ids); + assert.ok(solve(island, 900, 1300).ok[0] === 1); + const tooHigh = buildPieces(islandSpec(14, 40, 12, 14), 1000, 1, () => ++ids); + assert.ok(solve(tooHigh, 900, 1300).ok[0] === 0); +}); + +test('generator: thousands of pieces across seeds and speeds are placed as the proof requires', () => { + let pieces = 0; + let units = 0; + const kinds = new Set(); + for (const from of [0, 1800, 3600, 5400, 9000]) { + for (let seed = 1; seed <= 4; seed += 1) { + const course = new Course(createRng(seed * 31 + from).fork('course'), {from}); + course.ensure(from + 4500); + units += course.units.length; pieces += course.pieces.length; + let lastEnd = -Infinity; + for (const unit of course.units) { + kinds.add(unit.kind); + assert.ok(Number.isInteger(unit.x), 'pieces sit on whole pixels'); + assert.ok(unit.x - lastEnd >= MIN_GAP_PX, `units never closer than ${MIN_GAP_PX}`); + lastEnd = unit.end; + } + for (const p of course.pieces) { + if (p.kind === 'gap') assert.ok(p.w >= GAP_MIN && p.w <= GAP_MAX + 2 * 40 + 40); + if (p.kind === 'block') assert.ok(p.level === 0 || p.level <= APEX - 1.5); + } + for (let index = 1; index < course.pieces.length; index += 1) assert.ok(course.pieces[index - 1]!.x <= course.pieces[index]!.x); + // Whole course: robust at every phase (window >= WINDOW_STEPS everywhere), and passable by an independent search. + const first = course.units[0]!; + assert.ok(first.x >= from + 100); + const tables = solvePhases(course.pieces, from, course.units.at(-1)!.end + 250); + assert.ok(tables !== undefined, `seed ${seed} from ${from} is robust`); + assert.ok(passable(course.pieces, from, course.units.at(-1)!.end + 100).ok); + } + } + assert.ok(pieces > 1000, `${pieces} pieces`); + assert.ok(units > 400); + assert.deepEqual([...kinds].sort(), ['block', 'gap', 'island', 'perch', 'pole', 'spikes', 'stairs']); + assert.ok(REST_PX > 0 && REACT_STEPS > 0 && WINDOW_STEPS > 0 && HB_X === 1); +}); + +test('rests: the course goes flat for a long stretch every few units, so the proof restarts there', () => { + const course = new Course(createRng(5).fork('course'), {from: 0}); + course.ensure(6000); + const rests = course.units.filter(unit => unit.afterRest); + assert.ok(rests.length >= 5); + for (const unit of rests) { + const before = course.units[course.units.indexOf(unit) - 1]!; + assert.ok(unit.x - before.end >= REST_PX); + } +}); + +test('simulated players on the real world: windows early, middle, late and random survive 20000 steps at the cap; idling and spamming die', () => { + for (const [seed, from] of [[1, 5400], [2, 5400], [3, 0]] as const) { + const course = replayableCourse(seed, from, from + 20000 * 1.8 + 1500); + for (const timing of ['early', 'mid', 'late', 'random'] as const) { + const result = playBot(seed, 20000, timing, {from, course}); + assert.equal(result.survived, true, `${seed}/${from}/${timing}: ${JSON.stringify(result)}`); + assert.ok(result.jumps > 100); + } + assert.equal(playBot(seed, 20000, 'idle', {from, course}).survived, false); + assert.equal(playBot(seed, 20000, 'spam', {from, course}).survived, false); + } +}); + +test('world: scoring, determinism, jump buffer and the beat', () => { + const make = (seed: number) => new DashWorld(createRng(seed).fork('course')); + const a = make(9); const b = make(9); + for (let step = 0; step < 600; step += 1) { if (step % 37 === 0) { a.jump(); b.jump(); } a.step(); b.step(); assert.equal(a.hash(), b.hash()); } + assert.notEqual(make(10).hash(), make(11).hash()); + const world = make(3); + world.course.pieces.length = 0; + (world.course as unknown as {ensure(x: number): void}).ensure = () => {}; + for (let i = 0; i < 100; i += 1) world.step(); + assert.equal(world.score, Math.floor(world.distance / SCORE_PX)); + assert.equal(world.meters, Math.floor(world.distance / METER_PX)); + // A press in the air is remembered and jumps on touchdown; a press too early is forgotten. + const hop = make(3); hop.course.pieces.length = 0; (hop.course as unknown as {ensure(x: number): void}).ensure = () => {}; + hop.jump(); + for (let i = 0; i < AIR_STEPS - 3; i += 1) hop.step(); + assert.equal(hop.jump(), false); + for (let i = 0; i < 8; i += 1) hop.step(); + assert.equal(hop.airborne, true, 'buffered press jumped again on landing'); + assert.ok(hop.beat >= 0 && hop.beat < 1); +}); 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); +}); 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/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/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/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; +}); 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/askCommandSuggestions.test.ts b/tests/askCommandSuggestions.test.ts new file mode 100644 index 00000000..c8392fba --- /dev/null +++ b/tests/askCommandSuggestions.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +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'; +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 ?? '', /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'], + ['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('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'); + 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}; +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(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..38113500 --- /dev/null +++ b/tests/askCommandSuggestionsApp.test.ts @@ -0,0 +1,195 @@ +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> = {suggestion: {command: 'find . -type f -size +100M', explanation: 'Files over 100 MB here.'}}; + app['understanding'].suggestCommand = async (request: CommandRequest) => { calls.push(request); return reply; }; + let intent: Awaited> = 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, 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'}; }}; +} + +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 ?? '', /^NMSh's check: reads only\. Your local model \(Qwen3 0\.6B\) says: “Files over 100 MB here\.”/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(); } +}); + +/** + * QA F04 (v0.22 physical QA, A12): with the QA model (Qwen3 0.6B) running, `/btw convert every png here to webp` showed + * "Files · " 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(); } +}); diff --git a/tests/backgroundSupervisor.test.ts b/tests/backgroundSupervisor.test.ts index dbcd13c1..a61c30c9 100644 --- a/tests/backgroundSupervisor.test.ts +++ b/tests/backgroundSupervisor.test.ts @@ -109,6 +109,18 @@ test('a pipeline stops as a whole; a detached helper it started (like the adb se }); }); +test('a task that leaves nothing behind is never reported as having processes left, even for a moment; it is removable once exited', async () => { + await withSupervisor(async (supervisor, cwd, changes) => { + for (let run = 0; run < 5; run += 1) { + const {task} = supervisor.start({command: `printf 'done ${run}\\n'`, cwd, shell: SHELL, env: env()}) as {ok: true; task: TaskRecord}; + await waitFor(() => supervisor.get(task.id)!.state === 'exited', 'exited'); + assert.deepEqual(changes.filter(change => change.id === task.id && change.leftovers).map(change => change.state), [], + 'the guard still being alive is not a process left behind'); + assert.equal(supervisor.remove(task.id), true, 'exited with nothing left: removable at once'); + } + }); +}); + test('processes left in the group after the leader exits are reported, and killed only on request', async () => { await withSupervisor(async (supervisor, cwd) => { const {task} = supervisor.start({command: afterSleeperReady(cwd, '60000', 'sleep 1; exit 0'), cwd, shell: SHELL, env: env()}) as {ok: true; task: TaskRecord}; diff --git a/tests/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/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/browserApp.test.ts b/tests/browserApp.test.ts new file mode 100644 index 00000000..914063db --- /dev/null +++ b/tests/browserApp.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +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', 'browser-app-')); + const frame = executionFrameFixture(110, 36); + const app = frame.app; + app['shellCwd'] = dir; + const previous = process.env.NMSH_BROWSER; + // No browser can be found: the tests exercise NMSh's own handling, never a real Chromium. + process.env.NMSH_BROWSER = join(dir, 'no-such-browser'); + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + return {dir, app, frame, screen, done: () => { frame.dispose(); rmSync(dir, {recursive: true, force: true}); if (previous === undefined) delete process.env.NMSH_BROWSER; else process.env.NMSH_BROWSER = previous; }}; +} + +test('/browser with no usable browser says so, downloads nothing, and leaves the shell and the native viewer working', async () => { + const f = fixture(); + try { + await f.app['runSlash']('/browser', {kind: 'browser', target: ''}); + assert.equal(f.app['browserPanel'], undefined); + const text = f.screen().replace(/\s+/gu, ' '); + assert.match(text, /NMSH_BROWSER points to .* not an executable file/u); + assert.match(text, /does not download a browser/u); + assert.equal(f.app['browserHost']?.running ?? false, false, 'nothing was started'); + } finally { f.done(); } +}); + +test('/browser is refused while a command runs, when the setting is off, and for addresses it will not open', async () => { + const f = fixture(); + try { + f.frame.begin('sleep 5'); + await f.app['runSlash']('/browser https://example.com', {kind: 'browser', target: 'https://example.com'}); + assert.equal(f.app['browserPanel'], undefined); + assert.match(f.screen(), /let the running command finish first/u); + f.frame.complete(0); + f.app['promptConfiguration'].browser = 'off'; + await f.app['runSlash']('/browser', {kind: 'browser', target: ''}); + assert.match(f.screen(), /The real browser is off/u); + f.app['promptConfiguration'].browser = 'auto'; + for (const target of ['javascript:alert(1)', 'chrome://settings', 'data:text/html,hi', 'search for cats']) { + await f.app['runSlash'](`/browser ${target}`, {kind: 'browser', target}); + assert.equal(f.app['browserPanel'], undefined, target); + } + assert.match(f.screen(), /cannot be opened|not open|refus|NMSh/iu); + } finally { f.done(); } +}); + +test('the browser setting off keeps the document preview on NMSh\'s own renderer, with no browser started', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'page.html'), '

    Hello

    \n

    world

    \n'); + f.app['promptConfiguration'].browser = 'off'; + f.app['openViewer']('page.html', '/view page.html'); + await settle(() => Boolean(f.app['docSession'])); + f.app['handleKey']({kind: 'text', value: 'h'}); + await new Promise(done => setTimeout(done, 50)); + assert.equal(f.app['docSession'].mode, 'preview'); + assert.equal(f.app['docSession'].preview, undefined); + assert.equal(f.app['browserHost'], undefined, 'no browser host was even created'); + assert.match(f.screen(), /Hello/u); + } finally { f.done(); } +}); + +test('with no browser installed the workspace preview falls back to NMSh\'s own renderer and says why', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'page.md'), '# Title\n\nbody\n'); + f.app['openViewer']('page.md', '/view page.md'); + await settle(() => Boolean(f.app['docSession'])); + f.app['handleKey']({kind: 'text', value: 'h'}); + await settle(() => Boolean(f.app['docSession'].note)); + assert.equal(f.app['docSession'].preview, undefined); + assert.match(f.app['docSession'].note, /Showing NMSh's own preview/u); + assert.match(f.screen(), /Title/u); + f.app['handleKey']({kind: 'text', value: 'e'}); + assert.equal(f.app['docSession'].mode, 'edit', 'editing is unaffected'); + } finally { f.done(); } +}); + +test('/browser notes.md routes a Markdown file to the document preview, not a web page', async () => { + const f = fixture(); + try { + writeFileSync(join(f.dir, 'notes.md'), '# Notes\n'); + await f.app['runSlash']('/browser notes.md', {kind: 'browser', target: 'notes.md'}); + await settle(() => Boolean(f.app['docSession'])); + assert.ok(f.app['docSession']); + assert.equal(f.app['browserPanel'], undefined); + assert.equal(f.app['docSession'].mode, 'preview'); + } finally { f.done(); } +}); diff --git a/tests/browserEngineChrome.test.ts b/tests/browserEngineChrome.test.ts new file mode 100644 index 00000000..3656290a --- /dev/null +++ b/tests/browserEngineChrome.test.ts @@ -0,0 +1,411 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import type {BrowserPage, Frame} from '../src/browser/types.js'; +import {chromeSkipReason, createLab, Events, localSite, pidAlive, scratchDir, waitFor} from './support/browserFixtures.js'; +import {createBrowserEngine} from '../src/browser/engine/index.js'; + +/** + * These drive a real Chromium over the DevTools pipe. They skip (with the reason) when no browser is installed or it cannot start, + * and every test shuts its browser down in `finally`. + */ +const skip = await chromeSkipReason(); +const view = {width: 600, height: 400, scale: 1}; +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); +const settled = (page: BrowserPage, url?: string): Promise => waitFor(() => (url === undefined || page.url === url) && !page.loading && true, `${url ?? 'the page'} to load`); + +test('launch is lazy, uses a private profile, and shutdown leaves no process behind', {skip}, async () => { + const lab = createLab(); + try { + assert.equal(lab.engine.stats().running, false); + const page = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + const stats = lab.engine.stats(); + assert.equal(stats.running, true); + assert.equal(stats.pages, 1); + assert.ok(stats.pid && pidAlive(stats.pid)); + assert.equal(lab.engine.pages().length, 1); + assert.equal(fs.statSync(path.join(lab.dir, 'profile')).mode & 0o777, 0o700); + const events = new Events(page); + await lab.engine.shutdown(); + assert.ok(events.of('closed').length === 1); + assert.equal(lab.engine.stats().running, false); + assert.equal(lab.engine.stats().pages, 0); + await waitFor(() => !pidAlive(stats.pid!), 'the browser process to be gone'); + await lab.engine.shutdown(); // repeatable + } finally { + await lab.dispose(); + } +}); + +test('the browser shuts down after the idle period, a new open cancels it, and a later open relaunches', {skip}, async () => { + const lab = createLab({idleMs: 400}); + try { + const first = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + const pid = lab.engine.stats().pid!; + await first.close(); + assert.equal(lab.engine.pages().length, 0); + const second = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + await sleep(900); + assert.equal(lab.engine.stats().pid, pid, 'an open page keeps the same browser'); + await second.close(); + await waitFor(() => !lab.engine.stats().running, 'the idle shutdown'); + await waitFor(() => !pidAlive(pid), 'the idle browser to exit'); + const third = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + assert.ok(lab.engine.stats().running); + assert.notEqual(lab.engine.stats().pid, pid); + await third.close(); + } finally { + await lab.dispose(); + } +}); + +test('a browser that dies on its own closes every page and the next open starts a new one', {skip}, async () => { + const lab = createLab(); + try { + const page = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + const events = new Events(page); + const pid = lab.engine.stats().pid!; + process.kill(pid, 'SIGKILL'); + await events.next('closed'); + assert.equal(events.of('crashed').length, 1); + await waitFor(() => !lab.engine.stats().running, 'the engine to notice'); + const again = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + assert.notEqual(lab.engine.stats().pid, pid); + await again.close(); + } finally { + await lab.dispose(); + } +}); + +test('a missing browser is reported in plain words and nothing is started', async () => { + const dir = scratchDir(); + const engine = createBrowserEngine({profileDir: path.join(dir, 'p'), env: {PATH: '', NMSH_BROWSER: '/nonexistent/chrome'}}); + try { + const availability = await engine.available(); + assert.equal(availability.ok, false); + await assert.rejects(engine.open({kind: 'web', viewport: view, url: 'about:blank'}), /NMSH_BROWSER/); + assert.equal(engine.stats().running, false); + } finally { + await engine.shutdown(); + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + +test('web pages load from a local server; navigation, back, forward and reload work', {skip}, async () => { + const lab = createLab(); + const site = await localSite({'/a': 'Page A

    a

    ', '/b': 'Page B

    b

    '}); + try { + const page = await lab.engine.open({kind: 'web', viewport: view, url: `${site.origin}/a`}); + const events = new Events(page); + await settled(page, `${site.origin}/a`); + await waitFor(() => page.title === 'Page A', 'the title'); + await page.navigate(`${site.origin}/b`); + await settled(page, `${site.origin}/b`); + await waitFor(() => page.title === 'Page B', 'the second title'); + await waitFor(() => page.canGoBack, 'history'); + await page.back(); + await settled(page, `${site.origin}/a`); + await waitFor(() => page.canGoForward, 'forward history'); + await page.forward(); + await settled(page, `${site.origin}/b`); + const before = site.hits.length; + await page.reload(); + await waitFor(() => site.hits.length > before, 'the reload request'); + await settled(page); + assert.ok(events.of('navigated').length >= 3); + assert.ok(events.of('loading').some(event => event.loading)); + } finally { + await lab.dispose(); + await site.close(); + } +}); + +test('addresses that are not http, https, file or about:blank are refused and never navigated', {skip}, async () => { + const lab = createLab(); + try { + const page = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'}); + const events = new Events(page); + const refused = ['javascript:document.title="x"', 'data:text/html,

    hi

    ', 'chrome://version', 'chrome-untrusted://x/', 'devtools://devtools/bundled/inspector.html', + 'view-source:http://127.0.0.1/', 'blob:http://127.0.0.1/abc', 'ftp://127.0.0.1/', 'not an address']; + for (const address of refused) await page.navigate(address); + assert.equal(events.of('blocked').length, refused.length); + assert.deepEqual(events.of('blocked').map(event => event.url).slice(0, 2), [refused[0], refused[1]]); + assert.equal(page.url, 'about:blank'); + } finally { + await lab.dispose(); + } +}); + +test('a page that redirects or links to a refused scheme ends on about:blank with a blocked event', {skip}, async () => { + const lab = createLab(); + const site = await localSite({'/': 'xstart'}); + try { + const page = await lab.engine.open({kind: 'web', viewport: view, url: site.origin + '/'}); + const events = new Events(page); + await settled(page, site.origin + '/'); + await page.mouse({type: 'down', x: 20, y: 20, button: 'left'}); + await page.mouse({type: 'up', x: 20, y: 20, button: 'left'}); + await sleep(500); + assert.ok(!page.url.startsWith('blob:')); + void events; + } finally { + await lab.dispose(); + await site.close(); + } +}); + +const animation = '
    '; + +test('screencast frames arrive at the viewport size, advance with a CSS animation, and a slow listener skips frames', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview(animation); + const page = await lab.engine.open({kind: 'preview', viewport: {width: 320, height: 200, scale: 1}, url: handle.url}); + const frames: Frame[] = []; + const off = page.onFrame(frame => { frames.push(frame); }); + await waitFor(() => frames.length >= 6, 'six frames'); + off(); + assert.ok(frames.every(frame => frame.width === 320 && frame.height === 200)); + assert.deepEqual(frames.map(frame => frame.sequence), frames.map((_, index) => frames[0]!.sequence + index)); + assert.ok(frames.some(frame => Buffer.compare(frame.png, frames[0]!.png) !== 0), 'the animation changes the picture'); + assert.equal(Buffer.from(frames[0]!.png.subarray(1, 4)).toString(), 'PNG'); + + let fast = 0; + const offFast = page.onFrame(() => { fast++; }); + await sleep(1000); + offFast(); + let slow = 0; + const offSlow = page.onFrame(async () => { slow++; await sleep(300); }); + await sleep(1500); + offSlow(); + assert.ok(slow <= 7, `a slow listener sees few frames, not a backlog (saw ${slow})`); + assert.ok(fast > slow * 2, `a fast listener keeps up (fast ${fast}, slow ${slow})`); + + const still = await page.capture(); + assert.equal(still.width, 320); + const hi = await lab.engine.open({kind: 'preview', viewport: {width: 200, height: 100, scale: 2}, url: handle.url}); + const shot = await hi.capture(); + assert.deepEqual([shot.width, shot.height], [400, 200]); + const seen: Frame[] = []; + hi.onFrame(frame => { seen.push(frame); }); + await waitFor(() => seen.length > 0, 'a frame at scale 2'); + assert.deepEqual([seen[0]!.width, seen[0]!.height], [400, 200]); + } finally { + await lab.dispose(); + } +}); + +test('zoom keeps the frame size and is clamped', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview('

    zoom

    '); + const page = await lab.engine.open({kind: 'preview', viewport: {width: 300, height: 200, scale: 1}, url: handle.url}); + for (const zoom of [1, 2, 0.5, 1.5]) { + await page.setZoom(zoom); + assert.equal(page.zoom, zoom); + const frame = await page.capture(); + assert.deepEqual([frame.width, frame.height], [300, 200], `zoom ${zoom}`); + } + await page.setZoom(100); + assert.equal(page.zoom, 5); + await page.setZoom(0); + assert.equal(page.zoom, 0.25); + await page.setViewport({width: 150, height: 100, scale: 1}); + const frame = await page.capture(); + assert.deepEqual([frame.width, frame.height], [150, 100]); + } finally { + await lab.dispose(); + } +}); + +const form = ` +go +`; + +test('mouse and keys reach a trusted page: typing in a field, editable events, and a click on a link', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview(form, {trusted: true, files: {'next.html': 'next

    second

    '}}); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url, scripts: true}); + const events = new Events(page); + await settled(page, handle.url); + assert.equal(page.editable, false); + await page.mouse({type: 'move', x: 50, y: 25}); + await page.mouse({type: 'down', x: 50, y: 25, button: 'left'}); + await page.mouse({type: 'up', x: 50, y: 25, button: 'left'}); + await waitFor(() => page.editable, 'the field to take focus'); + assert.deepEqual(events.of('editable').map(event => event.editable), [true]); + for (const char of 'hi') { + await page.key('down', {key: char, code: `Key${char.toUpperCase()}`, text: char}); + await page.key('up', {key: char, code: `Key${char.toUpperCase()}`}); + } + await waitFor(() => page.title === 'v:hi', 'typed text to arrive'); + await page.insertText('!'); + await waitFor(() => page.title === 'v:hi!', 'inserted text to arrive'); + await page.key('down', {key: 'Backspace', code: 'Backspace'}); + await page.key('up', {key: 'Backspace', code: 'Backspace'}); + await waitFor(() => page.title === 'v:hi', 'backspace to delete'); + await page.mouse({type: 'down', x: 500, y: 300, button: 'left'}); + await page.mouse({type: 'up', x: 500, y: 300, button: 'left'}); + await waitFor(() => !page.editable, 'the field to lose focus'); + await page.mouse({type: 'down', x: 50, y: 120, button: 'left'}); + await page.mouse({type: 'up', x: 50, y: 120, button: 'left'}); + await settled(page, new URL('next.html', handle.url).href); + await waitFor(() => page.title === 'next', 'the next title'); + assert.equal(events.of('blocked').length, 0); + } finally { + await lab.dispose(); + } +}); + +test('wheel events scroll the page', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview('
    '); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url}); + await settled(page, handle.url); + const before = await page.capture(); + await page.mouse({type: 'wheel', x: 100, y: 100, deltaX: 0, deltaY: 400}); + await sleep(300); + const after = await page.capture(); + assert.notEqual(Buffer.compare(before.png, after.png), 0); + } finally { + await lab.dispose(); + } +}); + +test('find works on a preview with scripts off, steps and wraps, and clears', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview('

    Needle one

    nothing

    second needle

    needle hidden

    NEEDLE last

    '); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url}); + await settled(page, handle.url); + assert.notEqual(page.title, 'scripts ran', 'page scripts are off'); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 1}); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 2}); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 3}); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 1}); + assert.deepEqual(await page.find('needle', {backwards: true}), {matches: 3, current: 3}); + assert.deepEqual(await page.find('absent'), {matches: 0, current: 0}); + await page.clearFind(); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 1}); + await page.reload(); + await settled(page, handle.url); + assert.deepEqual(await page.find('needle'), {matches: 3, current: 1}); + } finally { + await lab.dispose(); + } +}); + +test('setScripts turns page scripts on and off and reloads', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview('before', {trusted: true}); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url}); + await settled(page, handle.url); + await waitFor(() => page.title === 'before', 'the title'); + await page.setScripts(true); + await waitFor(() => page.title === 'ran', 'the script to run'); + await page.setScripts(false); + await waitFor(() => page.title === 'before', 'the script to stop'); + } finally { + await lab.dispose(); + } +}); + +test('popups are closed and reported; dialogs are answered; downloads are refused', {skip}, async () => { + const lab = createLab(); + const site = await localSite({ + '/': `pop +get +`, + '/pop': 'popup', + '/file.bin': {type: 'application/octet-stream', body: 'bytes', headers: {'Content-Disposition': 'attachment; filename=f.bin'}} + }); + try { + const page = await lab.engine.open({kind: 'web', viewport: view, url: site.origin + '/'}); + const events = new Events(page); + await settled(page, site.origin + '/'); + await waitFor(() => events.of('dialog').length >= 3, 'three dialogs'); + assert.deepEqual(events.of('dialog').map(event => [event.kind, event.message]), [['alert', 'hello'], ['confirm', 'sure?'], ['prompt', 'name?']]); + await waitFor(() => page.title === 'confirm:false prompt:null', 'the dialog answers'); + + await page.mouse({type: 'down', x: 20, y: 20, button: 'left'}); + await page.mouse({type: 'up', x: 20, y: 20, button: 'left'}); + const popup = await events.next('popup'); + assert.equal(popup.url, site.origin + '/pop'); + await sleep(300); + assert.equal(lab.engine.pages().length, 1); + assert.equal(site.hits.filter(hit => hit === '/pop').length, 0, 'the new window never loads'); + + await page.mouse({type: 'down', x: 20, y: 120, button: 'left'}); + await page.mouse({type: 'up', x: 20, y: 120, button: 'left'}); + const download = await events.next('download-blocked'); + assert.equal(download.url, site.origin + '/file.bin'); + const files = fs.readdirSync(lab.dir).concat(fs.readdirSync(path.join(lab.dir, 'profile'))); + assert.ok(!files.includes('f.bin')); + } finally { + await lab.dispose(); + await site.close(); + } +}); + +test('a preview refuses remote resources and navigation, and no request leaves the machine', {skip}, async () => { + const lab = createLab(); + const remote = await localSite(); + try { + const html = ` +leave`; + const handle = await lab.preview(html); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url}); + const events = new Events(page); + await settled(page, handle.url); + const img = await events.next('blocked', event => event.url === `${remote.origin}/x.png`); + assert.match(img.reason, /outside/); + await page.navigate(`${remote.origin}/page`); + await events.next('blocked', event => event.url === `${remote.origin}/page`); + await page.mouse({type: 'down', x: 20, y: 120, button: 'left'}); + await page.mouse({type: 'up', x: 20, y: 120, button: 'left'}); + await sleep(500); + assert.equal(page.url, handle.url); + assert.deepEqual(remote.hits, [], 'nothing reached the remote server'); + await page.navigate('http://127.0.0.1:1/'); + assert.equal(page.url, handle.url); + } finally { + await lab.dispose(); + await remote.close(); + } +}); + +test('previews share nothing with browsing: cookies set in a web page are invisible to a preview', {skip}, async () => { + const lab = createLab(); + const site = await localSite({'/': {type: 'text/html', body: 'set', headers: {'Set-Cookie': 'k=v; Path=/'}}}); + try { + const web = await lab.engine.open({kind: 'web', viewport: view, url: site.origin + '/'}); + await settled(web, site.origin + '/'); + const handle = await lab.preview('', {trusted: true}); + const preview = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url, scripts: true}); + await waitFor(() => preview.title.startsWith('cookie:'), 'the preview script'); + assert.equal(preview.title, 'cookie:'); + } finally { + await lab.dispose(); + await site.close(); + } +}); + +test('closing a page closes its target and a preview page disposes its context', {skip}, async () => { + const lab = createLab(); + try { + const handle = await lab.preview('

    x

    '); + const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url}); + const events = new Events(page); + await page.close(); + await page.close(); + assert.equal(events.of('closed').length, 1); + assert.equal(lab.engine.pages().length, 0); + await assert.rejects(page.capture()); + } finally { + await lab.dispose(); + } +}); diff --git a/tests/browserEngineProtocol.test.ts b/tests/browserEngineProtocol.test.ts new file mode 100644 index 00000000..0d3ee810 --- /dev/null +++ b/tests/browserEngineProtocol.test.ts @@ -0,0 +1,279 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {PassThrough} from 'node:stream'; +import {CdpClient, CdpError, type CdpEvent} from '../src/browser/engine/cdp.js'; +import {candidatePaths, locateBrowser, parseChromeMajor, type LocatorHost} from '../src/browser/engine/chrome.js'; +import {chromeArguments, chromeEnvironment} from '../src/browser/engine/engine.js'; +import {checkWebUrl, keyParameters, pngSize} from '../src/browser/engine/page.js'; +import {cleanForDisplay} from '../src/browser/engine/preview.js'; + +/** Chrome-independent: the pipe framing and timeouts, the browser locator, launch arguments, URL policy and key mapping. */ + +function pipe(options?: ConstructorParameters[2]) { + const toBrowser = new PassThrough(); + const fromBrowser = new PassThrough(); + const written: string[] = []; + toBrowser.on('data', (chunk: Buffer) => written.push(chunk.toString('utf8'))); + const client = new CdpClient(toBrowser, fromBrowser, options); + const tick = (): Promise => new Promise(resolve => setImmediate(resolve)); + return {client, written, fromBrowser, toBrowser, tick, reply: (message: object) => fromBrowser.write(`${JSON.stringify(message)}\0`)}; +} + +test('cdp: requests are JSON terminated by one NUL, with ids and an optional session', async () => { + const {client, written, reply, tick} = pipe(); + const first = client.send('Browser.getVersion'); + const second = client.send('Page.enable', {x: 1}, {sessionId: 'S1'}); + await tick(); + const messages = written.join('').split('\0').filter(Boolean).map(text => JSON.parse(text)); + assert.deepEqual(messages, [{id: 1, method: 'Browser.getVersion', params: {}}, {id: 2, method: 'Page.enable', params: {x: 1}, sessionId: 'S1'}]); + assert.ok(written.join('').endsWith('\0')); + reply({id: 2, result: {ok: 2}}); + reply({id: 1, result: {product: 'x'}}); + assert.deepEqual(await first, {product: 'x'}); + assert.deepEqual(await second, {ok: 2}); + client.close(); +}); + +test('cdp: messages split across chunks, packed into one chunk, and split inside a multibyte character', async () => { + const {client, fromBrowser, tick} = pipe(); + const events: CdpEvent[] = []; + client.onEvent(event => events.push(event)); + const a = Buffer.from(`${JSON.stringify({method: 'A.one', params: {text: 'héllo €'}, sessionId: 'S'})}\0${JSON.stringify({method: 'B.two'})}\0`); + fromBrowser.write(a); + await tick(); + assert.deepEqual(events.map(event => event.method), ['A.one', 'B.two']); + assert.equal(events[0]!.params.text, 'héllo €'); + assert.equal(events[0]!.sessionId, 'S'); + assert.deepEqual(events[1]!.params, {}); + events.length = 0; + const b = Buffer.from(`${JSON.stringify({method: 'C.three', params: {text: '€€€'}})}\0`); + const cut = b.indexOf(0xe2) + 1; // inside the first euro sign + fromBrowser.write(b.subarray(0, cut)); + await tick(); + assert.equal(events.length, 0); + fromBrowser.write(b.subarray(cut, cut + 1)); + fromBrowser.write(b.subarray(cut + 1)); + await tick(); + assert.equal(events[0]!.params.text, '€€€'); + fromBrowser.write('\0\0'); // empty frames are ignored + fromBrowser.write('not json\0'); + fromBrowser.write('42\0'); + fromBrowser.write('{"method":"D.four"}\0'); + await tick(); + assert.equal(events.at(-1)!.method, 'D.four'); + client.close(); +}); + +test('cdp: an error response rejects with the protocol message and code; a throwing listener does not break the stream', async () => { + const {client, reply, tick} = pipe(); + const seen: string[] = []; + client.onEvent(() => { throw new Error('listener bug'); }); + client.onEvent(event => seen.push(event.method)); + const pending = client.send('Nope.nope'); + reply({id: 1, error: {code: -32601, message: "'Nope.nope' wasn't found"}}); + await assert.rejects(pending, (error: unknown) => error instanceof CdpError && error.code === -32601 && /wasn't found/u.test(error.message)); + reply({method: 'E.after'}); + await tick(); + assert.deepEqual(seen, ['E.after']); + client.close(); +}); + +test('cdp: a request that is not answered in time rejects, and a late answer is ignored', async () => { + const {client, reply} = pipe({requestTimeoutMs: 40}); + await assert.rejects(client.send('Slow.thing'), /did not answer Slow.thing/u); + reply({id: 1, result: {}}); // late + const quick = client.send('Quick.thing', {}, {timeoutMs: 1000}); + reply({id: 2, result: {done: true}}); + assert.deepEqual(await quick, {done: true}); + client.close(); +}); + +test('cdp: a single oversized message tears the connection down and rejects everything pending', async () => { + const {client, fromBrowser, tick} = pipe({maxMessageBytes: 1024}); + const pending = client.send('Some.call'); + const closed: string[] = []; + client.onClose(reason => closed.push(reason)); + fromBrowser.write('x'.repeat(600)); + await tick(); + assert.equal(client.closed, false); + fromBrowser.write('y'.repeat(600)); // 1200 buffered without a NUL + await assert.rejects(pending, /larger than/u); + assert.equal(client.closed, true); + assert.equal(closed.length, 1); + await assert.rejects(client.send('After.close'), CdpError); +}); + +test('cdp: a message of exactly the limit is accepted', async () => { + const {client, fromBrowser, tick} = pipe({maxMessageBytes: 200}); + const events: CdpEvent[] = []; + client.onEvent(event => events.push(event)); + const filler = 'z'.repeat(200 - JSON.stringify({method: 'M.m', params: {s: ''}}).length); + const body = JSON.stringify({method: 'M.m', params: {s: filler}}); + assert.equal(Buffer.byteLength(body), 200); + fromBrowser.write(`${body}\0`); + await tick(); + assert.equal(events.length, 1); + client.close(); +}); + +test('cdp: when the pipe closes every pending request rejects once and later sends fail', async () => { + const {client, fromBrowser, tick} = pipe(); + const a = client.send('A.a'); + const b = client.send('B.b'); + const reasons: string[] = []; + client.onClose(reason => reasons.push(reason)); + fromBrowser.end(); + await assert.rejects(a, /closed its connection/u); + await assert.rejects(b, /closed its connection/u); + await tick(); + assert.equal(reasons.length, 1); + client.close(); + assert.equal(reasons.length, 1); + await assert.rejects(client.send('C.c'), /closed its connection/u); +}); + +test('cdp: a failed write (the browser is gone) closes the client', async () => { + const {client, toBrowser, tick} = pipe(); + const pending = client.send('A.a'); + toBrowser.destroy(new Error('EPIPE')); + await assert.rejects(pending, /browser connection failed|closed its connection/u); + await tick(); + assert.equal(client.closed, true); +}); + +// ---- locating the browser --------------------------------------------------------------------------------------------------- + +function fakeHost(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, files: Record): LocatorHost & {probed: string[]} { + const probed: string[] = []; + return { + platform, env, probed, + async isExecutable(file) { return Object.hasOwn(files, file); }, + async version(file) { + probed.push(file); + const value = files[file]; + if (value instanceof Error) throw value; + return value ?? ''; + } + }; +} + +test('locator: macOS prefers Chrome, then Chromium, then Edge, and reads the version', async () => { + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const edge = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'; + assert.deepEqual(await locateBrowser(fakeHost('darwin', {}, {[chrome]: 'Google Chrome 156.0.8078.35', [edge]: 'Microsoft Edge 150.0.1.1'})), {ok: true, executable: chrome, version: 'Google Chrome 156.0.8078.35'}); + const result = await locateBrowser(fakeHost('darwin', {}, {[edge]: 'Microsoft Edge 150.0.1.1'})); + assert.equal(result.ok && result.executable, edge); +}); + +test('locator: an old Chrome is skipped for a newer one, and reported if it is all there is', async () => { + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const chromium = '/Applications/Chromium.app/Contents/MacOS/Chromium'; + const both = await locateBrowser(fakeHost('darwin', {}, {[chrome]: 'Google Chrome 100.0.1.1', [chromium]: 'Chromium 130.0.1.1'})); + assert.equal(both.ok && both.executable, chromium); + const old = await locateBrowser(fakeHost('darwin', {}, {[chrome]: 'Google Chrome 100.0.1.1'})); + assert.equal(old.ok, false); + assert.match(!old.ok ? old.reason : '', /version 100.*110 or newer/u); +}); + +test('locator: Linux searches PATH in the documented name order', async () => { + const host = fakeHost('linux', {PATH: '/usr/local/bin:/usr/bin:relative/bin:/opt/bin'}, {'/usr/bin/chromium': 'Chromium 125.0.6422.60', '/opt/bin/google-chrome': 'Google Chrome 126.0.6478.55', '/usr/local/bin/chromium-browser': 'Chromium 120.0.1.1'}); + const found = await locateBrowser(host); + assert.equal(found.ok && found.executable, '/opt/bin/google-chrome'); + const order = (await candidatePaths(host)).map(file => file.split('/').pop()); + assert.deepEqual([...new Set(order)], ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']); + assert.ok(!(await candidatePaths(host)).some(file => file.startsWith('relative'))); +}); + +test('locator: nothing found says what to install or set, and nothing is run', async () => { + const host = fakeHost('linux', {PATH: '/usr/bin'}, {}); + const result = await locateBrowser(host); + assert.equal(result.ok, false); + assert.match(!result.ok ? result.reason : '', /No Chrome, Chromium or Edge was found.*NMSH_BROWSER/u); + assert.deepEqual(host.probed, []); +}); + +test('locator: NMSH_BROWSER must be an absolute executable and is used without falling back', async () => { + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const files = {[chrome]: 'Google Chrome 156.0.8078.35', '/opt/mine/chrome': 'Chromium 140.0.1.1', '/opt/mine/old': 'Chromium 90.0.1.1', '/opt/mine/junk': 'hello world', '/opt/mine/broken': new Error('spawn failed')}; + const ok = await locateBrowser(fakeHost('darwin', {NMSH_BROWSER: '/opt/mine/chrome'}, files)); + assert.equal(ok.ok && ok.executable, '/opt/mine/chrome'); + const relative = await locateBrowser(fakeHost('darwin', {NMSH_BROWSER: 'chrome'}, files)); + assert.match(!relative.ok ? relative.reason : '', /absolute path/u); + const missing = await locateBrowser(fakeHost('darwin', {NMSH_BROWSER: '/opt/none'}, files)); + assert.match(!missing.ok ? missing.reason : '', /not an executable file/u); + for (const name of ['old', 'junk', 'broken']) { + const bad = await locateBrowser(fakeHost('darwin', {NMSH_BROWSER: `/opt/mine/${name}`}, files)); + assert.equal(bad.ok, false, name); + } + const overridden = await locateBrowser(fakeHost('darwin', {}, files), '/opt/mine/chrome'); + assert.equal(overridden.ok && overridden.executable, '/opt/mine/chrome'); +}); + +test('locator: version strings', () => { + assert.equal(parseChromeMajor('Google Chrome 156.0.8078.35'), 156); + assert.equal(parseChromeMajor('Chromium 120.0.6099.109 snap'), 120); + assert.equal(parseChromeMajor('Microsoft Edge 120.0.2210.91'), 120); + assert.equal(parseChromeMajor('hello'), undefined); +}); + +// ---- launch -------------------------------------------------------------------------------------------------------------------- + +test('launch arguments: the documented hardening flags, the sandbox on, no-sandbox only on request', () => { + const args = chromeArguments('/p/profile', {}); + for (const flag of ['--headless=new', '--remote-debugging-pipe', '--user-data-dir=/p/profile', '--no-first-run', '--no-default-browser-check', '--disable-extensions', + '--disable-component-update', '--disable-background-networking', '--disable-sync', '--disable-breakpad', '--disable-default-apps', '--mute-audio', '--renderer-process-limit=2', + '--disable-features=Translate,MediaRouter,OptimizationHints,InterestFeedContentSuggestions', '--force-color-profile=srgb']) assert.ok(args.includes(flag), flag); + assert.ok(!args.includes('--no-sandbox')); + assert.ok(!args.some(arg => arg.startsWith('--remote-debugging-port'))); + assert.ok(chromeArguments('/p', {NMSH_BROWSER_NO_SANDBOX: '1'}).includes('--no-sandbox')); + assert.ok(!chromeArguments('/p', {NMSH_BROWSER_NO_SANDBOX: '0'}).includes('--no-sandbox')); + assert.ok(chromeArguments('/p', {}, 'darwin').includes('--use-mock-keychain')); + assert.ok(chromeArguments('/p', {}, 'linux').includes('--password-store=basic')); +}); + +test('launch environment: only PATH, LANG and TMPDIR pass through and HOME is the profile', () => { + const env = chromeEnvironment('/p/profile', {PATH: '/bin', LANG: 'en_US.UTF-8', TMPDIR: '/t', HOME: '/Users/me', GITHUB_TOKEN: 'secret', ANTHROPIC_API_KEY: 'k', NMSH_X: '1', SSH_AUTH_SOCK: '/s'}); + assert.deepEqual(env, {HOME: '/p/profile', PATH: '/bin', LANG: 'en_US.UTF-8', TMPDIR: '/t'}); + assert.deepEqual(chromeEnvironment('/p', {}), {HOME: '/p'}); +}); + +// ---- page policy and input ----------------------------------------------------------------------------------------------------- + +test('url policy: http, https, file and about:blank only', () => { + for (const address of ['http://example.com/', 'https://example.com/a?b#c', 'file:///tmp/x.html', 'about:blank', ' ABOUT:BLANK ', 'HTTP://EXAMPLE.COM']) assert.equal(checkWebUrl(address).ok, true, address); + for (const address of ['javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,hi', 'chrome://settings', 'chrome-untrusted://x', 'chrome-error://chromewebdata/', 'devtools://devtools/x', 'view-source:https://a/', + 'blob:https://a/uuid', 'ftp://a/', 'about:srcdoc', 'about:version', 'filesystem:https://a/t/f', 'ws://a/', 'mailto:a@b', '', 'example.com', 'http//x']) assert.equal(checkWebUrl(address).ok, false, address); + const normalised = checkWebUrl('HTTP://EXAMPLE.COM'); + assert.equal(normalised.ok && normalised.url, 'http://example.com/'); +}); + +test('key mapping: printable keys type text, others are raw keys with virtual codes, ctrl/meta make editing commands', () => { + assert.deepEqual(keyParameters('down', {key: 'a', code: 'KeyA', text: 'a'}), {key: 'a', modifiers: 0, code: 'KeyA', windowsVirtualKeyCode: 65, type: 'keyDown', text: 'a', unmodifiedText: 'a'}); + assert.equal(keyParameters('down', {key: 'Enter'}).type, 'keyDown'); + assert.equal(keyParameters('down', {key: 'Enter'}).text, '\r'); + assert.equal(keyParameters('down', {key: 'Enter'}).windowsVirtualKeyCode, 13); + assert.equal(keyParameters('down', {key: 'ArrowDown'}).type, 'rawKeyDown'); + assert.equal(keyParameters('down', {key: 'ArrowDown'}).windowsVirtualKeyCode, 40); + assert.equal(keyParameters('down', {key: 'Backspace'}).windowsVirtualKeyCode, 8); + assert.equal(keyParameters('down', {key: 'F5'}).windowsVirtualKeyCode, 116); + assert.equal(keyParameters('down', {key: 'Escape'}).type, 'rawKeyDown'); + assert.equal(keyParameters('up', {key: 'a'}).type, 'keyUp'); + assert.deepEqual(keyParameters('char', {key: 'a', text: 'à'}).text, 'à'); + const select = keyParameters('down', {key: 'a', modifiers: 2}); + assert.equal(select.type, 'rawKeyDown'); + assert.deepEqual(select.commands, ['selectAll']); + assert.deepEqual(keyParameters('down', {key: 'v', modifiers: 4}).commands, ['paste']); + assert.equal(keyParameters('down', {key: 'q', modifiers: 2}).commands, undefined); + assert.equal(keyParameters('down', {key: 'A', text: 'A', modifiers: 8}).type, 'keyDown'); + assert.equal(keyParameters('down', {key: 'a', text: 'a', modifiers: 1}).type, 'rawKeyDown', 'Alt+key is not text'); +}); + +test('png size is read from the header; display strings lose control characters', () => { + const png = Buffer.alloc(33); + png.writeUInt32BE(640, 16); + png.writeUInt32BE(480, 20); + assert.deepEqual(pngSize(png), {width: 640, height: 480}); + assert.deepEqual(pngSize(new Uint8Array(4)), {width: 0, height: 0}); + assert.equal(cleanForDisplay('a\u001b[31mb\u0007c\u0085d‮e⁦f؜g\u007fh'), 'a[31mbcdefgh'); + assert.equal(cleanForDisplay('x'.repeat(500), 10), 'x'.repeat(10)); +}); diff --git a/tests/browserPanel.test.ts b/tests/browserPanel.test.ts new file mode 100644 index 00000000..05acc398 --- /dev/null +++ b/tests/browserPanel.test.ts @@ -0,0 +1,521 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import type {GraphicsDecision} from '../src/graphics/capabilities.js'; +import {PLACEHOLDER_CHAR} from '../src/graphics/kittyPlaceholder.js'; +import {FrameStream} from '../src/graphics/stream.js'; +import {browserPanelKey, createBrowserPanel, renderBrowserPanel, type BrowserPanelAction, type PanelGeometry} from '../src/browser/BrowserPanel.js'; +import {BrowserSession, CHANGE_INTERVAL_MS, MAX_TABS, ZOOM_STEPS} from '../src/browser/session.js'; +import type {Key} from '../src/terminal/keys.js'; +import {stripAnsi} from '../src/util/text.js'; +import {FakeClock, FakeEngine, pngFrame, settle, type FakePage} from './support/fakeBrowser.js'; + +const KITTY: GraphicsDecision = {backend: 'kitty', evidence: 'query', inline: 'placeholder', unicode: 'quadrant', cell: {width: 8, height: 16, source: 'estimated'}, reason: ''}; +const GEO: PanelGeometry = {top: 1, cell: {width: 8, height: 16}, scale: 1}; + +function rig(options: {decision?: GraphicsDecision; cwd?: string; scale?: number | 'auto'} = {}) { + const engine = new FakeEngine(); + const clock = new FakeClock(); + const sent: string[] = []; + const streams: FrameStream[] = []; + let id = 0x4e4d00; + const session = new BrowserSession({ + engine, clock, scale: options.scale ?? 1, cell: () => ({width: 8, height: 16}), + openStream: () => { + const s = new FrameStream({decision: () => options.decision ?? KITTY, send: data => { sent.push(data); }, allocateId: () => ++id, color: () => 'truecolor', safeGlyphs: () => false, now: () => clock.time}); + streams.push(s); + return s; + }, + }); + const panel = createBrowserPanel(session, {cwd: options.cwd ?? '/'}); + const page = (n = 0): FakePage => engine.opened[n]!; + const key = async (k: Key): Promise => { const out = browserPanelKey(panel, k, GEO); await settle(); return out; }; + const text = (value: string) => key({kind: 'text', value}); + const render = (columns = 80, height = 24) => renderBrowserPanel(panel, columns, height); + const start = async (url = 'https://example.com/') => { await session.open(url); render(); clock.advance(200); await settle(); }; + return {engine, clock, sent, streams, session, panel, page, key, text, render, start}; +} + +const pageCalls = (r: ReturnType, name: string) => r.engine.opened.reduce((n, page) => n + page.count(name), 0); + +// ---- focus model ------------------------------------------------------------------------------------------------------ + +const PRINTABLE = Array.from({length: 95}, (_, i) => String.fromCharCode(32 + i)); +const KINDS = ['enter', 'newline', 'complete', 'focusPrevious', 'backspace', 'delete', 'left', 'right', 'up', 'down', 'lineHome', 'lineEnd', 'pageUp', 'pageDown', 'deleteWord', 'deleteLineBefore', 'deleteLineAfter', 'historySearch', 'find', 'toggleDetails', 'queueCommand', 'sendToProgram', 'redo', 'suggestNext', 'suggestPrevious', 'historyDelete', 'latest', 'eof', 'suspend', 'palette', 'selectAll', 'wordLeft', 'wordRight', 'bufferHome', 'bufferEnd'] as const; + +test('focus: a new browser starts in chrome', async () => { + const r = rig(); + assert.equal(r.panel.focus, 'chrome'); +}); + +test('focus: in chrome no key of any class reaches the page', async () => { + const r = rig(); + await r.start(); + const keys: Key[] = [...PRINTABLE.map(value => ({kind: 'text', value}) as Key), ...KINDS.map(kind => ({kind}) as Key), {kind: 'paste', value: 'pasted'}, {kind: 'wheelUp'}, {kind: 'wheelDown'}]; + for (const k of keys) { + r.panel.focus = 'chrome'; + const action = await r.key(k); + if (action?.kind === 'close') r.panel.focus = 'chrome'; + if (r.engine.opened.length > 1) for (const extra of r.engine.opened.slice(1)) await extra.close(); + } + assert.equal(pageCalls(r, 'key'), 0, 'no key event'); + assert.equal(pageCalls(r, 'insertText'), 0, 'no inserted text'); + r.session.dispose(); +}); + +test('focus: chrome commands do what they say', async () => { + const r = rig(); + await r.start(); + const p = r.page(); + await r.text('j'); await r.text('k'); await r.text(' '); await r.text('b'); await r.text('g'); await r.text('G'); + const wheels = p.calls.filter(c => c.name === 'mouse').map(c => (c.args[0] as {deltaY: number}).deltaY); + assert.equal(wheels.length, 6); + assert.ok(wheels[0]! > 0 && wheels[1]! < 0 && wheels[2]! > 0 && wheels[3]! < 0 && wheels[4]! < 0 && wheels[5]! > 0); + await r.text('['); await r.text(']'); await r.text('H'); await r.text('L'); + assert.equal(p.count('back'), 2); assert.equal(p.count('forward'), 2); + await r.text('r'); await r.text('R'); await r.text('s'); + assert.deepEqual(p.calls.filter(c => c.name === 'reload').map(c => c.args[0]), [false, true]); + assert.equal(p.count('stop'), 1); + await r.text('+'); assert.equal(p.zoom, 1.1); + await r.text('-'); await r.text('-'); assert.equal(p.zoom, 0.9); + await r.text('0'); assert.equal(p.zoom, 1); + assert.deepEqual(await r.text('c'), {kind: 'copy', text: 'https://example.com/', label: 'Copied the page address'}); + await r.text('o'); assert.equal(r.panel.focus, 'address'); assert.equal(r.panel.draft, 'https://example.com/'); + await r.key({kind: 'escape'}); assert.equal(r.panel.focus, 'chrome'); + await r.text('/'); assert.equal(r.panel.focus, 'find'); await r.key({kind: 'escape'}); + await r.text('i'); assert.equal(r.panel.focus, 'page'); await r.key({kind: 'escape'}); + await r.key({kind: 'enter'}); assert.equal(r.panel.focus, 'page'); await r.key({kind: 'escape'}); + assert.deepEqual(await r.key({kind: 'escape'}), {kind: 'close'}, 'Esc in chrome leaves the browser'); + assert.deepEqual(await r.text('x'), {kind: 'close'}, 'closing the only tab leaves the browser'); + assert.equal(pageCalls(r, 'key'), 0); + r.session.dispose(); +}); + +test('focus: tabs switch with Tab, Shift+Tab and digits; t opens, x closes', async () => { + const r = rig(); + await r.start(); + await r.text('t'); await settle(); + assert.equal(r.session.count, 2); assert.equal(r.panel.focus, 'address'); assert.equal(r.session.activeIndex, 1); + await r.key({kind: 'escape'}); + await r.text('1'); assert.equal(r.session.activeIndex, 0); + await r.key({kind: 'complete'}); assert.equal(r.session.activeIndex, 1); + await r.key({kind: 'focusPrevious'}); assert.equal(r.session.activeIndex, 0); + await r.text('9'); assert.equal(r.session.activeIndex, 0, 'a tab that does not exist is ignored'); + await r.text('x'); assert.equal(r.session.count, 1); + assert.equal(r.page(0).closed, true, 'the active tab was the first'); + assert.equal(r.page(1).closed, false); + r.session.dispose(); +}); + +test('focus: in page every key but Esc is forwarded; Esc returns to chrome; Ctrl+C closes', async () => { + const r = rig(); + await r.start(); + r.panel.focus = 'page'; + let forwarded = 0; + for (const value of PRINTABLE) { await r.text(value); forwarded += 1; } + for (const kind of KINDS) { + const before = pageCalls(r, 'key'); + await r.key({kind} as Key); + assert.equal(pageCalls(r, 'key') - before, 2, `${kind} is a press and a release`); + forwarded += 1; + assert.equal(r.panel.focus, 'page', `${kind} does not change focus`); + } + assert.equal(pageCalls(r, 'key'), forwarded * 2); + const downs = r.page().calls.filter(c => c.name === 'key' && c.args[0] === 'down').map(c => (c.args[1] as {key: string}).key); + assert.equal(downs[0], ' '); assert.ok(downs.includes('q') && downs.includes('/') && downs.includes('j'), 'command letters are typed into the page'); + const before = pageCalls(r, 'key'); + assert.equal(await r.key({kind: 'escape'}), undefined); + assert.equal(r.panel.focus, 'chrome'); + assert.equal(pageCalls(r, 'key'), before, 'Esc is not forwarded'); + r.panel.focus = 'page'; + assert.deepEqual(await r.key({kind: 'interrupt'}), {kind: 'close'}); + assert.equal(pageCalls(r, 'key'), before, 'Ctrl+C is not forwarded'); + for (const focus of ['chrome', 'address', 'find'] as const) { r.panel.focus = focus; assert.deepEqual(await r.key({kind: 'interrupt'}), {kind: 'close'}, focus); } + r.session.dispose(); +}); + +test('focus: paste goes to the page only in page focus', async () => { + const r = rig(); + await r.start(); + await r.key({kind: 'paste', value: 'x'}); + assert.equal(pageCalls(r, 'insertText'), 0); + r.panel.focus = 'page'; + await r.key({kind: 'paste', value: 'hello\nworld'}); + assert.deepEqual(r.page().calls.find(c => c.name === 'insertText')?.args, ['hello\nworld']); + r.session.dispose(); +}); + +test('focus: address editing, navigation, refusals and Markdown routing', async () => { + const dir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bp-')); + try { + writeFileSync(join(dir, 'a.md'), '#'); + const r = rig({cwd: dir}); + await r.start(); + await r.text('o'); + for (let i = 0; i < 30; i += 1) await r.key({kind: 'backspace'}); + assert.equal(r.panel.draft, ''); + for (const ch of 'example.org') await r.text(ch); + await r.key({kind: 'enter'}); + assert.deepEqual(r.page().calls.find(c => c.name === 'navigate')?.args, ['https://example.org/']); + assert.equal(r.panel.focus, 'chrome'); + await r.text('o'); await r.key({kind: 'deleteWord'}); + for (const ch of 'javascript:alert(1)') await r.text(ch); + await r.key({kind: 'enter'}); + assert.equal(r.panel.focus, 'address', 'a refused address stays in the editor'); + assert.match(r.session.notice() ?? '', /never run/u); + assert.equal(r.page().count('navigate'), 1); + await r.key({kind: 'deleteWord'}); + await r.key({kind: 'paste', value: 'a.md\n'}); + assert.deepEqual(await r.key({kind: 'enter'}), {kind: 'open-document', path: join(dir, 'a.md')}); + assert.equal(r.page().count('navigate'), 1, 'a Markdown file is not loaded in the browser'); + r.session.dispose(); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('focus: find edits a query, Enter searches then steps, n and N step, an empty query clears', async () => { + const r = rig(); + await r.start(); + await r.text('/'); + for (const ch of 'cat') await r.text(ch); + await r.key({kind: 'enter'}); + assert.deepEqual(r.page().calls.find(c => c.name === 'find')?.args, ['cat', false]); + assert.deepEqual(r.session.activeView()?.find, {query: 'cat', matches: 3, current: 1}); + await r.key({kind: 'enter'}); + assert.equal(r.page().count('find'), 2); + await r.key({kind: 'escape'}); + assert.equal(r.panel.focus, 'chrome'); + await r.text('N'); + assert.deepEqual(r.page().calls.filter(c => c.name === 'find').at(-1)?.args, ['cat', true]); + await r.text('/'); assert.equal(r.panel.findDraft, 'cat'); + for (let i = 0; i < 3; i += 1) await r.key({kind: 'backspace'}); + await r.key({kind: 'enter'}); + assert.equal(r.page().count('clearFind'), 1); + assert.equal(r.session.activeView()?.find, undefined); + r.session.dispose(); +}); + +// ---- mouse ------------------------------------------------------------------------------------------------------------ + +test('mouse: a click on the page focuses it and is forwarded in CSS pixels; release and drag follow; outside is not forwarded', async () => { + const r = rig(); + await r.start(); + const area = r.render().pageArea; + assert.equal(r.panel.focus, 'chrome'); + await r.key({kind: 'mouseClick', x: 1, y: GEO.top + area.row}); + assert.equal(r.panel.focus, 'page'); + const down = r.page().calls.find(c => c.name === 'mouse')?.args[0] as {type: string; x: number; y: number}; + assert.deepEqual([down.type, down.x, down.y], ['down', 4, 8]); + await r.key({kind: 'mouseDrag', x: 5, y: GEO.top + area.row + 2}); + await r.key({kind: 'mouseRelease', x: 5, y: GEO.top + area.row + 2}); + const types = r.page().calls.filter(c => c.name === 'mouse').map(c => (c.args[0] as {type: string}).type); + assert.deepEqual(types, ['down', 'move', 'up']); + r.panel.focus = 'chrome'; + const n = r.page().count('mouse'); + await r.key({kind: 'mouseMove', x: 3, y: GEO.top + area.row}); + await r.key({kind: 'mouseRelease', x: 3, y: GEO.top + area.row}); + assert.equal(r.page().count('mouse'), n, 'hover in chrome and a stray release are not forwarded'); + await r.key({kind: 'wheelDown'}); + assert.equal(r.page().count('mouse'), n + 1, 'the wheel scrolls in either focus'); + r.session.dispose(); +}); + +test('mouse: clicks on the tab strip and the address row are browser clicks, not page clicks', async () => { + const r = rig(); + await r.start(); + await r.text('t'); await settle(); await r.key({kind: 'escape'}); + r.render(); + const layout = r.panel.layout!; + assert.equal(r.session.activeIndex, 1); + await r.key({kind: 'mouseClick', x: layout.tabHits[0]!.start + 2, y: GEO.top + layout.tabRow}); + assert.equal(r.session.activeIndex, 0); + await r.key({kind: 'mouseClick', x: 10, y: GEO.top + layout.addressRow}); + assert.equal(r.panel.focus, 'address'); + assert.equal(pageCalls(r, 'mouse'), 0); + r.session.dispose(); +}); + +// ---- session ---------------------------------------------------------------------------------------------------------- + +test('session: tab lifecycle cleans up subscriptions, streams and uploads', async () => { + const r = rig(); + await r.start(); + await r.page().push(pngFrame(640, 400, 1)); + await r.session.open('https://b.example/'); + const second = r.page(1); + assert.equal(second.frameListeners.size, 1); assert.equal(second.eventListeners.size, 1); + r.clock.advance(200); + await second.push(pngFrame(640, 400, 2)); + const uploadsBefore = r.sent.length; + r.session.closeTab(1); + assert.equal(second.frameListeners.size, 0); assert.equal(second.eventListeners.size, 0); + assert.equal(second.closed, true); + assert.ok(r.sent.slice(uploadsBefore).some(s => s.includes('a=d,d=I')), 'its picture is deleted from the terminal'); + assert.equal(r.session.count, 1); + const first = r.page(); + r.session.dispose(); + assert.equal(first.frameListeners.size, 0); assert.equal(first.eventListeners.size, 0); assert.equal(first.closed, true); + const after = r.sent.length; + first.emit({type: 'title', title: 'late'}); + await first.push(pngFrame(10, 10, 3)); + assert.equal(r.sent.length, after, 'nothing happens after dispose'); + r.session.dispose(); +}); + +test('session: switching tabs releases the hidden tab picture and draws the shown one again', async () => { + const r = rig(); + await r.start(); + await r.page().push(pngFrame(640, 400, 1)); + assert.ok(r.session.hasPicture()); + await r.session.open('https://b.example/'); + assert.ok(r.sent.some(s => s.includes('a=d,d=I')), 'the first tab picture was deleted when it was hidden'); + r.session.activate(0); + assert.ok(r.session.hasPicture(), 'drawn again from its latest frame'); + assert.ok(r.session.pageLines().every(line => line.includes(PLACEHOLDER_CHAR))); + r.session.dispose(); +}); + +test('session: popups open as tabs up to twelve; the 13th and bad addresses are refused with a notice', async () => { + const r = rig(); + await r.start(); + const first = r.page(); + for (let i = 0; i < MAX_TABS - 1; i += 1) { first.emit({type: 'popup', url: `https://p${i}.example/`}); await settle(); } + assert.equal(r.session.count, MAX_TABS); + first.emit({type: 'popup', url: 'https://one-too-many.example/'}); await settle(); + assert.equal(r.session.count, MAX_TABS); + assert.match(r.session.notice() ?? '', /blocked/u); + const refused = await r.session.open('https://x.example/'); + assert.equal(refused.ok, false); + r.session.closeTab(5); + first.emit({type: 'popup', url: 'javascript:alert(1)'}); await settle(); + assert.equal(r.session.count, MAX_TABS - 1); + assert.match(r.session.notice() ?? '', /cannot open/u); + r.session.dispose(); +}); + +test('session: blocked, dialog and download events become short notices; page text is made inert', async () => { + const r = rig(); + await r.start(); + const p = r.page(); + p.emit({type: 'blocked', url: 'https://tracker.example/', reason: 'remote\u001b[31m resource'}); + assert.match(r.session.notice()!, /^Blocked: remote/u); + assert.ok(!r.session.notice()!.includes('\u001b')); + p.emit({type: 'dialog', kind: 'alert', message: 'Hello‮'}); + assert.match(r.session.notice()!, /alert: Hello/u); + p.emit({type: 'download-blocked', url: 'https://x/y.zip'}); + assert.match(r.session.notice()!, /download was blocked/u); + p.emit({type: 'title', title: 'T\u001b]0;evil\u0007itle'}); + assert.ok(!r.session.activeView()!.title.includes('\u001b')); + r.clock.advance(6000); + assert.equal(r.session.notice(), undefined, 'notices expire'); + r.session.dispose(); +}); + +test('session: resize sets the viewport from the cell size once, debounced; frames are requested again', async () => { + const r = rig({scale: 'auto'}); + await r.session.open('https://example.com/'); + const p = r.page(); + r.session.resize({columns: 100, rows: 30}); + r.session.resize({columns: 90, rows: 28}); + r.session.resize({columns: 80, rows: 20}); + assert.equal(p.count('setViewport'), 0, 'nothing until the size settles'); + r.clock.advance(100); + r.session.resize({columns: 82, rows: 20}); + r.clock.advance(100); + assert.equal(p.count('setViewport'), 0, 'a later change restarts the wait'); + r.clock.advance(100); + await settle(); + assert.equal(p.count('setViewport'), 1); + assert.deepEqual(p.calls.find(c => c.name === 'setViewport')?.args[0], {width: 656, height: 320, scale: 1}); + assert.equal(p.count('capture'), 1); + r.session.resize({columns: 82, rows: 20}); + r.clock.advance(500); + assert.equal(p.count('setViewport'), 1, 'the same size does nothing'); + r.session.resize({columns: 40, rows: 10}, {width: 16, height: 32}); + r.clock.advance(200); await settle(); + assert.deepEqual(p.calls.filter(c => c.name === 'setViewport').at(-1)?.args[0], {width: 320, height: 160, scale: 2}, 'cell size 16x32 gives scale 2 and CSS pixels = terminal pixels / 2' ); + r.session.dispose(); +}); + +test('session: zoom steps through 25% to 500%', async () => { + const r = rig(); + await r.start(); + assert.deepEqual([...ZOOM_STEPS], [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]); + for (let i = 0; i < 30; i += 1) await r.session.zoomBy(1); + assert.equal(r.page().zoom, 5); + for (let i = 0; i < 30; i += 1) await r.session.zoomBy(-1); + assert.equal(r.page().zoom, 0.25); + r.session.dispose(); +}); + +test('session: a frame flood notifies at most every 33 ms; a slow listener makes the page skip frames', async () => { + const r = rig(); + await r.start(); + let changes = 0; + r.session.onChange(() => { changes += 1; }); + const p = r.page(); + const started = r.clock.time; + for (let ms = 0; ms < 1000; ms += 5) { + await p.push(pngFrame(64, 64, ms)); + r.clock.advance(5); + await settle(1); + } + const elapsed = r.clock.time - started; + assert.ok(changes <= Math.ceil(elapsed / CHANGE_INTERVAL_MS) + 1, `${changes} changes in ${elapsed} ms`); + assert.ok(changes > 5); + assert.ok(p.droppedFrames > 0, 'the engine skipped frames while the listener was busy'); + r.session.dispose(); + const after = changes; + r.clock.advance(1000); + assert.equal(changes, after, 'no notification after dispose'); +}); + +test('session: the newest frame is drawn once the rate limit allows (the last picture is not lost)', async () => { + const r = rig(); + await r.start(); + const p = r.page(); + await p.push(pngFrame(64, 64, 1)); + const uploads = () => r.sent.filter(s => s.includes('a=t')).length; + assert.equal(uploads(), 1); + r.clock.advance(10); + await p.push(pngFrame(64, 64, 2)); + assert.equal(uploads(), 1, 'held back'); + r.clock.advance(200); + await settle(); + assert.equal(uploads(), 2, 'drawn when due'); + r.session.dispose(); +}); + +test('session: a crashed or closed tab shows an error and reloads into a new page', async () => { + const r = rig(); + await r.start(); + const p = r.page(); + p.emit({type: 'crashed'}); + assert.match(r.session.activeView()!.error!, /crashed/u); + assert.equal(p.frameListeners.size, 0); + const rows = r.render().rows.map(stripAnsi).join('\n'); + assert.match(rows, /The page crashed\. Press r to reload\./u); + await r.text('r'); + assert.equal(r.engine.opened.length, 2); + assert.equal(r.page(1).url, 'https://example.com/'); + assert.equal(r.session.activeView()!.error, undefined); + r.page(1).emit({type: 'closed'}); + assert.match(r.session.activeView()!.error!, /closed/u); + r.session.dispose(); +}); + +test('session: no browser available shows the engine message and leaves the tab with an error', async () => { + const r = rig(); + r.engine.failOpen = 'No Chromium browser was found.'; + const result = await r.session.open('https://example.com/'); + assert.deepEqual(result, {ok: false, reason: 'No Chromium browser was found.'}); + assert.equal(r.session.activeView()!.error, 'No Chromium browser was found.'); + r.engine.failOpen = undefined; + await r.text('r'); + assert.equal(r.session.activeView()!.error, undefined); + r.session.dispose(); +}); + +// ---- rendering -------------------------------------------------------------------------------------------------------- + +test('render: layout rows, page area, picture rows and the address from the real URL', async () => { + const r = rig(); + await r.start(); + r.page().emit({type: 'title', title: 'Example Domain'}); + await r.page().push(pngFrame(640, 400, 1)); + const out = r.render(80, 24); + assert.equal(out.rows.length, 24); + assert.equal(out.pageArea.columns, 80); + const text = out.rows.map(stripAnsi); + assert.match(text[1]!, /Example Domain/u); + assert.match(text[2]!, /https:\/\/example\.com\//u); + const page = out.rows.slice(out.pageArea.row, out.pageArea.row + out.pageArea.rows); + assert.equal(page.length, out.pageArea.rows); + assert.ok(page.every(row => row.includes(PLACEHOLDER_CHAR))); + assert.match(text.at(-2)!, /Esc/u); + assert.match(text[out.pageArea.row + out.pageArea.rows]!, /Example Domain/u, 'the status row shows the title'); + r.page().emit({type: 'navigated', url: 'https://evil.example/‮gpj.exe'}); + assert.ok(!r.render().rows.map(stripAnsi).join('').includes('‮')); + r.session.dispose(); +}); + +test('render: typing hint, zoom, find count, http warning and loading marker', async () => { + const r = rig(); + await r.start('http://example.com/'); + const status = () => { const out = r.render(); return stripAnsi(out.rows[out.pageArea.row + out.pageArea.rows]!); }; + assert.match(status(), /not secure/u); + await r.text('+'); + assert.match(status(), /110%/u); + r.clock.advance(6000); + r.page().emit({type: 'editable', editable: true}); + r.panel.focus = 'page'; + assert.match(status(), /typing in the page · Esc for browser commands/u); + assert.match(stripAnsi(r.render().rows.at(-2)!), /Esc browser commands/u); + r.panel.focus = 'chrome'; + await r.text('/'); for (const ch of 'a') await r.text(ch); await r.key({kind: 'enter'}); + assert.match(stripAnsi(r.render().rows.at(-2)!), /Enter find/u); + await r.key({kind: 'escape'}); + assert.match(status(), /1 of 3/u); + r.page().emit({type: 'loading', loading: true}); + assert.match(stripAnsi(r.render().rows[1]!), /…/u); + r.session.dispose(); +}); + +test('render: NO_COLOR draws no color and keeps the active tab and address editor visible', async () => { + const previous = process.env.NMSH_COLOR; + process.env.NMSH_COLOR = '0'; + try { + const r = rig(); + await r.start(); + await r.text('o'); + const out = r.render(); + const joined = out.rows.filter(row => !row.includes(PLACEHOLDER_CHAR)).join('\n'); + assert.ok(!/\u001b\[(?:38|48)[;:]/u.test(joined), 'no color escapes outside the picture'); + assert.ok(out.rows[1]!.includes('\u001b[7m'), 'the active tab is reverse video'); + assert.ok(out.rows[2]!.includes('\u001b[7m'), 'the address editor and caret are reverse video'); + r.session.dispose(); + } finally { if (previous === undefined) delete process.env.NMSH_COLOR; else process.env.NMSH_COLOR = previous; } +}); + +test('render: Safe Glyphs use ASCII for the browser chrome', async () => { + const previous = process.env.NMSH_ICONS; + process.env.NMSH_ICONS = 'safe'; + try { + const r = rig(); + await r.start(); + r.page().emit({type: 'loading', loading: true}); + const out = r.render(); + for (const index of [1, 2]) assert.ok(/^[\x20-\x7e]*$/u.test(stripAnsi(out.rows[index]!)), `row ${index}: ${stripAnsi(out.rows[index]!)}`); + r.session.dispose(); + } finally { if (previous === undefined) delete process.env.NMSH_ICONS; else process.env.NMSH_ICONS = previous; } +}); + +test('render: narrow terminals show a tab count and wrap controls; tiny ones say what they need', async () => { + const r = rig(); + await r.start(); + await r.session.open('https://b.example/'); + const narrow = r.render(40, 24); + assert.equal(narrow.rows.length, 24); + assert.match(stripAnsi(narrow.rows[1]!), /tab 2 of 2/u); + const wide = r.render(120, 24); + assert.match(stripAnsi(wide.rows[1]!), /example\.com/u); + const tiny = r.render(20, 8); + assert.equal(tiny.rows.length, 8); + assert.match(tiny.rows.map(stripAnsi).join('\n'), /Needs at least 32x10/u); + assert.equal(tiny.pageArea.rows, 0); + const short = r.render(80, 9); + assert.match(short.rows.map(stripAnsi).join('\n'), /Needs at least/u); + const small = r.render(32, 10); + assert.equal(small.rows.length, 10); + assert.ok(small.pageArea.rows >= 3); + r.session.dispose(); +}); + +test('render: the page area follows the terminal and the viewport is requested for it', async () => { + const r = rig(); + await r.start(); + const a = r.render(100, 30).pageArea; + r.clock.advance(300); await settle(); + assert.deepEqual(r.page().calls.filter(c => c.name === 'setViewport').at(-1)?.args[0], {width: 100 * 8, height: a.rows * 16, scale: 1}); + r.session.dispose(); +}); diff --git a/tests/browserPreviewMarkdown.test.ts b/tests/browserPreviewMarkdown.test.ts new file mode 100644 index 00000000..f48fb650 --- /dev/null +++ b/tests/browserPreviewMarkdown.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {htmlDocumentFor, markdownToHtml} from '../src/browser/engine/markdownHtml.js'; + +test('markdown becomes a complete, self-contained document with GFM', () => { + const html = markdownToHtml('# Title\n\nSome *text* and `code`.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n~~gone~~ https://example.com\n\n```js\nlet x = 1;\n```\n', {title: 'README', colorScheme: 'light'}); + assert.match(html, /^/u); + assert.match(html, //u); + assert.match(html, /

    Title<\/h1>/u); + assert.match(html, /text<\/em>/u); + assert.match(html, //u); + assert.match(html, /]*type="checkbox"/u); + assert.match(html, /gone<\/del>/u); + assert.match(html, //u); + assert.match(html, /
    /u);
    +  assert.match(html, /
    +    

    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'); + // 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)', () => { + 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); +}); diff --git a/tests/viewImages.test.ts b/tests/viewImages.test.ts new file mode 100644 index 00000000..53242fb2 --- /dev/null +++ b/tests/viewImages.test.ts @@ -0,0 +1,163 @@ +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<typeof app['handleKey']>[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)', '', + '<p><img src="img/logo.png" alt="Same logo"></p>', '', 'The end.', + ].join('\n')); + f.app['shellCwd'] = join(f.dir, 'docs'); + await f.view('README.md'); + await settle(() => Boolean(f.app['docSession'])); + f.key({kind: 'text', value: 'h'}); + await settle(() => Boolean(f.app['viewerImages']) && f.app['viewerImages'].version > 1); + await new Promise(done => setTimeout(done, 100)); + 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['docSession'], 'back in the document, as it was'); + assert.equal(f.app['docSession'].mode, 'preview'); + assert.match(f.screen(), /Project/u); + f.key({kind: 'escape'}); + f.key({kind: 'escape'}); + assert.equal(f.app['docSession'], 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['docSession'])); + f.key({kind: 'text', value: 'h'}); + 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(); } +}); diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts new file mode 100644 index 00000000..24540ab0 --- /dev/null +++ b/tests/viewer.test.ts @@ -0,0 +1,237 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {loadFileDocument, outputDocument, resolveViewPath, type ViewDocument} from '../src/viewer/document.js'; +import {createViewer, renderViewer, viewerKey, type ViewerState} from '../src/viewer/ViewerPanel.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const scratch = () => realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-'))); +const text = (value: string): Key => ({kind: 'text', value}); +const press = (state: ViewerState, keys: Array<Key | string>, columns = 80, height = 24) => { + let action; + for (const key of keys) action = viewerKey(state, typeof key === 'string' ? text(key) : key, columns, height); + return action; +}; +const draw = (state: ViewerState, columns = 80, height = 24) => renderViewer(state, columns, height); +const shown = (rows: string[]) => rows.map(row => stripAnsi(row)); +async function load(path: string, options: Parameters<typeof loadFileDocument>[2] = {}): Promise<ViewDocument> { + const result = await loadFileDocument(path, '/', options); + if (!result.ok) throw new Error(result.reason); + return result.document; +} + +test('/view parses a path, an output number or nothing', () => { + assert.deepEqual(parseSlashCommand('/view README.md'), {kind: 'view', target: 'README.md'}); + assert.deepEqual(parseSlashCommand('/view 2'), {kind: 'view', target: '2'}); + assert.deepEqual(parseSlashCommand('/view'), {kind: 'view', target: ''}); + assert.notEqual(parseSlashCommand('/viewer')?.kind, 'view'); + assert.equal(resolveViewPath('~/notes.md', '/work', '/home/me'), '/home/me/notes.md'); + assert.equal(resolveViewPath('docs/a.md', '/work', '/home/me'), '/work/docs/a.md'); +}); + +test('files: only regular, decodable text is read, within limits, and every refusal says why', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'a.ts'), 'const a = 1;\r\nconst b = 2;\r\n'); + const code = await load(join(root, 'a.ts')); + assert.deepEqual(code.lines, ['const a = 1;', 'const b = 2;']); + assert.equal(code.lineEnding, 'crlf'); + assert.equal(code.format, 'text'); + assert.equal(code.language, 'js'); + assert.equal(code.complete, true); + + mkdirSync(join(root, 'dir')); + assert.match((await loadFileDocument(join(root, 'dir'), '/') as {reason: string}).reason, /is a directory/u); + assert.match((await loadFileDocument(join(root, 'missing.txt'), '/') as {reason: string}).reason, /does not exist/u); + writeFileSync(join(root, 'bin'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, 2])); + assert.match((await loadFileDocument(join(root, 'bin'), '/') as {reason: string}).reason, /binary/u); + writeFileSync(join(root, 'utf16.txt'), Buffer.from([0xff, 0xfe, 0x61, 0x00])); + assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16.*\/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. + 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); + 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')); + assert.ok(linked.origin.kind === 'file' && linked.origin.symlink && linked.origin.realPath === join(root, 'a.ts')); + assert.match(shown(draw(createViewer(linked), 400)).join('\n'), new RegExp(`→ ${join(root, 'a.ts').replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&')}`, 'u')); + + // A file name is untrusted text too: its controls never reach the transcript or the header as controls. + const hostileName = join(root, 'evil\u001b[2J\u202e.txt'); + const missing = await loadFileDocument(hostileName, '/') as {reason: string}; + assert.doesNotMatch(missing.reason, /[\u001b\u202e]/u); + assert.match(missing.reason, /evil␛\[2J·\.txt does not exist/u); + writeFileSync(hostileName, 'x\n'); + assert.doesNotMatch(draw(createViewer(await load(hostileName))).join('').replace(/\u001b\[[0-9;]*m/gu, ''), /[\u001b\u202e]/u); + + // Reading changes nothing. + assert.equal(readFileSync(join(root, 'a.ts'), 'utf8'), 'const a = 1;\r\nconst b = 2;\r\n'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('hostile text is drawn inert at every width: no terminal controls, no bidi tricks, nothing wider than the panel', () => { + const hostile = ['\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007 \u001b[2J\u001b[31mred', 'admin‮gnp.exe', 'zero​width\ttab', '中文字符 and emoji 🎉', 'x'.repeat(500)].join('\n'); + const document = outputDocument({command: 'cat hostile', output: hostile}, 1); + for (const columns of [32, 80, 120]) { + const rows = draw(createViewer(document), columns, 24); + for (const row of rows) { + const visible = stripAnsi(row); + assert.doesNotMatch(row.replace(/\u001b\[[0-9;]*m/gu, ''), /\u001b/u, 'only NMSh SGR styling remains'); + assert.doesNotMatch(visible, /[‮​]/u, 'format characters are shown as marks'); + assert.ok(displayWidth(visible) <= columns, `row fits ${columns} columns`); + } + const joined = shown(rows).join('\n'); + assert.match(joined, /␛\]8;;https:\/\/evil/u, 'escape sequences are visible text, never links or colors'); + assert.match(joined, /admin·gnp\.exe/u); + } + assert.equal(document.format, 'text', 'command output is never implicitly Markdown'); +}); + +test('Markdown files render, switch to source with m, and output whose command is a diff reads as a diff', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'README.md'), '# Title\n\nSome **bold** text and `code`.\n\n- one\n- two\n'); + const document = await load(join(root, 'README.md')); + const viewer = createViewer(document); + assert.equal(viewer.rendered, true); + const rendered = shown(draw(viewer)).join('\n'); + assert.match(rendered, /Title/u); + assert.doesNotMatch(rendered, /\*\*bold\*\*/u, 'rendered, not source'); + press(viewer, ['m']); + assert.match(shown(draw(viewer)).join('\n'), /\*\*bold\*\*/u, 'source on m'); + const diff = outputDocument({command: 'git diff', output: 'diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n'}, 1); + assert.equal(diff.format, 'diff'); + assert.equal(outputDocument({command: 'cat notes.md', output: '# not markdown here\n'}, 1).format, 'text'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('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); + 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..3abf2459 --- /dev/null +++ b/tests/viewerApp.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +/** A completed command block, as the shell would have produced it. */ +function completed(app: TerminalApp, command: string, output: string, exitCode = 0): number { + const buffer = app['output']; + const startId = buffer.beginCommand(command, [`$ ${command}`]); + buffer.write(output); + buffer.complete(exitCode); + return startId; +} + +async function until(check: () => boolean, what: string): Promise<void> { + const deadline = Date.now() + 10_000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +const transcriptText = (app: TerminalApp) => (app['output'].transcript().lines as Array<Array<{text?: string} | null>>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + +test('/view and View output open the native viewer on stored output; it owns keys until Esc and never writes to the transcript', async () => { + const isolation = isolateConfig(); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + completed(app, 'printf a', 'first\r\n'); + const startId = completed(app, 'printf b', 'second\tline\r\nthird\r\n', 2); + const before = JSON.stringify(app['output'].transcript()); + app['openViewer']('', '/view'); + const viewer = app['viewer']!; + assert.equal(viewer.document.title, 'printf b'); + assert.deepEqual(viewer.document.lines, ['second\tline', 'third'], 'the text /copy copies, tabs as written'); + assert.ok(viewer.document.origin.kind === 'output' && viewer.document.origin.exitCode === 2 && viewer.document.origin.number === 1); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'text', value: 'x'}); + assert.equal(app['editor'].text, '', 'keys belong to the viewer, not the composer'); + app['handleKey']({kind: 'escape'}); + assert.equal(app['viewer'], undefined); + app['openViewer']('2', '/view 2'); + assert.equal(app['viewer']?.document.title, 'printf a'); + app['handleKey']({kind: 'escape'}); + await app['runBlockAction'](startId, 'view'); + assert.equal(app['viewer']?.document.title, 'printf b'); + app['handleKey']({kind: 'escape'}); + assert.equal(JSON.stringify(app['output'].transcript()), before, 'viewing writes nothing to the transcript'); + app['openViewer']('9', '/view 9'); + assert.equal(app['viewer'], undefined); + assert.match(transcriptText(app), /No completed command output at 9\./u); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } +}); + +test('/view <file> reads relative to the shell directory; a missing file is reported in the transcript', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-viewapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'notes.md'), '# Notes\n\nhello\n'); + app['openViewer']('notes.md', '/view notes.md'); + await until(() => app['docSession'] !== undefined, 'the document workspace'); + assert.equal(app['docSession']!.viewer.document.format, 'markdown'); + assert.equal(app['docSession']!.mode, 'source', 'Markdown opens source-first; h previews it'); + assert.equal(app['docSession']!.viewer.rendered, false); + app['handleKey']({kind: 'text', value: 'h'}); + assert.equal(app['docSession']!.viewer.rendered, true, 'the native preview when no browser is in use'); + app['handleKey']({kind: 'escape'}); + app['handleKey']({kind: 'escape'}); + assert.equal(app['docSession'], undefined); + app['openViewer']('missing.txt', '/view missing.txt'); + await until(() => /does not exist/u.test(transcriptText(app)), 'the missing-file notice'); + assert.equal(app['viewer'], undefined); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); + +test('/edit opens a small file, Ctrl+S saves it, Esc closes; e in /view edits the file shown; refusals are reported', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'todo.txt'), 'buy milk\n'); + app['openTextEditor']('todo.txt', '/edit todo.txt'); + await until(() => app['textEditor'] !== undefined, 'the editor'); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'bufferEnd'}); + app['handleKey']({kind: 'text', value: ' and eggs'}); + assert.equal(app['editor'].text, '', 'keys belong to the editor, not the composer'); + app['handleKey']({kind: 'sendToProgram'}); + await until(() => readFileSync(join(root, 'todo.txt'), 'utf8') === 'buy milk and eggs\n', 'the save'); + await until(() => app['textEditor']?.busy === false, 'the save to settle'); + assert.equal(app['textEditor']!.buffer.dirty, false); + app['handleKey']({kind: 'escape'}); + assert.equal(app['textEditor'], undefined); + + app['openViewer']('todo.txt', '/view todo.txt'); + await until(() => app['viewer'] !== undefined, 'the viewer'); + app['handleKey']({kind: 'text', value: 'e'}); + await until(() => app['textEditor'] !== undefined, 'the editor from the viewer'); + assert.equal(app['viewer'], undefined); + app['handleKey']({kind: 'escape'}); + + writeFileSync(join(root, 'blob'), Buffer.from([0, 1, 2])); + app['openTextEditor']('blob', '/edit blob'); + await until(() => /looks binary/u.test(transcriptText(app)), 'the refusal'); + assert.equal(app['textEditor'], undefined); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/viewerLive.test.ts b/tests/viewerLive.test.ts new file mode 100644 index 00000000..20004d2f --- /dev/null +++ b/tests/viewerLive.test.ts @@ -0,0 +1,83 @@ +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'); + // Markdown opens as source in the document workspace; h shows the rendered preview, h again the source. + await frontend.waitFor(/notes\.md[\s\S]*Markdown · 6 lines[\s\S]*# Field notes[\s\S]*\*\*quick\*\* fox[\s\S]*h preview[\s\S]*Esc close/u, mark); + mark = frontend.mark; + frontend.pty.write('h'); + await frontend.waitFor(/Field notes[\s\S]*quick fox[\s\S]*h source/u, mark); + mark = frontend.mark; + frontend.pty.write('h'); + await frontend.waitFor(/\*\*quick\*\*/u, mark); + // Esc closes; the composer is drawn again before anything is typed (a lone Esc followed at once by text is ambiguous). + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + + await frontend.run('seq 1 400', /399[\s\S]*400/u); + mark = frontend.mark; + frontend.pty.write('/view\r'); + await frontend.waitFor(/\$ seq 1 400[\s\S]*output 1 · exit 0 · 400 lines[\s\S]*Lines 1–/u, mark); + mark = frontend.mark; + frontend.pty.write('/37\r'); + await frontend.waitFor(/match(?:es)? for “37”/u, mark); + mark = frontend.mark; + frontend.pty.write(':390\r'); + await frontend.waitFor(/Lines 3\d\d–400 of 400/u, mark); + frontend.pty.write('c'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').length > 0, 15_000, 'the document on the clipboard'); + assert.equal(readFileSync(clip, 'utf8'), Array.from({length: 400}, (_, index) => String(index + 1)).join('\n') + '\n'); + // The first Esc clears the search, the second closes. + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/of 400 +↑↓ scroll/u, mark); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + // Back at the composer: a command runs as usual. + await frontend.run('echo AFTER-VIEW', /AFTER-VIEW/u); + } finally { await sandbox.dispose(); } +}); + +test('live: /edit creates a file, typed text and Ctrl+S from real terminal bytes save it, Esc returns to the composer', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + frontend.pty.write('/edit notes.txt\r'); + await frontend.waitFor(/notes\.txt[\s\S]*new file[\s\S]*Ctrl\+S save/u, mark); + frontend.pty.write('first line\rsecond\tline'); + mark = frontend.mark; + frontend.pty.write('\u0013'); + await frontend.waitFor(/Saved/u, mark); + assert.equal(readFileSync(join(sandbox.home, 'notes.txt'), 'utf8'), 'first line\nsecond\tline\n'); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + await frontend.run('cat notes.txt', /second\s+line/u); + } finally { await sandbox.dispose(); } +}); 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(); } +});