diff --git a/docs/architecture/context-modules.md b/docs/architecture/context-modules.md index 45ff167a..c4431988 100644 --- a/docs/architecture/context-modules.md +++ b/docs/architecture/context-modules.md @@ -8,8 +8,8 @@ without taking over shell execution or running anything the workspace chose. > **Release status.** v0.17.0 shipped the foundation: typed fact metadata, > native module routing and the Context Rail. Everything else described here — > capability scheduling, the first-party module catalog, declarative Context -> Packs, Status Strip routing and Status Strip 2.0, `/modules` and Claude Code agent context — shipped in -> v0.18.0. +> Packs, Status Strip routing and Status Strip 2.0, `/modules` and Claude Code agent context — is in +> the v0.18 development stack, pending acceptance and release. ## From capability to presentation @@ -169,9 +169,28 @@ fullscreen programs hide it, and it is never written to the transcript. - **Packs** — bundled and installed Context Packs with their verified state; installed packs can be enabled, disabled or removed (with confirmation). +Short detail panels keep the module title visible and scroll through existing rows +with Up/Down or PageUp/PageDown. Resize clamps that temporary view; scrolling +does not edit settings or demand data. + A module's details show what it reads, whether its value may be kept with commands in history, and each fact's current state ("fresh · 3s ago", "last -known, refreshing", "unavailable (timed out after 1500 ms)", "not checked"). +known, refresh waits for demand", "unavailable (timeout)", "not checked"). Trusted source IDs, +scope, freshness TTL, event invalidation and demand-driven refresh policy come +from the capability definition. Failure text excludes raw exception paths and +values. A failed refresh can retain a previous value; the view distinguishes +that from a failure with no cached value, without claiming that new data arrived. + +"Took" measures the last collection's wall time, starting when the scheduler +dispatches it; it excludes time waiting in the queue. An unmeasured collection +has no invented zero. Opening details reads existing snapshots and declarations, +and does not demand collectors. `/doctor` summarizes bounded engine counters. +Routing, provider availability, conditions and priority explain presentation +policy. Enabled means the saved setting, not proof that the module is on screen. +The live condition check is shared with Native rendering; it does not infer +visibility from collection success. Width fitting can compact or omit lower +priority modules; the detail view does not claim to record the last frame's +exact compaction decisions. `/shell` edits the current-shell module's visibility and side. ## Context Packs diff --git a/docs/design/claude-remote-control-compatibility.md b/docs/design/claude-remote-control-compatibility.md new file mode 100644 index 00000000..83a90ebe --- /dev/null +++ b/docs/design/claude-remote-control-compatibility.md @@ -0,0 +1,62 @@ +# Claude Remote Control: compatibility findings + +Issue [#340](https://github.com/raiseCatError/notMyShell/issues/340) (research). Primary source: Anthropic's Claude Code +documentation page *Continue local sessions from any device with Remote Control* +(`https://code.claude.com/docs/en/remote-control`), read on 2026-10-10. Findings are what the page states or does not +state; **nothing here was probed against a live account**, which needs an eligible claude.ai login and a human to +approve it. Re-verify before any implementation: the feature is new and its flags change. + +## What the documentation says + +| Topic | Documented fact | +| --- | --- | +| Where it runs | The session keeps running on the person's machine; the remote surface (claude.ai/code, mobile apps) drives it | +| Invocation modes | `claude remote-control` (server mode, creates sessions on demand), `claude --remote-control` / `--rc` (a normal interactive session that is also reachable remotely), `/remote-control` or `/rc` inside an existing session; the Desktop app and VS Code extension use `/remote-control` | +| Eligibility | Pro, Max, Team and Enterprise; **API keys are not supported**; Team and Enterprise need an Owner to enable the toggle in admin settings; a claude.ai login is required | +| One session per process | Each interactive process registers one remote session; several instances give several sessions. Server mode runs several sessions in one process (`--capacity`, default 32; `--spawn same-dir` or `worktree`) | +| Flags | Global `claude` flags placed before `remote-control` (or added by a wrapper) are **not** carried into the sessions the server creates; `--permission-mode` and sandboxing are set explicitly | +| Trust | In an untrusted directory `claude remote-control` asks `Trust ? [y/N]` before starting | +| Terminal indicator | An interactive session shows an `/rc active` indicator (hidden in terminals that are too narrow) and a failure reason when the connection drops | +| Security model | Outbound HTTPS only, no inbound ports; traffic through the Anthropic API over TLS; optional Trusted Devices enrolment, enforced per organization | +| Resume | `claude remote-control --continue` / `--session-id` for about four hours after the server stops; interactive sessions resume with `claude --continue` / `--resume` | +| Not documented | Any support for `--print` / stream-json (headless or SDK-style) sessions, for a third-party UI driving the session, or for exposing the remote channel to such a UI | + +## What this means for NMSh + +NMSh has two relationships with Claude Code: + +1. **Raw (native TUI)**: Claude runs in NMSh's shell as any program. `claude --remote-control` and `/remote-control` are + Claude's own features, work exactly as documented, and need nothing from NMSh. NMSh only has to keep passthrough + correct (it does). +2. **Managed**: NMSh drives Claude through `--print --input-format stream-json --output-format stream-json` (see + `src/agents/sessions/claudeAdapter.ts`). **The documentation does not describe Remote Control for that mode.** The + modes it does describe are interactive sessions and its own server. + +| Question | Finding | +| --- | --- | +| Remote Control for a Raw session | **Supported by Claude itself**; NMSh must not interfere or claim it | +| Remote Control for a Managed (stream-json) session | **Unknown / undocumented.** Do not advertise it | +| NMSh starting `claude remote-control` as a managed target | **Unsupported**: server mode creates its own sessions with its own permission handling; NMSh could not own the approval path or the transcript | +| Flag inheritance for per-profile launch policy | **Documented limitation**: flags are not inherited by server-created sessions, so a per-profile `--permission-mode` would have to be passed to the server itself | +| Account/device boundaries | Remote Control needs the person's claude.ai login (no API keys) and may be gated by an organization toggle and Trusted Devices; NMSh must never read, store or forward those credentials, and must show the organization's refusal as Claude reports it | +| Privacy | A remote session sends the conversation through Anthropic's API to the person's other devices; that is a data-sharing choice the person makes inside Claude, not an NMSh feature | + +## Go / no-go + +**No-go for a Never / Ask / Always Remote Control setting in NMSh at this time.** The only supported combination (Raw + +Claude's own commands) needs no NMSh feature, and the Managed combination is undocumented. A setting would imply support +NMSh cannot demonstrate and would blur who owns approvals. + +Revisit when: (a) Anthropic documents Remote Control for headless or SDK-driven sessions, or (b) a human-run probe +shows a supported way to attach the remote channel to a stream-json session. + +## Probe plan (not run; needs a person with an eligible login) + +1. With an eligible account on a test machine, run Claude in Raw mode under NMSh, enable `/remote-control`, and confirm + the `/rc active` indicator survives NMSh's passthrough, resize and detach/reattach. Record the Claude Code version. +2. Start a Managed target and check whether any documented flag attaches Remote Control to a stream-json session. Record + the exact help text and error output; treat silence as "unsupported". +3. Confirm that environment variables and arguments NMSh sets for a managed target do not change the login Claude uses. +4. Record each result with the Claude Code version and date in this file. + +Any implementation is a separate reviewed scope with physical validation, as the issue requires. diff --git a/docs/design/external-tool-decisions.md b/docs/design/external-tool-decisions.md new file mode 100644 index 00000000..a0388863 --- /dev/null +++ b/docs/design/external-tool-decisions.md @@ -0,0 +1,58 @@ +# External tool integrations: decisions + +Issue [#366](https://github.com/raiseCatError/notMyShell/issues/366), under [#359](https://github.com/raiseCatError/notMyShell/issues/359). +Every candidate was checked against what NMSh already does (the curated `/tools` catalog in `src/tools/catalog.ts`, the +provider registry behind `/providers`, `/dirs`, `/open`, Theme Bridge, the clipboard writer and reader, the same-shell +command queue, native Compare). A decision is one of **adopt** (already supported or built), **defer** (a plausible +slice, not now) or **reject**. + +## Principles that decide every row + +- Optional and absent-safe: NMSh works fully without any of these. Nothing is installed, configured or enabled without + a deliberate action and a preview. +- The native same-shell **queue** stays the way to prepare commands for the person's own shell. It keeps the shell's + state (directory, variables, functions) and its input ownership. A detached job runner is a different tool for a + different job, never a replacement. +- Native **Compare** stays the comparison of two stored command outputs. An external diff renderer adds colour, not + meaning. +- No tool receives clipboard or output contents unless the person invoked that tool on that content. +- No second picker, file manager or form framework inside NMSh. + +## Decisions + +| Tool | Already in NMSh | Decision | Why | +| --- | --- | --- | --- | +| **[Atuin](https://github.com/atuinsh/atuin/blob/main/docs/docs/configuration/config.md)** | Curated in `/tools`; usable as the explicit history provider (`/providers`); NMSh reads local commands and metadata for suggestions | **Adopt as is** | NMSh keeps shell history authority and uses Atuin as an optional source. Project/session-aware ranking is already NMSh's own (directory affinity, frecency); do not add a second history store | +| **[fzf](https://github.com/junegunn/fzf#usage)** | Curated in `/tools`; selection provider; shell keybindings remain external configuration | **Adopt as is** | The existing picker provider covers selection. No new picker subsystem | +| **[Television](https://github.com/alexpasmantier/television#readme)** | Curated in `/tools`; picker provider choice | **Adopt as is** | Same provider slot as fzf. Preview/select actions for output references (#353) already use NMSh's own palette, which is enough | +| **[zoxide](https://github.com/ajeetdsouza/zoxide#readme)** | Curated in `/tools`; directory provider behind `/dirs`, queried through a private database copy | **Adopt as is** | zoxide can write while querying. NMSh protects the original database and falls back to Native when it cannot obtain a bounded snapshot | +| **[bat](https://github.com/sharkdp/bat#adding-new-themes)** | Curated in `/tools`; Ask knows it; Theme Bridge offers an owned theme and explicitly reviewed cache build | **Adopt as is** | No dedicated source-preview launcher is implemented. Users can invoke bat in their shell; Compare and reports use native plain text | +| **[delta](https://github.com/dandavison/delta#readme)** | Curated in `/tools`; Theme Bridge offers optional Git diff colours and bat-backed syntax theme support | **Adopt as is; do not use for Compare** | Native Compare uses a bounded edit distance, reviewed reports and a sanitized plain diff copy. Sending output to an external renderer creates another presentation/copy boundary without addressing the accepted workflow needs | +| **[Pueue](https://github.com/Nukesor/pueue#readme)** | No Pueue adapter | **Defer** | Its separate daemon provides durable jobs, but it owns independent execution and persisted output. A reviewed handoff would need output capture and transcript provenance. The same-shell queue stays native. Workspace background-task work (#383/#384) belongs to the other track; adding a second adapter before that handoff is premature | +| **[Yazi](https://yazi-rs.github.io/docs/tips/)** | No chooser adapter | **Defer** | Returning paths through `--chooser-file` is plausible, but needs a bounded private result file, validated shell quoting, passthrough handoff and explicit insertion. No accepted implementation slice supersedes the existing deferral | +| **[Gum](https://github.com/charmbracelet/gum#readme)** | No Gum-specific adapter | **Reject** | Gum provides script prompts. NMSh retains ordinary passthrough and input ownership rather than adding a forms framework. Gum-specific physical interoperability has not been validated | + +## What stays native, and why + +- **Command queue** (same shell, once-only, pauses on failure or interrupt, optional approval): needed because the next + command depends on the state the previous one left in the person's shell. +- **Compare** and **Copy as Report**: exact, reviewed, redactable text; the properties that make them safe to share would + be lost behind an external renderer. +- **Output references and failure navigation**: use the existing editor/browser bridges instead of a picker tool. + +## Follow-ups worth a ticket each + +1. Yazi chooser handoff (isolated, high value). +2. Pueue *send to background* with status (needs the output-capture design first). + +## Implementation evidence and limits + +Re-audited 2026-10-11. Existing optional-provider behavior is implemented in +[`historyProviders.ts`](../../src/shell/historyProviders.ts), +[`HistoryService.ts`](../../src/shell/HistoryService.ts), +[`Picker.ts`](../../src/pickers/Picker.ts), +[`DirectoryService.ts`](../../src/shell/DirectoryService.ts) and +[`Theme Bridge runtime`](../../src/themeBridge/runtime.ts). +Provider and bridge tests cover bounded reads, absent dependencies, native fallbacks and owned configuration; +live queue, alternate-screen and report/diff acceptance covers the retained native paths. No new CLI adapter is +claimed by this audit. Host-specific picker visuals and physical interoperability remain human QA. diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md new file mode 100644 index 00000000..484bf832 --- /dev/null +++ b/docs/design/native-module-coverage.md @@ -0,0 +1,57 @@ +# Native module coverage: Starship and Oh My Posh + +Issue [#364](https://github.com/raiseCatError/notMyShell/issues/364), under [#305](https://github.com/raiseCatError/notMyShell/issues/305). +The goal is useful coverage, not a module count: NMSh had **10 built-in modules and 25 bundled Context Pack modules** (27 now that Ruby and PHP are added) +(`nmsh.agents`, `cloud`, `environment`, `infrastructure`, `project`, `system`, `vcs`). Anything below that is missing is +either already covered by a generic mechanism, available through the external prompt providers NMSh already supports +(Starship, Oh My Posh, Powerlevel10k), deliberately skipped, or a candidate. + +Catalogs read on 2026-10-10: Starship's configuration reference (`starship.rs/config`) and Oh My Posh's +`website/docs/segments` tree (agents, cli, cloud, health, languages, music, scm, system, web). Nothing here claims a +module behaves identically to its upstream namesake. + +## Rules a native module must meet + +- Facts come from declarative files or the executable's install location, through the Context Engine's capabilities + (scoped cache, timeout, cancellation, provenance, privacy). **No module runs repository-controlled code** or a tool + from the workspace to learn a version; this is why several Starship modules that shell out are not ported as is. +- Demand-driven: collected only while a surface that shows it is visible. +- One module per question a person asks about a directory, not one per file extension. + +## Matrix + +| Upstream area | Examples (Starship / Oh My Posh) | NMSh today | Decision | +| --- | --- | --- | --- | +| Directory, shell, OS, user, time, status, duration, jobs, battery, memory | `directory`, `os`, `username`, `time`, `status`, `cmd_duration`, `jobs`, `battery`, `memory_usage` / `path`, `os`, `session`, `time`, `executiontime`, `battery`, `sysinfo` | Native: built-ins `cwd`, `shell`, `exitStatus`; pack `nmsh.system` (`os`, `user`, `jobs`, `duration`, `time`, `battery`, `memory`) | Covered | +| Git | `git_branch`, `git_status`, `git_commit`, `git_state`, `git_metrics` / `git` | Native: `gitBranch`, `gitStatus`; pack `nmsh.vcs` (`stash`, `upstream`) | Covered; commit/state/metrics are candidates only if requested | +| Other VCS | `hg_branch`, `fossil_*`, `pijul_channel`, `vcsh` / `mercurial`, `fossil`, `jujutsu`, `sapling`, `svn`, `plastic`, `dvc` | None | **Candidate: Jujutsu and Mercurial** (declarative `.jj` / `.hg` metadata); the rest defer to a provider | +| Core runtimes | `nodejs`, `python`, `golang`, `rust`, `java`, `package` / `node`, `python`, `golang`, `rust`, `java`, `project` | Native: pack `nmsh.project` (`package`, `node`, `python`, `go`, `rust`, `java`) with requested vs. active versions | Covered | +| More language runtimes | `ruby`, `php`, `dotnet`, `swift`, `kotlin`, `zig`, `elixir`, `lua`, `haskell`, `dart`, `deno`, `bun`, `scala`, `perl`, `ocaml`, `nim`, `julia`, `r`, `crystal` | Only generic: built-in `toolchain` and `discoveredTools` show that a project uses a tool, not its version | **Done: Ruby, PHP (this branch). Next: .NET, Swift, Deno/Bun, Zig, Elixir, Dart/Flutter.** Same pattern as `runtimes.ts`: requested version from the project's own file, active version from the install path or the runtime's metadata, never by running it | +| Build systems | `cmake`, `meson`, `gradle`, `maven`, `buf`, `xmake`, `bazel` / `cmake`, `gradle`, `mvn`, `bazel`, `nx` | Gradle/Maven appear as the `java` module's build tool; others only as discovered tools | Covered for Java; **candidate: a single `build` module** (CMake/Meson/Bazel/Make detection) rather than one module each | +| Environment managers | `conda`, `direnv`, `mise`, `nix_shell`, `pixi`, `guix_shell`, `spack`, `singularity`, `container` / `nix-shell` | Native: `direnv` and `tools` (pack `nmsh.environment`); Python environments including conda/venv inside `python` | Covered for direnv/conda/venv; **candidates: mise, Nix shell, devcontainer/container** | +| Cloud and infrastructure | `aws`, `gcloud`, `azure`, `kubernetes`, `terraform`, `helm`, `pulumi`, `docker_context`, `openstack`, `nats`, `opa` / `aws`, `az`, `gcp`, `kubectl`, `terraform`, `helm`, `pulumi`, `docker`, `argocd`, `cf`, `firebase` | Native: built-ins `kubeContext`, `dockerContext`; packs `nmsh.cloud` (`aws`, `gcp`, `azure`), `nmsh.infrastructure` (`terraform`, `helm`, `pulumi`) | Covered for the common set; ArgoCD, Firebase, Cloud Foundry, OpenStack, NATS, OPA: provider | +| Frameworks and app tooling | `angular`, `react`, `svelte`, `tauri`, `flutter`, `unity`, `umbraco` (Oh My Posh) | None | **Do not duplicate.** These are project-type labels derived from the same files the runtimes read; they add chrome, not information | +| Package managers | `npm`, `pnpm`, `yarn`, `bun` (OMP) | `package` module reports the package and its manager | Covered | +| Agents | `claude`, `copilot`, `antigravity` (OMP) | Native: pack `nmsh.agents` (`claude`, `claude-limits`) through Claude Code's status-line interface | Covered for Claude; other agents only when they publish a comparable official interface | +| Prompt structure | `character`, `line_break`, `fill`, `shell`, `shlvl`, `sudo`, `text`, `vimode` | NMSh owns prompt layout and its own composer | Not applicable | +| Network and web | `localip`, `ipify`, `http`, `owm`, `todoist`, `wakatime`, `lastfm`, `spotify`, `strava` | None | **Unsafe or impractical by design**: they need network calls or credentials in a prompt. Available through an external provider if a person wants them | +| Health, music, misc. | `nightscout`, `ramadan`, `withings`, `nba`, `taskwarrior` | None | Out of scope | +| Custom commands | `custom` / `command`-style segments | None, on purpose | **Not supported natively**: it runs arbitrary commands. A person who needs it uses a provider (they own that trust decision) or a data-only Context Pack | + +## Recommended order of work + +1. **~~Ruby, PHP~~ (done), .NET, Swift**: highest daily-use gap, all have declarative version files (`.ruby-version`/`Gemfile`, + `composer.json`, `global.json`/`*.csproj`, `Package.swift`/`.swift-version`). +2. **Deno/Bun, Zig, Elixir, Dart**: same pattern. +3. **`build` and `env` summary modules** (CMake/Meson/Bazel; mise/Nix/devcontainer) instead of one module per tool. +4. **Jujutsu / Mercurial** state in `nmsh.vcs`. + +Each addition ships with: the capability, its pack module, provenance and privacy metadata, tests against fixture +directories (including hostile and symlinked files), and the explain/timings view from +[#365](https://github.com/raiseCatError/notMyShell/issues/365). + +## Compatibility + +Starship, Oh My Posh and Powerlevel10k keep working as external prompt providers; nothing here replaces them. Importing +their *visual* configuration into NMSh themes is a separate problem owned by +[#304](https://github.com/raiseCatError/notMyShell/issues/304); translating their segment *logic* is not attempted. diff --git a/docs/development/workflow-initiative-ledger.md b/docs/development/workflow-initiative-ledger.md index 43dc41a6..456cfb76 100644 --- a/docs/development/workflow-initiative-ledger.md +++ b/docs/development/workflow-initiative-ledger.md @@ -1,10 +1,148 @@ -# Terminal workflow initiative: ledger +# Workflow initiative ledger -The durable record for the "make the result of one command useful for the next" initiative (queue, copy, -paste, reports, comparison, references, error navigation, pins/recipes, integrations). A fresh session resumes -from this file and Git, not from a conversation. Update it with every slice. +Canonical tracker: [#359](https://github.com/raiseCatError/notMyShell/issues/359). Updated 2026-10-12. -Design and research: [docs/design/terminal-workflow.md](../design/terminal-workflow.md). +## Current stabilization checkpoint + +Fresh-session recovery: [workflow stabilization handoff](workflow-stabilization-handoff.md). +Development is stopped at a preservation checkpoint. Cedar/Birch host canonical passed; +Amber full verification failed three compatibility cases. Elm commit 5447eb9 preserves +incomplete acceptance tests (15 focused passed, 4 failed). Input 48a0ded Actions +38157687343 failed macOS flowComposer PTY spawning; Ubuntu shards passed. +No frontend freeze or final integration is claimed. Exact heads/commands/failures are +in the handoff above. + +The shared frontend is **not frozen**. This record supersedes the historical status claims below. No workflow PR +has been merged to master or the release branch. Physical terminal QA remains outstanding. + +| PR | Published head | Local additive candidate | Gate | +| --- | --- | --- | --- | +| #345 | 7122468 | unchanged | stack base | +| #348 | 48a0ded | fix/workflow-cedar / 48a0ded | host canonical passed; current Actions failed macOS PTY spawn | +| #357 | 7fb6b5a | fix/workflow-birch / 3adc451 | host canonical passed; final CI pending | +| #358 | 790fd74 | fix/workflow-amber / 000736d | host canonical failed three compatibility cases; final CI pending | +| #360 | afe7f69 | fix/workflow-coral / 80bd0cf | final canonical/CI pending | +| #361 | b1cdfbe | fix/workflow-indigo / 0117a34 | final canonical/CI pending | +| #367 | 66e164d | fix/workflow-willow / be037a0 | independent sibling; final canonical/CI pending | +| #368 | 5a795ed | fix/workflow-maple / this checkpoint | final canonical/CI pending | + +All candidates preserve their published ancestors through additive merges. Owned +worktrees use neutral names: nmsh-cedar-input, nmsh-birch-queue, nmsh-amber-copy, +nmsh-coral-report, nmsh-indigo-compare, nmsh-willow-vocabulary and nmsh-maple-workflow. +They are on external scratch storage. Existing worktrees and modifications were preserved. + +Temporary resources disappeared during continuations and interrupted two full +runs. Those runs are not passing evidence. Committed work survived and was restored. +Verification logs and the Linux VM now use durable external storage. +The test runner supports NMSH_TEST_TMPDIR; fixture and key-diagnostic writers +respect the selected temporary directory. Invalid storage fails instead of falling +back. Source typecheck, focused lifecycle/privacy/platform tests and verify:fast +passed for storage correction 9465eb5. The runner also isolates child HOME in the chosen root. + +## Confirmed findings and work + +- **Fish 3.7 reader ownership:** the native read editor deliberately does not enable bracketed paste. Waiting answers + remained in the composer/queue. The authenticated `fish_read` marker fixes handover before the prompt, including + hidden readers inside functions and detached readers. `answersByDefault` was not weakened. Details and exact + baseline evidence: [#356 comment](https://github.com/raiseCatError/notMyShell/issues/356#issuecomment-6106773236), + `docs/development/fish-typeahead.md`. +- **Fish multiline submission:** use native `begin`/`end`; zsh/Bash use brace groups. Composer, dispatcher and parse + checks must use the real shell ID. Queue regressions verify persistence of shell variables and one logical record. +- **Fixture synchronization:** unique runtime-built read prompts prevent historical redraws authorizing a reply; + the Ctrl+C survivor waits for trap installation, uses a timed-read-capable shell and an explicit release file. +- **Attach privacy follow-up:** a final canonical run caught a password echoed by the outer PTY during journal I/O. + A slow-journal real-PTY fixture reproduces it. Raw input is now protected before passthrough output and throughout + probing/journaling; the later fixture follow-ups require their own final gate. Key diagnostics now exclude all running/startup input. +- **Gemfile security:** `18e94d9` applies the bounded single-line Ruby declaration parser to the duplicate in #368. + Malicious-whitespace and cross-line declaration regressions pass. #370 remains the canonical runtime owner. +- **Workflow additions (a16d556, 3097cbb):** clips deliberately kept in bounded frontend memory, reviewed through the + existing Paste Review/palette, history selection pinning with recorded cwd, and F8/Shift+F8 failure navigation. + Copy/folding remain independent. Clip and reference actions never submit commands automatically. +- **Clipboard suggestions (a16d556):** off by default and frontend-only. macOS uses only NSPasteboard changeCount + while idle; contents are read only by explicit `/ps`. Linux has no supported metadata-only counter and fails closed. + The fixed native metadata query and lifecycle/privacy unit regressions passed. +- **Guide/fixture corrections:** paste batches and pins are included in the guide; app fixtures pause the queue before + asserting accepted entries, preventing asynchronous parse checks from racing first-entry dispatch. +- **Diagnostics/import audit:** module explanations distinguish uncollected/unknown/stale facts from known data; + collection metadata does not prove conditional visibility. Starship/Oh My Posh previews disclose loss, save only + after a second explicit action, and preserve source files. External-tool decisions describe bat's actual Theme + Bridge support; dedicated source-preview launching is not implemented. +- **Fish parse-only transport:** Fish 3.7 on Linux rejects Node's socket-backed stdin with “Unable to read input + file: Is a directory”. Paste verification supplies Fish's script through `--no-execute -c` instead; failed/oversized + argv submission refuses the split. Regression checks prove neither commands nor substitutions execute. +- **Diagnostic privacy:** module details show safe collection failure states, rather than raw exception paths or + values. Measured durations remain available; no new collector is invoked by opening details. + +- **Comparison UI trust boundary (5570d34):** restored command labels in the chooser header bypassed display sanitization. Both chooser and diff now use safe labels; format and separator controls become visible in authored review UI. Adversarial tests cover narrow layouts and actual clipboard bytes. +- **Report Markdown status (25af5ba):** restored lifecycle text was a raw Markdown fact. It now uses the existing delimiter-sized inline-code representation, so HTML, links and backticks remain literal. +- **Vocabulary (d4619e4):** selected activity pairs survive live detach/reattach, including previous-choice collision avoidance. Optional journal indices are validated; factual status still derives from lifecycle and exit code. +- **Module diagnostics (1a372fb, 06433f1):** details show capability/source, freshness TTL, invalidation and demand-refresh policy, retained-cache availability and measured collection wall time excluding queue delay. Details reuse fact snapshots and renderer conditions; they add no collectors. Exact last-frame width-compaction decisions are not recorded. +- **Cross-feature queue fixture (4636672):** the combined workflow's optional batch action changes the paste-preview footer. The fixture accepts that optional action while still testing whole-paste insertion and explicit once-only queueing. The original regex failure reproduced, and corrected real-PTY cases passed for zsh, Bash and Fish. + +- **Report fixture (ee5483e):** the existing live Copy as Report assertion now expects the literal Markdown status added by the security fix. Its name accurately states that every report is reviewed. Review, cancellation and actual clipboard-byte tests passed. +- **Linux prerequisites:** the disposable minimal Ubuntu VM initially lacked less and vim. Their command-not-found failures are recorded; both tools are now installed in that VM. This changes no application installer policy. +- **Metadata/installer fixtures (86fb4e4):** identical size and timestamps after an immediate mise config edit reproduced even at nanosecond precision. The stat-only cache test now supplies a definite timestamp change and documents explicit refresh for unchanged metadata. Provider review tests supply their own fake executable installer; they do not require host Homebrew or run an installer. + +- **Completion and home isolation (566c321):** the queue full run at f081c42 failed its first nested-path candidate assertion after about 1.5 seconds. Controlled slow initialization reproduces a bounded empty result and transport cleanup. Functional capture/cache fixtures now prepare their own completion cache; a new expiry/recovery test retains the production startup deadline. The suite runner also assigns a private home inside its selected temporary root, preventing generated shell caches from reaching the host home. The new isolation test failed before the correction; affected lifecycle/completion tests and verify:fast passed afterward. Final canonical/CI verification is pending. + +- **Fixture ownership follow-up (48a0ded, e2adc1e):** a visible composer preceded the third shell's authenticated startup prompt in the SIGHUP fixture; a controlled startup release gate now proves and waits for that transition. Same-shell queue tests use a release file rather than a fixed sleep, keeping all five entries pending until assertions finish. Interrupt typeahead waits for actual foreground process ownership before signalling. The focused SIGHUP case and all three shell queue cases plus Ctrl+C/Ctrl+Z passed. +- **Short module details (52db115):** diagnostic rows now honor the panel's height and remain reachable through arrows/PageUp/PageDown. The viewport is ephemeral; reading and resizing do not change settings or demand collectors. Focused unit and real-PTY tests in a 50-column NO_COLOR Safe terminal, direct typecheck and verify:fast passed. Exact last-frame module width-compaction explanations remain an acceptance-integration follow-up. + +## Verification recorded so far + +Newest exact-head evidence is in the handoff above. Historical statements below +apply to their recorded commits, not later head acceptance. + +- macOS canonical verification passed at 566c321 with the isolated child home. Its later startup-gate fixture commit needs a new exact-head gate. +- The macOS queue run at 2dd8b80 failed five cases: a transient five-entry assertion after the holding sleep expired, signalling before actual foreground ownership, two no-output frontend startup deadlines and the bounded zoxide query fixture. The first two assumptions are corrected above. At inspection this 8 GiB/8 CPU host had load averages 18.05/50.35/59.36 and unrelated Xcode/xctest work active. Their processes are preserved. Host verification now runs one test worker with this track's Linux VM stopped; resource pressure is evidence, not a blanket explanation for remaining failures. +- The Ubuntu run at 895aae5 passed ranking and multiplexer cases but failed SIGHUP's third-shell readiness. That exact run is failed evidence; the controlled startup correction must pass a new full gate. +- macOS canonical verification and direct typecheck passed at 86fb4e4. [Actions 38150534791](https://github.com/raiseCatError/notMyShell/actions/runs/38150534791) passed the Ubuntu/macOS shards, Quality, Node 22 and aggregate checks; timing/Fedora jobs were skipped on the PR event. The isolated-home follow-up needs its own final gates. +- Ubuntu canonical verification passed at 5e45e87 with Fish 3.7.0, including the unchanged ranking threshold. Multiplexer tests were skipped because the minimal VM lacked tmux/screen; those tools are now installed for the next run. +- The Ubuntu canonical run at 5756f7c failed the report assertion, two missing-tool fixtures, mise timestamp assertion, provider installer availability assertion and the unchanged suggestion timing threshold. The timing test passed an isolated rerun; the final canonical gate must pass before publication. +- Typecheck and canonical verification of published #348: passed; exact Actions head `684a3fbc837be97f185cc938f4fb13cbccf1533b`. +- Queue candidate canonical verification passed before the additional attach privacy finding; that later failed run + is retained and must be superseded by a clean final run before pushing #357. +- Ubuntu 24.04 arm64 / Fish 3.7.0 / Node 26.10.0: published queue baseline fails both original Fish read tests; fixed + snapshot passes `inputAwarenessLive`, `typingAfterRead`, `queryOrder`, `commandQueueLive` including detach, + interruption, same-shell grouping and narrow/NO_COLOR/Safe presentation. Ubuntu amd64 CI remains an independent gate. +- Focused report/diff sanitization, hostile pin rendering, clip/paste staging, recipe review, history pinning, failure + keys, clipboard failure/EPIPE/size bounds, and actual stand-in clipboard diff bytes passed. +- Live Report cancellation and actual clipboard bytes passed on macOS and in the Ubuntu canonical run at 5e45e87 with hostile controls, invisible Unicode, tokens and fence content. +- Early full local runs inherited disabled colors/dumb TERM and one hit ENOSPC. They are failed runs, not evidence + of passing behavior. Final fixture runs explicitly set their truecolor environment; explicit NO_COLOR tests remain. + +## Cross-track boundary and remaining work + +[Native/workspace handoff PR #385](https://github.com/raiseCatError/notMyShell/pull/385) documents the current +independent heads and remaining frontend wiring. It explicitly waits for the #359 stabilization checkpoint. +Do not modify its handoff or branches. Ruby/PHP duplication is security-patched locally; reconcile against canonical +#370 after its verified freeze, retaining module diagnostics, theme imports and independent workflow behavior. + +The overlap is localized and deliberately retained until the canonical native heads are frozen: + +| File | Integration decision | +| --- | --- | +| `src/context/capabilities/runtimes.ts` | #368's duplicate Ruby/PHP remains patched by `18e94d9`; take canonical #370's stronger runtime implementation at integration | +| `src/context/packs/builtin/nmsh.project.json` | take the highest frozen native stack's catalog once, not both variants | +| `tests/contextCapabilities.test.ts` | preserve workflow-independent tests; retain malicious-whitespace/declaration regressions and canonical runtime tests | +| `tests/moduleManager.test.ts` | reconcile expected catalog contents with the highest native stack, preserving workflow diagnostics | +| `docs/design/native-module-coverage.md` | retain #368's coverage research; update adoption status against the exact integrated native heads | + +No destructive revert of the published runtime commit is needed. Removing the duplicate before the native handoff +would remove currently tested functionality from the standalone workflow PR; resolve these known file overlaps on +the acceptance integration branch while preserving both security regressions. #370 owns additional install-layout +support in `services.ts`, which this track has not changed. + +Next: complete final canonical verification and exact-head GitHub CI for every owned PR, then publish the frontend freeze checkpoint in #359. Acceptance integration follows both freezes in a separate `integration/nmsh-acceptance-*` +branch/worktree; no master/release merge is authorized. + +Physical QA must cover Ghostty and Terminal.app, hidden input and immediate typing after completion/interrupt, +queue/paste approval, clipboard/report/diff rendering, long-output folding, references, narrow/Safe/NO_COLOR, +reattach, vocabulary and theme previews. Headless fixtures are not physical validation. + +--- + +## Historical records (superseded; retained for context) ## Rules this work keeps @@ -27,6 +165,9 @@ Design and research: [docs/design/terminal-workflow.md](../design/terminal-workf | `feature/copy-report` | `../notMyShell-report` | #360 (draft, base #358) | Copy as Report; security fixes below | | `feature/compare-output` | `../notMyShell-compare` | #361 (draft, base #360) | Compare output; contains #360 by merge | +| `feature/expressive-activity-vocabulary` | `../notMyShell-activity` | none yet (PR body ready; opening it was held) | #362: Expressive/Classic wording, 663 curated pairs; contains #361 by merge; full suite green | +| `feature/output-references` | `../notMyShell-references` | none yet | #353 references + failure navigation; #354 paste batches, `/ps`; #355 queue conditions, pins, recipes (separate commits); contains #361 | + Other open PRs (independent, not part of this stack): #347 agent workspace, #349 notice word wrap, #346 mods. ## Work completed @@ -100,3 +241,11 @@ navigation, #354 paste batches and clipboard, #355 pins/recipes/queue conditions - `/copy ui` picker keys, search and preview at your usual window sizes. - Queue in daily use: Enter while a command runs, Ctrl+Q/Ctrl+S, a failing step pausing the rest. - Smart folding on your adb/diagnostic commands; the important-lines preview. + +## Session 2 checkpoint (branches pushed, no PRs opened by this session) + +- `feature/expressive-activity-vocabulary` (#362): full suite green (2227 pass). PR body ready; `gh pr create` was held by the safety hook, so open it with base `feature/compare-output`. +- `feature/output-references`: full suite green after fixing three deterministic count/registry tests (see last commit). +- Security: the batch splitter got three review rounds; it is now gated by real-shell parse checks (`pasteBatchCheck.ts`) and a whole-versus-split execution differential test. `displaySafe` now shows bidi/format characters. +- Not done: clipboard-change suggestions and ephemeral clips (#354), pin from history selection (#355), #365 explain/timings, #304 import, #366 decisions, native runtime modules (matrix in `docs/design/native-module-coverage.md`), workspace work (#330), physical QA for everything. +- CI: Ubuntu Fish live tests (`read` hand-over, alt-screen passthrough) time out on #357/#358 runs; they pass locally on macOS. Tracked under #356; needs a Linux repro. diff --git a/docs/development/workflow-physical-qa.md b/docs/development/workflow-physical-qa.md new file mode 100644 index 00000000..370b05c6 --- /dev/null +++ b/docs/development/workflow-physical-qa.md @@ -0,0 +1,67 @@ +# Workflow physical QA + +Tracker: [#359](https://github.com/raiseCatError/notMyShell/issues/359). Automated PTY fixtures do not establish +physical terminal validation. Record the exact branch/commit, OS, shell version and terminal host with each result. +Use an isolated NMSh configuration and a temporary working directory; keep provider/account configs unchanged. + +## Input and queue + +Use Ghostty and Terminal.app where available, with zsh, Bash 4.4+ and Fish 3.7 or later. + +1. Ask for a demo hidden answer using the active shell's command below. Type `FAKE_ONLY`, then Enter. Only + `ANSWER-ACCEPTED` should appear; the answer must not echo or execute as a shell command. + - zsh: `read -s 'reply?Demo password: '; [[ $reply == FAKE_ONLY ]] && print ANSWER-ACCEPTED` + - Bash: `read -rs -p 'Demo password: ' reply; test "$reply" = FAKE_ONLY && echo ANSWER-ACCEPTED` + - Fish: `read --silent --prompt-str 'Demo password: ' reply; test "$reply" = FAKE_ONLY; and echo ANSWER-ACCEPTED` +2. Repeat after detaching the window while the question is open and explicitly reattaching from `/resume`. + Input must remain hidden, answer the original reader and return to the composer after completion. +3. Run `sleep 3`, prepare `echo QUEUED` with Ctrl+Q, and inspect `/queue`. The entry runs once, after the original + command. Repeat with failure and Ctrl+C: the default pauses remaining entries. Ctrl+Z must return control without + sending a prepared response into a new shell command. Answer a real `read` while another command is queued; + the response belongs to the reader. +4. Paste native multiline blocks (including a Bash/zsh heredoc and a Fish `begin`/`end` block). Review splitting, + quoting, substitutions and strings. Pasting, opening review and choosing a recipe must execute nothing. Explicit + queue confirmation submits the reviewed text in order; variables and cwd remain in the same shell. +5. Set an entry to “asks first”, approve it, then edit it before dispatch. The edit withdraws approval. “Runs even + after a failure” permits the reviewed follow-up after failure, but does not bypass an interrupt. Repeat in each shell, + detach with an entry awaiting approval, and reattach: it must keep waiting until you approve that exact entry. +6. Open `less` or another alternate-screen program; keys and terminal selection belong to it. Exit and immediately + type a command. Repeat after Ctrl+C, Ctrl+Z and resize. + +## Clipboard, output and reuse + +1. `/ps` explicitly reads the clipboard. Review, keep with C, cancel and open `/clips`. Selecting a clip reviews it + again; insertion and queue approval are separate actions. Closing the frontend discards its clips. On macOS, + `/clips suggestions on` offers a neutral change hint while idle; `/ps` remains the content read. Disable with + `/clips suggestions off`. Unsupported platforms should explain that metadata suggestions are unavailable. +2. Pin a selected history command from the palette. Check its recorded cwd, command text and recipe review. A + stale history selection must not pin a different command. Keep that selected history command as a clip and review + it without running it; after closing/reattaching, the saved pin remains and the clip is gone. Placeholder values must stay shell-quoted data. +3. Run a failure between successful commands. F8/Shift+F8 navigate actual failed records while idle, preserving + the draft. Program input and passthrough must retain their keys. Check files, URLs, commits and device references + through Actions; output-derived choices must not execute automatically. +4. Produce long output, fold it and copy it through the picker, Actions and selection. Copied bytes stay complete + unless a selection was explicitly chosen; folding alone does not change copy content. +5. Copy as Report: inspect long lines, Markdown fences, backticks, demo tokens/private paths and edited text. Cancel + must leave the clipboard unchanged. Explicit copy must match the reviewed sanitized text. Scan detection is + best-effort. Verify a clipboard backend failure is reported without claiming a successful copy. +6. Compare two outputs. `c` copies a sanitized plain diff without redaction; sensitive findings disclose that. + `r` opens report review with redaction. Check actual clipboard bytes in a plain text editor, including combining + characters, Unicode separators and bidi/zero-width content. + +## Appearance and diagnostics + +Repeat relevant steps at 40, 50, 80 and 120 columns, Safe glyphs and `NO_COLOR=1`, and inside tmux. + +- Expressive is the default; successes pair active/completed wording. Failed, interrupted and waiting states keep + factual status and timing. Classic persists through restart. Copies/reports and archived records stay factual. + Reattach a running command and check its phrase stays paired when its live journal is available. +- Import a local Starship/Oh My Posh theme in `/theme`. Preview must disclose unsupported/lossy mappings before + saving. Cancel stores nothing. Saving changes only the NMSh theme library; provider source files remain unchanged. +- `/modules` details explain reads, routing, collection state and measured time. In a short window, + use Up/Down and PageUp/PageDown to reach Source, Cache, Timing and Why; resize and confirm the view stays bounded. Opening details or `/doctor` must + not trigger unrelated expensive collection. Unknown/uncollected states must not claim available data or proven + visibility. + +Keep issues open until required physical results are recorded. Report failures with the exact action sequence, +capture and commit; do not substitute automated passage for these results. diff --git a/docs/development/workflow-stabilization-handoff.md b/docs/development/workflow-stabilization-handoff.md new file mode 100644 index 00000000..66f351a2 --- /dev/null +++ b/docs/development/workflow-stabilization-handoff.md @@ -0,0 +1,411 @@ +# Workflow stabilization handoff — 2026-10-12 + +Canonical initiative: [#359](https://github.com/raiseCatError/notMyShell/issues/359). +This is a preservation checkpoint, **not release acceptance or a frontend freeze**. +Development and verification were stopped at the user's request. No new full suite +was started for this handoff. No PR was merged, no shared history rewritten, and no +protected worktree modified. + +## Commands to run first + +Read this file, workflow-initiative-ledger.md and current issue/PR discussion. +GitHub heads can advance; these tables are checkpoint observations. +Recover this file from origin/fix/workflow-maple after fetching. + +~~~sh +git status +git branch -vv +git worktree list --porcelain +git remote -v +git fetch origin +git show origin/fix/workflow-maple:docs/development/workflow-stabilization-handoff.md +gh issue view 359 --repo raiseCatError/notMyShell --comments +gh issue view 356 --repo raiseCatError/notMyShell --comments +gh pr view 385 --repo raiseCatError/notMyShell +gh run view 38157687343 --repo raiseCatError/notMyShell --log-failed +~~~ + +Use an isolated worktree, never develop in the main checkout. On this host, temporary +files, caches, VM disks and new worktrees belong on T7 Shield. Use neutral tree/color/ +fruit names and project commits without authorship trailers. Preserve other owners' +worktrees, including their names and stale registrations. + +Main checkout is clean on feature/status-strip-2 at +7916f2e160e713878db9fbe52b2fbb126a3b698c (ahead one, preserved). + +## Owned checkpoint branches and worktrees + +All eight trees were clean after acceptance commit 5447eb9, before this documentation. +Each is published under the identical neutral remote name. The Maple SHA below is +its implementation base; the final branch tip additionally contains this documentation. +Use the #359 comment/remote ref for the documentation commit's SHA. + +| Branch | Implementation/checkpoint SHA | Worktree | +| --- | --- | --- | +| fix/workflow-amber | 000736ddc47e78d322d2ba6ff83fd4e205c7ca26 | /Volumes/nmsh-t7/nmsh-amber-copy | +| fix/workflow-birch | 3adc4515a10e9f7a3af958683b1f389522fc0119 | /Volumes/nmsh-t7/nmsh-birch-queue | +| fix/workflow-cedar | 48a0ded6a9eecaca6753300ea33785d05e08f4db | /Volumes/nmsh-t7/nmsh-cedar-input | +| fix/workflow-coral | 80bd0cfaf3cabd0b36229317372e16995cbd0505 | /Volumes/nmsh-t7/nmsh-coral-report | +| fix/workflow-elm | 5447eb9e8a33f4496bd2f06c175b0a5f1c390cb6 | /Volumes/nmsh-t7/nmsh-elm-acceptance | +| fix/workflow-indigo | 0117a3439b05b1e4dab933e9da31c4fb795ef24a | /Volumes/nmsh-t7/nmsh-indigo-compare | +| fix/workflow-maple | e942523f3f352cc766c94eddfcc61e775a60df3d | /Volumes/nmsh-t7/nmsh-maple-workflow | +| fix/workflow-willow | be037a0861546705687bc4d633d4281e50fedf46 | /Volumes/nmsh-t7/nmsh-willow-vocabulary | + +Elm contains **incomplete acceptance regressions** at +5447eb9e8a33f4496bd2f06c175b0a5f1c390cb6, based on Maple e942523. +It is not merged into Maple or any published feature PR. +Maple and Willow are independent siblings above Indigo. + +Older local fix/workflow-*-checkpoint and *-resume refs are recovery history, not +active branches. All workflow stabilization commits reachable from those old refs +must remain recoverable; check ancestry before cleaning anything. + +## Published workflow PRs + +| PR | Published branch | Exact published head | Base | +| --- | --- | --- | --- | +| #345 | release/v0.18.0 | 7122468f0550f415ab6041cce94abda3a9fb6fa1 | master | +| #348 | feature/input-awareness | 48a0ded6a9eecaca6753300ea33785d05e08f4db | release/v0.18.0 | +| #357 | feature/command-queue | 7fb6b5ac23aeecc21e7a0d7ba5b824af95f505aa | feature/input-awareness | +| #358 | feature/copy-selection | 790fd740cc607cbf6e834bd01501006fa9183f82 | feature/command-queue | +| #360 | feature/copy-report | afe7f695ef803d0de11f53afd540f0a24d992a01 | feature/copy-selection | +| #361 | feature/compare-output | b1cdfbe9c28e7af58f5a181a51922f103f520bf5 | feature/copy-report | +| #367 | feature/expressive-activity-vocabulary | 66e164d973fab278823f773036800d9c6b0fd6a3 | feature/compare-output | +| #368 | feature/output-references | 5a795ed6214bbbb43223e6625cd15d552b84466e | feature/compare-output | + +Only #348 received the current input candidate. Other feature PRs retain old published +heads; candidate work is preserved on the neutral checkpoint refs, without opening +new PRs or dispatching CI. Do not claim those feature PRs contain candidate fixes. + +Dependency order: #345 → #348 → #357 → #358 → #360 → #361; +**#367 and #368 are siblings** based on #361. Preserve additive merge history. + +Related issues/ownership: #348 input; #357 queue; #358 copy selection/picker/sticky/ +folding; #360 report; #361 comparison; #367/#362 vocabulary; #368/#353–#355 references, +failure navigation, staging/clips, pins/recipes/conditions; #356 Fish; #363 security; +#365 existing diagnostics; #304 theme imports; #366 integrations; #359 coordination. +Manual acceptance remains outstanding; do not close issues from headless results. + +## Implemented work and limits + +- **Input:** authenticated Fish fish_read markers hand over before native reader prompts, + including hidden/function/detached readers. answersByDefault was not weakened. + Same-shell grouping uses Fish begin/end or zsh/Bash braces and explicit shell identity. + Attach privacy protects input before passthrough/probe/journal delay; key diagnostics + exclude startup/running input. Real PTY regressions cover private input and reattach. +- **Fish 3.7 parsing:** no-config/no-execute script via -c avoids socket-backed stdin's + “Unable to read input file: Is a directory”; oversized argv refuses splitting. Sentinel + regressions prove no commands/substitutions execute. +- **Security:** authored report/diff/reference/pin UI boundaries cover hostile controls, + bidi/invisible format characters, restored labels and Markdown fence/link/status + injections. Final clipboard bytes use stand-in clipboard programs; review/cancellation + and copy failure cases have evidence. Report status uses delimiter-sized inline code. + Secret scanning remains best-effort. Raw program output remains raw. +- **Workflow:** bounded ephemeral clips (12 entries / 1 MiB), explicit review, history + pinning with recorded cwd, F8/Shift+F8 factual failure navigation. Opt-in macOS idle + suggestions query clipboard changeCount only; contents require explicit /ps. Linux + metadata counter unsupported/fails closed. No derived command auto-executes. +- **Vocabulary:** stable per-command pairs and optional validated journal indices survive + live reattach; previous-pair collision avoidance retained. Failure/interruption/wait + are factual. Expressive default and Classic persistence retained; archived/copy/report + wording stays factual. +- **Imports:** bounded static Starship TOML and Oh My Posh JSON/YAML/TOML mappings, + unsupported/lossy disclosure, preview and second explicit save; no provider-source + rewrites, template execution or parallel theme engine. +- **Diagnostics:** availability, source/trust, TTL/invalidation/demand/cache state, safe + failure and measured collector wall time excluding queue delay. Reading details adds + no collectors. Short panels scroll via arrows/PageUp/PageDown, preserving draft and + settings. **Exact last-frame width-compaction decisions remain unimplemented.** +- **Runtime duplicate:** workflow Gemfile parser bounded at 18e94d9, retaining hostile + whitespace/cross-line regressions. #370 owns canonical native additions. Old published + #368 still lacks candidate fixes; no native catalog expansion was performed here. +- **Verification infrastructure:** explicit external temp roots fail closed; private child + HOME. Completion expiry/recovery retains production deadlines. Fixtures wait for + authenticated startup and foreground process ownership, replacing fixed holding sleeps. + +See the canonical ledger, fish-typeahead.md, workflow-physical-qa.md and linked issues +for individual fixing commits/design. These features are implemented, but **the whole +stack lacks final exact-head canonical/CI acceptance**. + +## Exact commands and results + +Host: macOS arm64, Node 26; one worker with own Linux VM stopped. +The suite supplies private child HOME. Do not alter personal/provider config. + +~~~sh +export TMPDIR=/Volumes/nmsh-t7/t TMP=/Volumes/nmsh-t7/t TEMP=/Volumes/nmsh-t7/t +export NMSH_TEST_TMPDIR=/Volumes/nmsh-t7/t +export XDG_CACHE_HOME=/Volumes/nmsh-t7/cache/xdg +export npm_config_cache=/Volumes/nmsh-t7/cache/npm +export TERM=xterm-256color COLORTERM=truecolor NMSH_TEST_CONCURRENCY=1 +unset NO_COLOR +npm run verify +~~~ + +Canonical verification builds/typechecks source, runs canonical tests plus separate +ranking and checks diffs. Counts are historical evidence for these runs, not a fixed +project test total. + +| Exact source head | Command and result | +| --- | --- | +| Cedar 48a0ded6a9eecaca6753300ea33785d05e08f4db | npm run verify PASS; runtime 2149 passed, 0 failed/skipped; ranking 8 passed; cleanup gate passed; runtime 648807.530084 ms | +| Birch 3adc4515a10e9f7a3af958683b1f389522fc0119 | npm run verify PASS; runtime 2176 passed, 0 failed/skipped; ranking 8 passed; cleanup gate passed; runtime 769361.74225 ms | +| Amber 000736ddc47e78d322d2ba6ff83fd4e205c7ca26 | npm run verify FAIL; runtime 2200 passed / 3 failed / 0 skipped; ranking 8 passed; runtime 948723.419 ms | +| Coral 80bd0cf / Indigo 0117a34 / Willow be037a0 / Maple e942523 | Final host canonical NOT RUN: serial loop stopped at Amber | +| Elm 5447eb9 source | Focused command below FAIL: 15 passed / 4 failed / 0 skipped; 92825.112 ms. Run preceded commit; commit preserves the same files | +| Maple diagnostics 52db115 | npm run typecheck PASS; focused moduleManagerUx/moduleExplain/moduleManager/moduleDiagnosticsLive: 25 passed; npm run verify:fast PASS (67 focused tests) | + +Current verification was finished before preserving Elm. **No new suite was launched +for this handoff**. Exact last focused command with the environment above: + +~~~sh +node /Volumes/nmsh-t7/run-selected.mjs "$PWD" \ + tests/queueConditionOwnershipLive.test.ts tests/queueConditionsLive.test.ts \ + tests/outputReferencesLive.test.ts tests/historyReuseLive.test.ts \ + tests/compatibilityHarness.test.ts --test-reporter=spec +~~~ + +Local helper imports cwd's scripts/test.mjs, validates selected file existence, calls +runTestFiles and returns its exit code. Passing files to scripts/test.mjs directly +does not focus tests. Cloud equivalent (do not run until development resumes): + +~~~sh +node --input-type=module -e " +import {runTestFiles} from './scripts/test.mjs'; +process.exitCode = (await runTestFiles([ +'tests/queueConditionOwnershipLive.test.ts', 'tests/queueConditionsLive.test.ts', +'tests/outputReferencesLive.test.ts', 'tests/historyReuseLive.test.ts', +'tests/compatibilityHarness.test.ts' +], ['--test-reporter=spec'])).code;" +~~~ + +### Elm acceptance failures — preserved, not fixed + +1. outputReferencesLive.test.ts:72: “timed out waiting for the original composer draft + executes only on its explicit Enter”. Earlier restored navigation/reference copy + assertions passed; final draft submission timed out. Cause unestablished. +2. queueConditionOwnershipLive.test.ts:110, zsh/Bash/Fish: “AssertionError: NO_COLOR + stays plain”. Expected no ESC[38;[25]; in output slice; actual has + ESC[38;2;242;240;236m and other colors in restored command/status rows. + Approval/failure/interrupt checks reached this assertion; subsequent transcript/cwd + assertions were **not reached**. Establish restored ANSI vs live rendering/fixture + boundary before changing product or checks. +3. historyReuseLive passed; existing queueConditionsLive passed across all three shells. + Every compatibilityHarness case passed in this focused rerun. + +### Amber full-suite failures — remain failed evidence + +- Agent fixture clean-exit ownership: disposal timed out; four lsof ownership probes + ETIMEDOUT at 2120/2357/3002/2287 ms. Frontend PID 8730 already dead, sockets empty, + ownership still unproven. +- Nested fixture clean-exit ownership: same failure, five ETIMEDOUT probes at + 2007/2041/2059/2041/2047 ms; frontend PID 8978 dead, sockets empty. +- Canonical inline restoration: startup /❯/ deadline expired after 20184 ms; zero + frontend output bytes; PID 9277 alive. No prompt/test command executed. + +8 GiB / 8 CPU host had unrelated Xcode/xctest/Shortcuts/Docker work; load reached +34.51/16.34/11.29 during failure. Their processes were preserved. +**Scheduling/resource effects are suspected, not confirmed flakes.** A passing focused +rerun does not erase failed canonical evidence. Do not weaken deadlines or ownership +checks. Disposal may mask body assertions; preserve both errors during investigation. + +### Current input GitHub CI + +[Actions 38157687343](https://github.com/raiseCatError/notMyShell/actions/runs/38157687343) +at 48a0ded6a9eecaca6753300ea33785d05e08f4db: **FAIL**. + +- Ubuntu both shards, macOS shard 2/2, Quality Node 26 and Node 22 compatibility passed. +- macOS shard 1/2 failed tests/flowComposer.test.ts: “Bottom and Top keep their existing + behavior: typing does not change the scrolled view”, Error: posix_spawnp failed. + Stack: node-pty UnixTerminal → ShellSession → InProcessSessionClient → TerminalApp → + flowApp line 88 → test line 163. Cause not investigated at checkpoint. +- Command: npm test -- --shard=1/2 --test-timeout=120000, after npm run build. +- Aggregate CI failed. Verify 26 failed / Verify 22 cancelled at aggregate-success gate; + these wrappers are not independent test failures. +- Timing/Fedora skipped on PR event. +- Older 86fb4e4 had green [38150534791](https://github.com/raiseCatError/notMyShell/actions/runs/38150534791). + Historical green does not establish the current head. +- Remaining checkpoint refs lack final exact-head CI. Neutral checkpoint-only pushes + do not match master/dev push filters; no PR or dispatch was created. + +### Linux/Fish evidence + +Ubuntu 24.04 arm64 / Fish 3.7.0 / zsh 5.9 / Bash 5.2 / Node 26.10.0: +original queue baseline reproduced both Fish reader failures. Fixed input/read/query/ +queue PTY focused cases passed, including detach, interrupt and narrow/NO_COLOR/Safe. + +Full workflow 5e45e87 canonical PASS: runtime 2327 passed, 15 platform/missing-mux skips; +ranking 8 passed. Later 895aae5 full run failed third-shell SIGHUP startup readiness, +while ranking and tmux/screen passed. Controlled 48a0ded correction passed focused and +host canonical; **new full Linux verification remains pending**. + +Earlier 5756f7c failed literal report expectation, missing less/vim, unchanged mise +stat metadata, host installer fixture and unchanged timing threshold. Corrections/ +isolated timing rerun are recorded in the ledger. They are not current-head acceptance. +less/vim and tmux/screen are now installed in the owned VM. + +### Evidence remaining local on T7 + +Under /Volumes/nmsh-t7/verification/: + +- cedar-input-48a0ded6a9eecaca6753300ea33785d05e08f4db-canonical.log +- birch-queue-3adc4515a10e9f7a3af958683b1f389522fc0119-canonical.log +- amber-copy-000736ddc47e78d322d2ba6ff83fd4e205c7ca26-canonical.log +- elm-acceptance-checkpoint-focused.log +- checkpoint-input-ci-failed.log and checkpoint-input-ci-jobs.json +- host-stack-canonical-progress.log +- birch-ownership-lifecycle-navigation.log (34 focused passed) +- birch-queue-gates-focused.log (4 focused passed) +- cedar-session-startup-gate-focused.log (1 focused passed) +- maple-detail-typecheck.log, maple-detail-focused-final.log, maple-detail-fast.log. + +Raw logs, node_modules, selected runner and VM are **local only**. Important commands, +assertions/results and failures are copied here for GitHub/cloud recovery. Fresh +sessions must not depend on local caches or hidden conversation. + +## #356, #359 and shared frontend boundaries + +[#356](https://github.com/raiseCatError/notMyShell/issues/356) remains open. Confirmed +Fish reader ownership and parse transport fixed in checkpoint ancestry. Immediate +input after read/interrupt, fullscreen and privacy acceptance must remain green on +final heads. Current CI failure is macOS PTY spawning, not a Fish failure diagnosis. + +[#359](https://github.com/raiseCatError/notMyShell/issues/359) remains open. +TerminalApp.ts remains exclusively owned by workflow stabilization. **No frontend freeze**. +Native/workspace shared wiring awaits explicit verified #359 freeze; do not integrate +unfinished heads. + +Interfaces to preserve: authenticated Fish reader/terminalOwner; explicit shell ID in +grouping/parse/queue; privacy protection before passthrough/journal I/O; optional validated +journal activity indices; reserved F8/Shift+F8; diagnostics hasValue/renderer condition +metadata and ephemeral detail viewport. Inspect actual code before conflict resolution. + +## Protected native/workspace branches + +Use [PR #385](https://github.com/raiseCatError/notMyShell/pull/385)'s separate handoff. +Protected heads advanced during this checkpoint. These observed heads supersede our +old manifest, not that track's ownership documentation. No edits to protected worktrees/ +branches. Functional provider names in existing branch names are not authorship. + +| PR | Protected published branch | Exact observed head | Base | +| --- | --- | --- | --- | +| #369 | feature/action-controls | 85ab93026e564b4f763e63d63a4326487e118032 | release/v0.18.0 | +| #370 | feature/native-runtimes | 417d105e3f513e36bf35d0efb7c0b79574a31f9b | release/v0.18.0 | +| #372 | feature/runtimes-elixir-dart | 72775e6aea273f71cb2ddf402cdb7952c0740c95 | feature/native-runtimes | +| #373 | feature/project-build-module | 029fac47d0007336d6829e9f7c9aaead62e5670d | feature/runtimes-elixir-dart | +| #374 | feature/vcs-hg-jj | d7d348886d008a7a70ffac3df2fa46daea9cf3e1 | release/v0.18.0 | +| #375 | feature/workspace-foundation | 111af0daf15bce06ea10645b56eab50ca239ccab | release/v0.18.0 | +| #376 | feature/workspace-sidebar | 955d29f292d99494fa0acaa7db65c8eaa6bd807e | feature/workspace-foundation | +| #377 | feature/workspace-panel | f5ef4b2da679f6c56dfd3b3bc5df114f8fd00968 | feature/workspace-foundation | +| #378 | feature/semantic-keymap | 1dc8a24177ed8a2d643c3061d4e88257d4b76b19 | feature/workspace-foundation | +| #379 | feature/portable-mod-gate | fccf68eabc0aaacbd771a3cea9a17d5b13ad66b1 | release/v0.18.0 | +| #380 | feature/codex-app-server-adapter | 8854bb78629acdb1fbaa3e8390b6b9d366724857 | release/v0.18.0 | +| #381 | feature/worktree-pr-evidence | 8a40b76aa3317e85d1525bb6b506e2b6e2470892 | release/v0.18.0 | +| #383 | feature/background-tasks-core | 4b9685d37f6ffffa8a1dba8d8614b1e52d2374ef | release/v0.18.0 | +| #384 | feature/background-task-ui | 85ac4210faf6de804729a8e8b7dc724aa12b9b8e | feature/background-tasks-core | +| #385 | docs/context-workspace-handoff | c9a368663fc334304f6631f664bbdf5e5459dc59 | release/v0.18.0 | + +Protected worktree paths come from git worktree list --porcelain; their checked-out +heads may lag GitHub. Do not update them. PR #385's description calls published #368's +Gemfile parser quadratic; Maple's candidate already contains bounded fix 18e94d9. + +Known retained Ruby/PHP overlap: + +| File | Integration decision | +| --- | --- | +| src/context/capabilities/runtimes.ts | Prefer canonical #370 implementation/security fix, retain regression intent | +| src/context/packs/builtin/nmsh.project.json | Take highest frozen native catalog once | +| tests/contextCapabilities.test.ts | Preserve workflow tests and both malicious whitespace/declaration regressions | +| tests/moduleManager.test.ts | Reconcile catalog expectations, preserve diagnostics | +| docs/design/native-module-coverage.md | Preserve research and update adoption | + +#370 owns install-layout services.ts changes; workflow did not edit those. +Do not revert unrelated #368 diagnostics/imports/docs to remove duplication. +Shared frontend conflicts are resolved only after both freezes. + +## Recommended next development steps (separate authorization) + +1. Audit current heads/issues/ownership, read protected handoff. Use isolated neutral + external worktrees; leave main checkout and others' trees untouched. +2. Focused investigation of current input flowComposer spawn failure and Amber + startup/disposal. Preserve body and cleanup errors; no deadline/security weakening. +3. Review Elm 5447eb9: draft/palette context and restored NO_COLOR. Checkpoint intentionally + incomplete, not release-ready. +4. Complete remaining host exact-head canonical, exact-head Ubuntu/Fish full verification + including installed tmux/screen, source/benchmark typecheck and timing smoke. + Serialize own host tests with own VM stopped; preserve unrelated processes. +5. Propagate fixes additively up original stack and into both siblings independently. + Merge corrected Elm acceptance into Maple deliberately. Verify coherent final heads + then publish to original feature refs; report actual exact-head CI and update PRs. + No force-push/rebase. Timing/Fedora need applicable final verification event. +6. Publish explicit #359 freeze only after workflow gates, with exact heads/interfaces. + Native/workspace track must separately freeze and document final frontend wiring. +7. **After both freezes**, dedicated external integration/nmsh-acceptance-* branch/tree, + based on verified workflow tip. Merge Willow sibling; highest native ancestry once + (currently #373 includes #372/#370); independent #374/#369; highest workspace + foundation ancestry once then independent sidebar/panel/keymap; #379/#380/#381; + highest background UI ancestry once (#384 includes #383). Recheck ancestry and + protected handoff order/contracts at integration time; never merge ancestry twice. + Resolve runtime/shared-frontend conflicts preserving both security fixes. +8. Full cross-feature acceptance on integration branch. Never merge master/release or + tag/publish. Physical Ghostty/Terminal.app QA remains required; workflow-physical-qa.md. + +### External storage / VM recovery + +APFS image lives physically on external ExFAT T7: + +~~~sh +hdiutil info +# Only if owned image is absent: +hdiutil attach -nobrowse -owners off -mountpoint /Volumes/nmsh-t7 \ + "/Volumes/T7 Shield/nmsh-maple-scratch.sparsebundle" +~~~ + +Do not unmount active storage or delete others' files. Owned Lima cedar-fish37 confirmed +**Stopped**. Binary /Volumes/nmsh-t7/maple-linux/bin/bin/limactl; +LIMA_HOME=/Volumes/nmsh-t7/maple-linux/state. Guest temp/cache physically on T7. + +Prepared maple-source-readiness.git and verify-readiness.sh snapshot e942523, **not Elm** +or this doc commit; no new run performed. Refresh an immutable snapshot after changes +rather than resetting a running guest checkout. + +## Deferred / manual + +#366 retains optional supported Atuin/fzf/Television/zoxide/bat/delta paths. Pueue/Yazi +dedicated contracts deferred; native queue remains same-shell sequential. Embedded Gum +rejected; ordinary passthrough retained. Dedicated bat source-preview launching not +implemented. See recorded integration decisions. + +Physical QA outstanding: hidden input/immediate typing, Ctrl+C/Z, alternate-screen, +exact queue approval/detach, clip lifecycle/history pins, host clipboard success/failure, +hostile report/diff, narrow/NO_COLOR/Safe, long output/folding, vocabulary/resume, +theme preview/save and diagnostics scroll. No personal config changes or physical QA +were claimed. + +All implementation and incomplete acceptance source is recoverable from the eight +checkpoint refs. Final verification/CI/freeze/integration remain incomplete. + +## Retained local recovery refs + +These inactive refs have no active owned worktree. Every head below was verified to +be an ancestor of Maple, Willow or Elm, so pushing the active leaves preserves all +these commits on GitHub. The inactive branch names themselves remain local; do not +merge them again. No recovery ref has unique work absent from the published leaves. + +| Local recovery ref | Exact head | Preserved in active leaf ancestry | +| --- | --- | --- | +| fix/workflow-acceptance | 372bd5a4b58b5f010dee438f3c67b54927641b17 | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-acceptance-durable | 7d29d2b7371cf5914018ac96b385a30aa348b79a | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-acceptance-resume | 7d29d2b7371cf5914018ac96b385a30aa348b79a | fix/workflow-maple, fix/workflow-elm | +| fix/workflow-compare-checkpoint | 82726ae9fb4edd171d57b3b2af10c31dc20a55b7 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-compare-resume | 1489b8f03ec8f4a2b9dcc4e8b21a288ab5c9453a | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-copy-checkpoint | 59e77a0050c8585cc1cd1046e978fb841babd946 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-copy-resume | 0af5116c6b4a9917422d9060412c38892a8c712d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-fish-routing | 2d24743c903e5e9e8c5c1afb1f3efca3bbb02f15 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-input-checkpoint | 2d96b25a38806029f5c9ea685bba2bed6c52d89d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-input-resume | 2d96b25a38806029f5c9ea685bba2bed6c52d89d | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-queue-resume | 463667209b957f7f6465ff72ad948749ab9fec5f | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-report-checkpoint | 25af5ba6ff06e027c983f777c30d13704ee5e16a | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-report-resume | 7173b46f05953792d06865d91842b40bc7103897 | fix/workflow-maple, fix/workflow-willow, fix/workflow-elm | +| fix/workflow-vocabulary-checkpoint | d016545942c5558cf41a6be9b15abf9d43b072d8 | fix/workflow-willow | +| fix/workflow-vocabulary-resume | fdf6eb3baa14d4ac7a3f3be444731931fd69f6fb | fix/workflow-willow | diff --git a/scripts/test-selection.mjs b/scripts/test-selection.mjs index fa8e1b33..c0681d71 100644 --- a/scripts/test-selection.mjs +++ b/scripts/test-selection.mjs @@ -25,6 +25,7 @@ const runtimeWeights = { "bundledCatalog.test.ts": 5, "compatibilityHarness.test.ts": 28, "commandQueueLive.test.ts": 68, + "copySelectionLive.test.ts": 13, "configuredCompletion.test.ts": 6, "ctrlZJobControl.test.ts": 12, "detachedOutput.test.ts": 22, @@ -38,6 +39,8 @@ const runtimeWeights = { "nativeCaptureLifecycle.test.ts": 6, "sessionLifecycle.test.ts": 28, "sessionPresets.test.ts": 20, + "pasteBatchLive.test.ts": 21, + "queueConditionsLive.test.ts": 24, "shellAdapters.test.ts": 35, "shellSwitchApp.test.ts": 23, "startupBlocked.test.ts": 29, diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f59608bd..ae6aeb4e 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -25,6 +25,8 @@ import {paletteItems} from '../ui/CommandPalette.js'; import {createConfigurationPanel, configurationKey, renderConfigurationPanel, type ConfigurationPanel} from '../tools/ConfigurationPanel.js'; import {openSupportedConfiguration} from '../tools/SupportedConfiguration.js'; import {selectOpener} from '../host/desktop.js'; +import {findOutputReferences, referenceFollowUp} from '../output/references.js'; +import {referenceCopyText, referencePaletteItems} from '../ui/ReferenceMenu.js'; import {integrationActivation} from '../tools/Activation.js'; import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; @@ -128,7 +130,7 @@ import {createProviderPanel, handleProviderPanelKey, providerPanelEnterAction, p type ProviderPanelState} from '../providers/ProviderPanel.js'; import {TapActivityObserver} from '../output/TapActivityObserver.js'; import {HistoryViewport, stickyHeaderFor, type StickyHeader, type WrappedRow} from '../output/viewport.js'; -import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, statusStripModules, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, statusStripModules, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, moduleConditionMet, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; import {foldingPreview, handleTranscriptPanelKey, renderTranscriptPanel, type TranscriptPanelState} from '../output/TranscriptPanel.js'; import {tabCompletionAction} from '../input/tabBehavior.js'; import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; @@ -168,7 +170,7 @@ import {KeyDecoder, type Key} from '../terminal/keys.js'; import {promptConfigurationPath} from '../configuration/paths.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import {isSlashInput, parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow, type ParsedSlashCommand} from '../commands/slashCommands.js'; -import {ClipboardUnavailableError, copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; +import {ClipboardUnavailableError, copyFeedback, copyStats, readClipboard, writeClipboard} from '../clipboard/clipboard.js'; import {beginSelection, extendSelection, isRowSelected, selectedText, type TranscriptSelection} from '../output/TranscriptSelection.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {QueryExtractor, ReplyRouter} from '../passthrough/TerminalQueries.js'; @@ -243,7 +245,17 @@ import {runDoctor} from '../doctor/doctor.js'; import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis} from '../input/pasteGuard.js'; +import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; +import {batchFromCommands, batchReviewKey, batchTexts, createBatchReview, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {QUEUE_LIMIT} from '../session/CommandQueue.js'; +import {addPin, addRecipe, changeItem, keepProblem, loadPins, pinsPath, removeItem, type PinData, type Recipe} from '../pins/PinStore.js'; +import {createPinsPanel, pinsPanelKey, renderPinsPanel, type PinsPanelState} from '../pins/PinsPanel.js'; +import {ClipStore, type Clip} from '../clipboard/ClipStore.js'; +import {clipboardCounter, ClipboardChangeWatch} from '../clipboard/ClipboardChanges.js'; +import {expandSteps, quotedPlaceholder} from '../pins/recipes.js'; +import {splitBatch} from '../input/pasteBatch.js'; +import {verifyCommands} from '../input/pasteBatchCheck.js'; import {WHY_FAILED} from '../ask/failure.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} from '../doctor/DoctorPanel.js'; @@ -763,6 +775,11 @@ export class TerminalApp { /** A queued entry being edited in the composer: Enter saves it in place, Esc cancels. */ private queueEdit?: {id: number; draft: string}; private queuePanel?: QueuePanelState; + private pinsPanel?: PinsPanelState; + private readonly clips = new ClipStore(); + private readonly clipboardChangeCounter = clipboardCounter(); + private clipboardChangeWatch?: ClipboardChangeWatch; + private clipboardChangeSuggested = false; /** One pending timeout at a time drives the welcome cat's occasional blink. */ private welcomeBlinkTimer?: () => void; private welcomeBlinkCount = 0; @@ -1694,11 +1711,13 @@ export class TerminalApp { return; } if (this.cursorPanel) { this.handleCursorPanelKey(key, this.cursorPanel); this.render(); return; } + if (this.pinsPanel) { void this.handlePinsKey(key); return; } if (this.queuePanel) { - const action = queuePanelKey(this.queuePanel, key, this.queueState); + const action = queuePanelKey(this.queuePanel, key, this.queueState, this.queueConditionsAvailable()); if (action?.kind === 'close') { this.queuePanel = undefined; this.returnFromPanel(); } else if (action?.kind === 'change') this.session.queue(action.change); else if (action?.kind === 'edit') { this.queuePanel = undefined; this.returnFromPanel(); this.beginQueueEdit(action.id); } + else if (action?.kind === 'saveRecipe') { this.queuePanel = undefined; this.openPins({naming: this.queueState.entries.map(entry => entry.text)}); } this.render(); return; } @@ -1758,10 +1777,37 @@ export class TerminalApp { if (this.worktreePanel) { void this.handleWorktreeKey(key); return; } if (this.githubPanel) { this.handleGithubKey(key); return; } if (this.agentView) { this.handleAgentViewKey(key); return; } + if (this.batchReview) { + const review = this.batchReview; + const action = batchReviewKey(review, key, this.dimensions().rows - 4); + const done = () => { this.batchReview = undefined; this.pasteReview = undefined; this.pastePreview = undefined; this.selectedSuggestion = 0; }; + if (action?.kind === 'queue') { + // Enter on the reviewed list is the confirmation: exactly these commands, in this order, into this session's own queue. + const texts = batchTexts(review); + if (!this.queueAvailable() || texts.length > QUEUE_LIMIT - this.queueState.entries.length) { + this.output.addFrontendInteraction('/ps', 'The queue cannot take these commands now; nothing was queued.', ERROR); + } else { + for (const text of texts) { + this.session.queue({op: 'add', text}); + this.sessionSubmissions.push({text, slash: false}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + } + this.output.addFrontendInteraction('/ps', `Queued ${texts.length} command${texts.length === 1 ? '' : 's'}. They run one at a time in this shell; /queue shows them.`, INFO); + } + done(); + } else if (action?.kind === 'edit') { this.editor.insertPaste(batchTexts(review).join('\n')); done(); } + else if (action?.kind === 'text') { this.editor.insertPaste(review.source); done(); } + else if (action?.kind === 'back') { this.batchReview = undefined; if (review.title) this.openPins(); } + else if (action?.kind === 'cancel') done(); + this.render(); + return; + } if (this.pasteReview && this.pastePreview) { // The bounded review surface: Enter inserts the original text (nothing runs), Esc returns to the compact preview. const outcome = pasteReviewKey(this.pasteReview, key, this.dimensions().rows - 4); - if (outcome === 'insert') { const text = this.pastePreview.text; this.pasteReview = undefined; this.pastePreview = undefined; this.editor.insertPaste(text); this.selectedSuggestion = 0; } + if (outcome === 'batch') void this.openBatchReview(this.pastePreview.text); + else if (outcome === 'clip') this.keepClip(this.pastePreview.text, 'paste', this.shellCwd); + else if (outcome === 'insert') { const text = this.pastePreview.text; this.pasteReview = undefined; this.pastePreview = undefined; this.editor.insertPaste(text); this.selectedSuggestion = 0; } else if (outcome === 'back') { this.pasteReview = undefined; if (!this.pasteCompactFits()) this.pastePreview = undefined; } else if (outcome === 'cancel') { this.pasteReview = undefined; this.pastePreview = undefined; } this.render(); @@ -1770,7 +1816,9 @@ export class TerminalApp { if (this.pastePreview) { const preview = this.pastePreview; if (key.kind === 'enter') { this.pastePreview = undefined; this.editor.insertPaste(preview.text); this.selectedSuggestion = 0; } - else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis); + else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis, preview.batch); + else if (key.kind === 'text' && key.value.toLowerCase() === 'b' && preview.batch) void this.openBatchReview(preview.text); + else if (key.kind === 'text' && key.value.toLowerCase() === 'c') this.keepClip(preview.text, 'paste', this.shellCwd); else if (key.kind === 'escape' || key.kind === 'interrupt') this.pastePreview = undefined; this.render(); return; @@ -2022,6 +2070,10 @@ export class TerminalApp { if (key.kind === 'escape') this.clearBlockFocus(); if (FLOW_EDIT_KEYS.has(key.kind) && key.kind !== 'enter') this.clearBlockFocus(); + if (key.kind === 'previousFailure' || key.kind === 'nextFailure') { + if (!this.running) this.goToFailure(key.kind === 'previousFailure' ? 'previous' : 'next'); + return; + } if (key.kind === 'historyDelete' && this.historySearchActive) { const entry = this.historyQuery === this.editor.text.substring(HISTORY_SEARCH.length) ? this.historyResults[this.selectedSuggestion] : undefined; if (entry) void this.historyService.index.delete(entry.id).then(() => { @@ -2171,9 +2223,10 @@ export class TerminalApp { // Paste Guard: worth-a-look pastes are previewed first (never changed); ordinary ones insert at once. const analysis = analyzePaste(key.value); if (needsPreview(analysis, this.promptConfiguration.pastePreview)) { - this.pastePreview = {text: key.value, analysis}; + const batch = this.batchAvailable(key.value); + this.pastePreview = {text: key.value, analysis, batch}; // The compact strip never squeezes the composer: when this screen has no room for it, Review is the surface. - if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis); + if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis, batch); this.render(); return; } @@ -2554,6 +2607,12 @@ export class TerminalApp { this.render(); } else if (slash.kind === 'copy') await this.copySlash(command, slash.args); + else if (slash.kind === 'pasteClipboard') await this.pasteClipboard(command); + else if (slash.kind === 'clips') { + if (slash.suggestions === undefined) this.openClips(); + else this.configureClipboardChanges(slash.suggestions); + } + else if (slash.kind === 'pins') { this.panelOrigin = undefined; this.openPins(); } else if (slash.kind === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -2673,7 +2732,103 @@ export class TerminalApp { private openPalette(): void { if (this.settingsPanelActive) return; const record = this.focusedCommandIndex === undefined ? this.output.recent(1) : this.output.recent(this.focusedCommandIndex + 1); - this.paletteState = createPalette([...paletteItems(), ...(record ? blockPaletteItems(record) : [])]); + const history = this.historySearchActive && this.historyQuery === this.editor.text.substring(HISTORY_SEARCH.length) + ? this.historyResults[this.selectedSuggestion] : undefined; + const historyItems: PaletteItem[] = history ? [{id: `history:pin:${history.id}`, label: 'Pin selected history command', + detail: displaySafe(history.command), category: 'Transcript', action: {kind: 'pinHistory', id: history.id}}, + {id: `history:clip:${history.id}`, label: 'Keep selected history command as clip', detail: displaySafe(history.command), + category: 'Transcript', action: {kind: 'keepHistoryClip', id: history.id}}] : []; + const composerItems: PaletteItem[] = this.editor.text.trim() && !isSlashInput(this.editor.text) + ? [{id: 'clip:keep-composer', label: 'Keep composer draft as clip', detail: 'Keep text in memory without changing the draft or executing it', category: 'Transcript', action: {kind: 'keepComposerClip'}}] : []; + this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record) : [])]); + } + + /** Newest-first indexes of completed commands that did not exit 0 (a nonzero exit or an interrupt is authoritative; output text is not). */ + private failedBlockIndexes(): number[] { + return this.output.view().completed.flatMap((record, index) => record.exitCode !== 0 ? [index] : []); + } + + private failureNavigationItems(): PaletteItem[] { + const failed = this.failedBlockIndexes(); + if (!failed.length) return []; + const count = `${failed.length} failed command${failed.length === 1 ? '' : 's'} in this transcript`; + return [ + {id: 'failure:previous', label: 'Previous failed command', detail: `Older block that exited nonzero or was interrupted · ${count}`, category: 'Transcript', action: {kind: 'failure', direction: 'previous'}}, + {id: 'failure:next', label: 'Next failed command', detail: `Newer block that exited nonzero or was interrupted · ${count}`, category: 'Transcript', action: {kind: 'failure', direction: 'next'}}, + ]; + } + + /** Focus the previous (older) or next (newer) failed block, found by its command record and brought into view. */ + private goToFailure(direction: 'previous' | 'next'): void { + const failed = this.failedBlockIndexes(); + const current = this.focusedCommandIndex; + const target = direction === 'previous' + ? failed.find(index => current === undefined || index > current) + : [...failed].reverse().find(index => current !== undefined && index < current); + if (target === undefined) { + this.output.addFrontendInteraction('/failures', direction === 'previous' ? (failed.length ? 'No older failed command.' : 'No failed command in this transcript.') : (current === undefined ? 'Nothing is focused; use Previous failed command first.' : 'No newer failed command.'), INFO); + return; + } + const record = this.output.view().completed[target]; + this.focusedLineIndex = undefined; + this.focusedActivityId = undefined; + this.focusedCommandIndex = target; + if (record) this.revealBlock(record.startId); + } + + /** Scroll so a block's first row is in view (the same placement find uses for a match). */ + private revealBlock(startId: number): void { + const {columns, rows} = this.dimensions(); + const wrapped = this.output.wrapped(columns); + const row = wrapped.findIndex(item => item.blockStartId === startId); + if (row < 0) return; + const height = this.planFrame(columns, rows).viewportRows; + this.historyViewport.scrollLines(wrapped.length, height, revealStart(row, wrapped.length, height) - this.historyViewport.resolve(wrapped.length, height)); + } + + private openReferences(record: CompletedCommand): void { + const references = findOutputReferences(record); + if (!references.length) { + this.output.addFrontendInteraction('/references', 'No files, links, commits or devices found in this output.', INFO); + return; + } + this.paletteState = createPalette(referencePaletteItems(record.startId, references)); + } + + /** One action on one reference, resolved again from its block: nothing here runs a command. */ + private async runReference(startId: number, refId: string, verb: 'open' | 'copy' | 'stage'): Promise { + const say = (message: string, style = INFO) => { this.output.addFrontendInteraction('/references', message, style); this.render(); }; + const record = this.output.view().completed.find(item => item.startId === startId); + const reference = record ? findOutputReferences(record).find(item => item.id === refId) : undefined; + if (!record || !reference) return say('That output is no longer available.', ERROR); + if (verb === 'copy') { + const text = referenceCopyText(reference); + try { await writeClipboard(text); this.noteClipboard(`Copied ${reference.kind === 'file' ? 'location' : reference.kind} · ${copyStats(text).characters.toLocaleString()} characters`, 'success'); } + catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } + return; + } + if (reference.kind === 'file') { + const resolved = resolveLocation({path: reference.value, ...(reference.line ? {line: reference.line} : {}), ...(reference.column ? {column: reference.column} : {})}, record.historicalContext?.cwd ?? this.shellCwd); + if (!resolved.ok) return say(resolved.reason, ERROR); + const adapter = this.hostActions(); + await this.performHostAction(resolved.kind === 'directory' ? adapter.openDirectory(resolved.location.path) : adapter.openFile(resolved.location), say); + return; + } + if (reference.kind === 'url') { + const opener = selectOpener(); + if (!opener) return say(`No system URL opener is available here. The link is ${reference.value}`); + try { spawn(opener, [reference.value], {detached: true, stdio: 'ignore'}).unref(); say(`Opened ${reference.value}.`); } catch { say(`Couldn't open ${reference.value}.`, ERROR); } + return; + } + const argv = referenceFollowUp(reference); + if (!argv) return say(reference.kind === 'device' ? `${reference.value} is not ready (${reference.state ?? 'unknown state'}), so there is no follow-up for it.` : 'There is nothing to stage for this.'); + // The composer is the person's: staged text never replaces what they have typed. + if (this.editor.text.trim()) return say('The composer has text. Clear it (or run it) first; nothing was staged.'); + const quote = this.shellId === 'fish' ? fishQuote : posixQuote; + this.editor.insert(argv.map(arg => (/^[\w@%+=:,./-]+$/u.test(arg) ? arg : quote(arg))).join(' ')); + this.clearBlockFocus(); + this.historyViewport.latest(); + say('Staged in the composer for you to review. Press Enter to run it.'); } private clearBlockFocus(): void { @@ -2698,6 +2853,10 @@ export class TerminalApp { if (action === 'copyOutput') await this.copyRecords([record], includeStatus); else if (action === 'copyReport') this.startReport([record], 'markdown'); else if (action === 'compare') this.openCompare(record); + else if (action === 'references') this.openReferences(record); + else if (action === 'pin') this.pinCommand(record.command, record.historicalContext?.cwd ?? this.shellCwd); + else if (action === 'clipCommand' || action === 'clipOutput') this.keepClip(action === 'clipCommand' ? record.command : recordCopyText(record, false), + action === 'clipCommand' ? 'command' : 'output', record.historicalContext?.cwd, null); else if (action === 'copyBoth') await this.copyRecords([record], includeStatus, record.command); else if (action === 'copyCommand') { // The command text only: completion status never applies here. @@ -2725,6 +2884,39 @@ export class TerminalApp { case 'block': await this.runBlockAction(action.startId, action.id); break; + case 'reference': + await this.runReference(action.startId, action.refId, action.verb); + break; + case 'failure': + this.goToFailure(action.direction); + break; + case 'pinHistory': { + const entry = this.historyResults.find(entry => entry.id === action.id); + if (entry) this.pinCommand(entry.command, entry.cwd ?? this.shellCwd); + break; + } + case 'keepComposerClip': + if (!this.running && !isSlashInput(this.editor.text)) this.keepClip(this.editor.text, 'composer', this.shellCwd); + break; + case 'keepHistoryClip': { + const entry = this.historyResults.find(entry => entry.id === action.id); + if (entry) this.keepClip(entry.command, 'history', entry.cwd, entry.session ?? null); + break; + } + case 'clip': { + if (action.verb === 'remove') { this.clips.remove(action.id); this.openClips(); break; } + const clip = this.clips.get(action.id); + if (!clip || this.running) break; + if (this.editor.text.trim()) { this.output.addFrontendInteraction('/clips', 'The composer has text. Clear it before staging a clip.', INFO); break; } + const analysis = analyzePaste(clip.text); + const batch = this.batchAvailable(clip.text); + this.pastePreview = {text: clip.text, analysis, batch}; + this.pasteReview = createPasteReview(clip.text, analysis, batch); + break; + } + case 'clearClips': + this.clips.clear(); this.openClips(); + break; case 'slash': { const slash = parseSlashCommand(action.command); if (slash) await this.runSlash(action.command, slash); @@ -3787,7 +3979,8 @@ export class TerminalApp { /** What /prompt's module manager shows beside the draft: live capability status, packs, recommendations, the Claude Code bridge. */ private modulesContext(): ModulesContext { - return {status: id => this.contextEngine.status(id), packs: this.modulesListings, recommendations: this.moduleRecommendations, + const presentation = this.promptContext(); + return {conditionMet: module => moduleConditionMet(module, presentation), status: id => this.contextEngine.status(id), packs: this.modulesListings, recommendations: this.moduleRecommendations, ...(this.claudeBridgeState ? {claudeBridge: this.claudeBridgeState} : {}), now: Date.now()}; } @@ -4261,7 +4454,7 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.reportReview || this.comparePanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.reportReview || this.comparePanel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } @@ -4359,8 +4552,10 @@ export class TerminalApp { const activity = this.askActivityLine(); return framePanel(renderAsk(this.askState, columns, {presentation: this.promptConfiguration.askPresentation, height: this.dimensions().rows - 4, shell: this.shellId, ...(activity ? {activity} : {})}), columns); } + if (this.pinsPanel) return framePanel(renderPinsPanel(this.pinsPanel, columns, this.dimensions().rows - 4), columns); + if (this.batchReview) return framePanel(renderBatchReview(this.batchReview, columns, this.dimensions().rows - 4), columns); if (this.pasteReview) return framePanel(renderPasteReview(this.pasteReview, columns, this.dimensions().rows - 4), columns); - if (this.queuePanel) return framePanel(renderQueuePanel(this.queuePanel, this.queueState, this.running?.command, columns, this.dimensions().rows - 4), columns); + if (this.queuePanel) return framePanel(renderQueuePanel(this.queuePanel, this.queueState, this.running?.command, columns, this.dimensions().rows - 4, this.queueConditionsAvailable()), columns); if (this.watchPanel) return framePanel(renderWatchPanel(this.watchPanel, this.watches.active(), columns, Date.now(), this.dimensions().rows - 4), columns); if (this.doctorPanel) return framePanel(renderDoctorPanel(this.doctorPanel, columns, Date.now(), !this.decorativeMotionAllowed()), columns); if (this.cursorPanel) return framePanel(this.cursorPanelRows(this.cursorPanel, columns, this.cursorEnv(this.cursorPanel.draft, this.promptConfiguration)), columns); @@ -6864,10 +7059,192 @@ export class TerminalApp { // The agent shelf shares this chrome slot (and the screen plan's rows): hidden at rest, never a permanent row. const shelf = this.shelfRow(columns); const now = Date.now(); - const rows = [...this.clipboardRows(columns), ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)]; + const suggestion = this.clipboardChangeSuggested && !this.running && this.clipboardChangeWatch?.enabled + ? [truncateAnsi(`${SUBTLE}Clipboard changed · /ps reads it for review${RESET}`, columns)] : []; + const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)]; return shelf ? [...rows, shelf] : rows; } + /** The shell grammar this session's shell speaks, for splitting a paste into commands (none for other shells). */ + private batchShell(): BatchShell | undefined { + return this.shellId === 'zsh' || this.shellId === 'bash' || this.shellId === 'fish' ? this.shellId : undefined; + } + + /** A paste can be offered as a queue of commands: the queue is available and the shell's grammar says it splits cleanly. */ + private batchAvailable(text: string): boolean { + const shell = this.batchShell(); + return Boolean(shell && this.queueAvailable() && text.includes('\n') && createBatchReview(text, shell, 1)); + } + + /** + * B: split the paste, have the real shell parse-check every command on its own (it never runs them), and only then + * show the review. A boundary the shell does not accept means the paste stays one block of text. + */ + private async openBatchReview(text: string): Promise { + const shell = this.batchShell(); + const review = shell ? createBatchReview(text, shell, Math.max(0, QUEUE_LIMIT - this.queueState.entries.length)) : undefined; + if (!shell || !review) return; + const check = await verifyCommands(batchTexts(review), shell); + if (this.stopped || this.pastePreview?.text !== text) return; + if (check.ok) this.batchReview = review; + else { + this.output.addFrontendInteraction('/ps', check.reason, INFO); + this.pastePreview = {...this.pastePreview, batch: false}; + if (this.pasteReview) this.pasteReview.batch = false; + } + this.render(); + } + + /** /ps: read the clipboard now, once, and bring it to paste review. Nothing is kept, inserted or run by reading it. */ + private async pasteClipboard(command: string): Promise { + if (this.running) { this.output.addFrontendInteraction(command, 'A command is running: /ps pastes at the prompt. Use your terminal\'s paste to send text to the program.', INFO); return; } + let text: string; + try { text = await this.readClipboardText(); } + catch (error) { + this.output.addFrontendInteraction(command, error instanceof ClipboardUnavailableError ? error.message : `Could not read the clipboard: ${error instanceof Error ? error.message : String(error)}`, ERROR); + return; + } + if (this.stopped) return; + if (!text.trim()) { this.output.addFrontendInteraction(command, 'The clipboard is empty.', INFO); return; } + this.clipboardChangeSuggested = false; + const analysis = analyzePaste(text); + const batch = this.batchAvailable(text); + this.pastePreview = {text, analysis, batch}; + // An explicit /ps always shows the review first, whatever Paste Preview is set to. + this.pasteReview = createPasteReview(text, analysis, batch); + } + + /** Explicit staging only: never reads the clipboard, writes files or submits commands. */ + private keepClip(text: string, source: Clip['source'], cwd?: string, session: string | null | undefined = this.sessionId): void { + const added = this.clips.add(text, {source, cwd, ...(session ? {session} : {})}); + this.output.addFrontendInteraction('/clips', added.ok + ? `Kept clip ${added.clip.id} in memory for this frontend. /clips reviews it before insertion.${added.dropped ? ` Discarded ${added.dropped} oldest clip(s) to stay within the memory limit.` : ''}` + : added.reason, added.ok ? INFO : ERROR); + } + + private openClips(): void { + const clips = this.clips.list(); + if (!clips.length) { this.output.addFrontendInteraction('/clips', 'No clips kept. Use C in Paste Review, a block’s Actions, or Keep composer draft as clip in the palette. Clips disappear when this frontend exits.', INFO); return; } + const items: PaletteItem[] = clips.flatMap(clip => { + const detail = `${clip.source} · ${displaySafe(clip.text.replace(/[\r\n]/gu, ' ')).slice(0, 160)}`; + return [{id: `clip:${clip.id}:review`, label: `Review clip ${clip.id}`, detail, category: 'Transcript', action: {kind: 'clip', id: clip.id, verb: 'review'}}, + {id: `clip:${clip.id}:remove`, label: `Discard clip ${clip.id}`, detail, category: 'Transcript', action: {kind: 'clip', id: clip.id, verb: 'remove'}}]; + }); + items.push({id: 'clips:clear', label: 'Discard all session clips', detail: 'Remove all staged text from this frontend’s memory', category: 'Transcript', action: {kind: 'clearClips'}}); + this.paletteState = createPalette(items); + } + + private configureClipboardChanges(enabled: boolean): void { + if (!enabled) { + this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; + this.output.addFrontendInteraction('/clips', 'Clipboard-change suggestions off for this frontend.', INFO); + return; + } + if (!this.clipboardChangeCounter) { + this.output.addFrontendInteraction('/clips', 'Clipboard-change metadata is unavailable on this platform. /ps still reads text only when requested.', INFO); + return; + } + this.clipboardChangeWatch ??= new ClipboardChangeWatch({counter: this.clipboardChangeCounter, + idle: () => !this.stopped && !this.running && !this.externalPassthrough && !this.startupPending && !this.settingsPanelActive && !this.editor.text, + changed: () => { this.clipboardChangeSuggested = true; this.render(); }, + failed: () => { + this.clipboardChangeSuggested = false; + this.output.addFrontendInteraction('/clips', 'Clipboard-change metadata became unavailable; suggestions disabled.', INFO); this.render(); + }}); + this.clipboardChangeSuggested = false; + this.clipboardChangeWatch.start(); + this.output.addFrontendInteraction('/clips', 'Clipboard-change suggestions on for this frontend. Only a native change counter is read while idle; /ps explicitly reads text. /clips suggestions off stops them.', INFO); + } + + // ---------------------------------------------------------------- pins and recipes + + private openPins(options: {naming?: string[]} = {}): void { + const loaded = loadPins(); + if (!loaded.ok) { this.output.addFrontendInteraction('/pins', loaded.reason, ERROR); return; } + this.pinsPanel = createPinsPanel(loaded.value, this.shellCwd, {composerText: this.editor.text, canEdit: Boolean(configuredEditor(process.env)), ...(options.naming ? {naming: options.naming} : {})}); + } + + /** Pin from a block: kept as written, global until you scope it in /pins; nothing about its output is kept. */ + private pinCommand(command: string, cwd: string): void { + const result = addPin({command, cwd}); + this.output.addFrontendInteraction('/pins', result.ok ? `Pinned. /pins lists what you kept; it is stored in ${pinsPath()}.` : result.reason, result.ok ? INFO : ERROR); + } + + private refreshPins(panel: PinsPanelState, message?: {text: string; error: boolean}): void { + const loaded = loadPins(); + if (loaded.ok) panel.data = loaded.value as PinData; + panel.composerText = this.editor.text; + panel.message = loaded.ok ? message : {text: loaded.reason, error: true}; + } + + private async handlePinsKey(key: Key): Promise { + const panel = this.pinsPanel!; + const action = pinsPanelKey(panel, key); + const done = (result: {ok: true} | {ok: false; reason: string}, text: string) => this.refreshPins(panel, result.ok ? {text, error: false} : {text: result.reason, error: true}); + if (action?.kind === 'close') { this.pinsPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'stage') { + // The composer is the person's: a pin never replaces what they have typed. + if (this.editor.text.trim()) panel.message = {text: 'The composer has text. Clear it (or run it) first; nothing was staged.', error: true}; + else { + if (action.command.includes('\n')) this.editor.insertPaste(action.command); else this.editor.insert(action.command); + this.pinsPanel = undefined; this.returnFromPanel(); + this.output.addFrontendInteraction('/pins', 'Staged in the composer for you to review. Press Enter to run it.', INFO); + } + } else if (action?.kind === 'use') await this.useRecipe(panel, action.recipe, action.values); + else if (action?.kind === 'rename') done(changeItem(action.id, {name: action.name}), 'Renamed.'); + else if (action?.kind === 'scope') done(changeItem(action.id, {scope: action.scope}), action.scope === 'directory' ? 'Kept for this folder and the folders inside it.' : 'Kept for every folder.'); + else if (action?.kind === 'delete') done(removeItem(action.id), 'Removed.'); + else if (action?.kind === 'pinComposer') done(addPin({command: this.editor.text, cwd: this.shellCwd}), 'Pinned what is in the composer. It is not run.'); + else if (action?.kind === 'saveRecipe') { + const quoted = action.steps.map(step => quotedPlaceholder(step)).find(Boolean); + if (quoted) { panel.message = {text: `{{${quoted}}} is inside quotes. Put placeholders outside quotes: NMSh quotes the value for you.`, error: true}; panel.mode = 'name'; panel.naming = {steps: action.steps}; panel.input = action.name; } + else done(addRecipe({name: action.name, steps: action.steps, cwd: this.shellCwd}), `Saved the recipe “${action.name}”. Enter on it asks for its values and shows the commands first.`); + } else if (action?.kind === 'edit') { await this.editRecipe(panel, action.recipe); } + this.render(); + } + + /** Enter on a recipe: its values are quoted into the steps, and the expanded commands are reviewed before anything is queued. */ + private async useRecipe(panel: PinsPanelState, recipe: Recipe, values: Record): Promise { + if (!this.queueAvailable()) { panel.message = {text: 'The command queue is off or unavailable here, so a recipe cannot be queued.', error: true}; return; } + const expanded = expandSteps(recipe.steps, values, this.shellId === 'fish' ? fishQuote : posixQuote); + if (!expanded.ok) { panel.message = {text: expanded.reason, error: true}; return; } + const shell = this.batchShell(); + const check = shell ? await verifyCommands(expanded.steps, shell) : {ok: false as const, reason: 'This shell cannot check recipes.'}; + if (this.pinsPanel !== panel) return; + if (!check.ok) { panel.message = {text: check.reason.replace('so the paste was not split', 'so the recipe was not queued'), error: true}; return; } + const where = this.shellCwd; + const note = recipe.scope === 'directory' && recipe.cwd && recipe.cwd !== where ? `Saved for ${recipe.cwd}; this shell is in ${where}.` : `Runs in this shell, from ${where}.`; + this.pinsPanel = undefined; + this.batchReview = batchFromCommands(expanded.steps, Math.max(0, QUEUE_LIMIT - this.queueState.entries.length), {title: `Recipe: ${recipe.name}`, note}); + } + + /** e on a recipe: its commands in your editor (a private temporary file); saved only if they still split into whole commands. */ + private async editRecipe(panel: PinsPanelState, recipe: Recipe): Promise { + const editor = configuredEditor(process.env); + const shell = this.batchShell(); + if (!editor || !shell || !this.hostTerminalFree()) return; + const directory = mkdtempSync(join(tmpdir(), 'nmsh-recipe-')); + const file = join(directory, 'recipe.sh'); + try { + writeFileSync(file, recipe.steps.join('\n'), {mode: 0o600}); + const code = await this.withHostTerminal(() => new Promise(finish => { + const child = spawn(editor[0]!, [...editor.slice(1), file], {stdio: 'inherit'}); + child.once('error', () => finish(null)); + child.once('close', exit => finish(exit)); + })); + if (code !== 0) { this.refreshPins(panel, {text: 'The editor did not finish; the recipe is unchanged.', error: true}); return; } + const split = splitBatch(readFileSync(file, 'utf8'), shell); + if (!split.ok) { this.refreshPins(panel, {text: `Not saved: ${split.reason} The recipe is unchanged.`, error: true}); return; } + const steps = split.commands.map(command => command.text); + const quoted = steps.map(step => quotedPlaceholder(step)).find(Boolean); + const problem = quoted ? `{{${quoted}}} is inside quotes; put placeholders outside quotes.` : steps.map(step => keepProblem(step)).find(Boolean); + if (problem || !steps.length) { this.refreshPins(panel, {text: `Not saved: ${problem ?? 'a recipe needs at least one command.'}`, error: true}); return; } + const saved = changeItem(recipe.id, {steps}); + this.refreshPins(panel, saved.ok ? {text: `Saved ${steps.length} command${steps.length === 1 ? '' : 's'}. A queued run keeps the commands it was reviewed with.`, error: false} : {text: saved.reason, error: true}); + } catch { this.refreshPins(panel, {text: 'The editor could not be started; the recipe is unchanged.', error: true}); } + finally { rmSync(directory, {recursive: true, force: true}); } + } + /** Whether the compact preview fits above the composer on this screen (the same rule notices follow). */ private pasteCompactFits(): boolean { const {columns, rows} = this.dimensions(); @@ -6907,7 +7284,7 @@ export class TerminalApp { if (hidden > 0 && summary.length) summary[summary.length - 1] += `${SUBTLE} · +${hidden} more${RESET}`; for (const item of summary) rows.push(` ${item}`); if (analysis.commands.some(command => ['text', 'unknown'].includes(primaryKind(command.kinds)))) rows.push(`${SUBTLE}${PASTE_EXACT_NOTE}${RESET}`); - rows.push(`${SUBTLE}Enter insert · R review · Esc cancel${RESET}`); + rows.push(`${SUBTLE}Enter insert · R review${preview.batch ? ' · B queue as commands' : ''} · Esc cancel · C keep clip${RESET}`); return rows.map(row => truncateAnsi(row, columns)); } @@ -7306,7 +7683,7 @@ export class TerminalApp { nmsh: {configurationLoaded: true, sessionMode: this.sessionMode, ...(serviceReachable !== undefined ? {serviceReachable} : {}), transcriptDirectory: join(nmshConfigDirectory(), 'sessions'), shell: {id: this.shellId, label: shellAdapter(this.shellId).label, ...(resolveCommand(this.shellId) ? {executable: resolveCommand(this.shellId)!} : {}), promptSeen: !this.startupPending}, host: {name: this.host.name, truecolor: colorLevel() === 'truecolor', keyboard: this.host.capabilities.enhancedKeyboard || this.host.capabilities.kittyKeyboard}}, - ...(git ? {git} : {}), ...(root ? {repoRoot: root} : {}), providers: providerRows, + ...(git ? {git} : {}), ...(root ? {repoRoot: root} : {}), providers: providerRows, contextEngine: {...this.contextEngine.stats}, understanding: {mode: configuration.localUnderstanding.mode, ...(model ? {model: {label: model.label, runtime: model.runtime, ...(model.path ? {path: model.path} : {}), owned: Boolean(model.owned)}} : {}), runtimeAvailable, ...(model ? {state: stateLabel(this.understanding.status, configuration.localUnderstanding)} : {})}, agents: this.agents.harnesses().map(item => ({label: item.harness.name, installed: Boolean(item.executable)})), @@ -7678,7 +8055,10 @@ export class TerminalApp { private readonly cursorPresenter = new CursorPresenter(() => resolveCursorSettings(this.promptConfiguration.cursor, {...contextFor(this.promptConfiguration), chrome: UI_COLORS.accent}), () => { if (!this.stopped) this.paintPresentation(Date.now()); }); private caretCause: 'typing' | 'jump' = 'jump'; /** A paste waiting for Insert / Review / Cancel (presentation and classification only; the text is never changed). */ - private pastePreview?: {text: string; analysis: PasteAnalysis}; + private pastePreview?: {text: string; analysis: PasteAnalysis; /** Splits into separate commands that can be queued. */ batch: boolean}; + private batchReview?: BatchReviewState; + /** The one place the clipboard is read (only /ps calls it). */ + private readClipboardText: () => Promise = () => readClipboard(); private pasteReview?: PasteReviewState; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, @@ -9313,6 +9693,11 @@ export class TerminalApp { } /** Queueing works when the setting is on and whatever owns the shell runs a queue (an older service does not). */ + /** The session's service understands per-entry conditions and approval (older ones would ignore them, so they are never offered). */ + private queueConditionsAvailable(): boolean { + return this.queueAvailable() && this.session.features.has('queue-conditions'); + } + private queueAvailable(): boolean { return this.promptConfiguration.commandQueue !== false && this.session.features.has('queue'); } @@ -9657,6 +10042,8 @@ export class TerminalApp { if (this.stopped) return; this.hostTitle.end(); this.stopped = true; + this.clips.clear(); + this.clipboardChangeWatch?.stop(); this.clipboardChangeSuggested = false; this.contextEngine.dispose(); if (this.contextRefreshTimer) { clearTimeout(this.contextRefreshTimer); this.contextRefreshTimer = undefined; } if (this.noticeExpiry) { clearTimeout(this.noticeExpiry); this.noticeExpiry = undefined; } diff --git a/src/appearance/themeImporters.ts b/src/appearance/themeImporters.ts index e2e4356e..6f076ab7 100644 --- a/src/appearance/themeImporters.ts +++ b/src/appearance/themeImporters.ts @@ -401,6 +401,80 @@ export function importOhMyPosh(data: Record, fileName: string, return finish('oh-my-posh', theme, mapping, [...warnings], name); } +// ---- Starship ---------------------------------------------------------------- + +const STARSHIP_ROLE: Record = {directory: 'cwd', git_branch: 'gitBranch', nodejs: 'node', golang: 'go', python: 'python', + docker_context: 'docker', kubernetes: 'kubernetes', package: 'project'}; +const STARSHIP_MODIFIERS = new Set(['bold', 'italic', 'underline', 'dimmed', 'inverted', 'blink', 'hidden', 'strikethrough', 'none', 'prev_fg', 'prev_bg']); + +/** + * Starship config (TOML, already parsed as data). Only static colors are read: hex values in module `style` strings, + * the active `[palettes.*]` table that `palette = "name"` selects, and the `[…](style)` parts of the character + * module's success/error symbols. Format strings, `$variables`, conditions, `[custom.*]` commands, `command`/`when` + * keys, ANSI color names and 0–255 numbers are never evaluated or invented into colors; they become warnings. Which + * modules the file enables is disclosed, not imported: module visibility is chosen in NMSh's own Modules screen. + */ +export function importStarship(data: Record, fileName: string, base: CustomTheme): ImportOutcome { + const warnings = new Set(['Starship modules, format strings and commands are not imported; only static colors become an NMSh Native theme.']); + const palettes = isRecord(data.palettes) ? data.palettes : {}; + const selected = typeof data.palette === 'string' ? data.palette : undefined; + const palette = selected && isRecord(palettes[selected]) ? palettes[selected] as Record : {}; + if (selected && !isRecord(palettes[selected])) warnings.add(`The palette "${sanitizeName(selected)}" is not defined in the file, so its names were not resolved.`); + const unresolved = new Set(); + const color = (style: unknown): string | undefined => { + if (typeof style !== 'string' || style.length > 200) return undefined; + let found: {fg?: string; bg?: string} = {}; + for (const token of style.trim().split(/\s+/u).slice(0, 12)) { + const side = token.startsWith('bg:') ? 'bg' : 'fg'; + const name = token.replace(/^(?:fg|bg):/u, ''); + if (!name || STARSHIP_MODIFIERS.has(name)) continue; + const literal = hex(name) ?? (typeof palette[name] === 'string' ? hex(palette[name]) : undefined); + if (literal) found = {...found, [side]: literal}; + else unresolved.add(sanitizeName(name)); + } + return found.bg ?? found.fg; + }; + const prompt: Partial> = {}; + const mapping: RoleMapping[] = []; + let accent: string | undefined; + const modules: string[] = []; + let custom = 0; + for (const [key, value] of Object.entries(data).slice(0, 200)) { + if (key === 'custom') { custom = isRecord(value) ? Object.keys(value).length : 1; continue; } + if (!isRecord(value)) continue; + if (key !== 'palettes') modules.push(sanitizeName(key)); + const role = STARSHIP_ROLE[key]; + const fill = color(value.style); + if (fill) accent ??= fill; + if (role && fill && !prompt[role]) { prompt[role] = fill; mapping.push({role: ROLE_LABELS[role], from: `[${sanitizeName(key)}] style`}); } + if (key === 'character') { + const symbolColor = (symbol: unknown) => typeof symbol === 'string' ? color(/\]\(([^)]{1,120})\)/u.exec(symbol)?.[1]) : undefined; + const success = symbolColor(value.success_symbol), failure = symbolColor(value.error_symbol); + if (success && !prompt.success) { prompt.success = success; mapping.push({role: 'Success', from: '[character] success_symbol'}); } + if (failure && !prompt.failure) { prompt.failure = failure; mapping.push({role: 'Failure', from: '[character] error_symbol'}); } + } + } + if (custom) warnings.add(`${custom} [custom] module${custom === 1 ? '' : 's'} ignored: their commands are never run.`); + if (unresolved.size) warnings.add(`Colors that are not static hex values (names, ANSI colors, numbers) were not imported: ${[...unresolved].slice(0, 6).join(', ')}${unresolved.size > 6 ? '…' : ''}.`); + if (!Object.keys(prompt).length && !accent) return {errors: ['No static Starship style colors were found to build an NMSh theme from.']}; + const kept = PROMPT_THEME_ROLES.filter(role => !prompt[role]); + if (kept.length) warnings.add(`No Starship source for ${kept.map(role => ROLE_LABELS[role]).join(', ')}; ${kept.length === 1 ? 'it keeps' : 'they keep'} ${base.name} colors.`); + if (modules.length) warnings.add(`The file configures ${modules.length} module${modules.length === 1 ? '' : 's'} (${modules.slice(0, 8).join(', ')}${modules.length > 8 ? ', …' : ''}). Which modules NMSh shows is set in /modules; Starship itself keeps working as a prompt provider.`); + warnings.add('Starship does not define a terminal background; text tiers assume a dark terminal (change it in the editor).'); + const name = sanitizeName(fileStem(fileName).replace(/^starship$/u, 'Starship')) || 'Starship theme'; + const theme: CustomTheme = {...structuredClone(base), name, basedOn: 'Starship import', dark: true, + prompt: {...base.prompt, ...prompt}, ui: {...base.ui, ...(accent ? {accent} : {}), + ...(prompt.success ? {success: prompt.success} : {}), ...(prompt.failure ? {failure: prompt.failure} : {})}}; + delete theme.terminal; + if (accent) mapping.push({role: 'Accent', from: 'first colored module'}); + return finish('starship', theme, mapping, [...warnings], name); +} + +function isStarship(data: unknown): data is Record { + return isRecord(data) && !isRecord(data.colors) && (isRecord(data.palettes) || typeof data.palette === 'string' || typeof data.add_newline === 'boolean' + || Object.keys(STARSHIP_ROLE).some(key => isRecord(data[key])) || isRecord(data.character)); +} + // ---- Dispatcher ------------------------------------------------------------ function fileStem(fileName: string): string { @@ -465,12 +539,13 @@ export function importThemeSource(text: string, fileName: string, defaults: Reco if (isScheme(json) && (want('base16') || want('base24'))) return schemeImport(json, text, defaults, format === 'auto' ? undefined : format); return {errors: ['This JSON is not an NMSh theme, Oh My Posh config, Windows Terminal scheme or Base16/Base24 scheme.']}; } - if (extension === 'toml' || format === 'wezterm' || (format === 'oh-my-posh' && /^\s*\[/mu.test(text))) { + if (extension === 'toml' || format === 'wezterm' || ((format === 'oh-my-posh' || format === 'starship') && /^\s*\[/mu.test(text))) { let data: unknown; try { data = parseToml(text); } catch { return {errors: [looksLikeLua(text, fileName) ? WEZTERM_LUA_GUIDANCE : 'Not valid TOML.']}; } if (isOhMyPosh(data) && want('oh-my-posh')) return importOhMyPosh(data, fileName, base); if (isRecord(data) && isRecord(data.colors) && want('wezterm')) return importWezTermToml(data, fileName, defaults); - return {errors: ['This TOML is not an Oh My Posh config or a WezTerm color scheme.']}; + if (isStarship(data) && want('starship')) return importStarship(data, fileName, base); + return {errors: ['This TOML is not an Oh My Posh config, a Starship config or a WezTerm color scheme.']}; } if (format === 'auto' && looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; // Line-oriented terminal configs, detected by their own color syntax before YAML. @@ -494,5 +569,5 @@ function windowsTerminalPalette(json: Record): TerminalPalette ...(hex(json.cursorColor) ? {cursor: hex(json.cursorColor)!} : {})}; } -export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm']; +export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'starship', 'kitty', 'ghostty', 'iterm2', 'wezterm']; export const importFormatLabel = (format: ImportFormatChoice): string => format === 'auto' ? 'Detect automatically' : THEME_SOURCE_LABELS[format]; diff --git a/src/appearance/themeLibrary.ts b/src/appearance/themeLibrary.ts index 53a0bf17..cd42c21b 100644 --- a/src/appearance/themeLibrary.ts +++ b/src/appearance/themeLibrary.ts @@ -20,10 +20,10 @@ export const THEME_LIBRARY_LIMIT = 64; /** The asset a pre-library `customTheme` migrates into. */ export const LEGACY_THEME_ID = 'legacy-custom'; -export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; +export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'starship', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; export type ThemeSourceKind = typeof THEME_SOURCE_KINDS[number]; export const THEME_SOURCE_LABELS: Record = { - nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', + nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', starship: 'Starship', kitty: 'Kitty', ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', }; diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 53fed951..c4c79b7b 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -69,7 +69,7 @@ export const CONCEPTS: readonly Concept[] = [ description: 'Appearance covers the Native theme, vibrance, UI chrome colors and terminal opacity/blur where the terminal supports it.'}, {id: 'themeStudio', label: 'Theme Studio', support: 'actionable', open: '/theme', covers: ['/theme'], aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], - description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, + description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Starship, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, {id: 'uiChrome', label: 'UI chrome', support: 'actionable', open: '/chrome', covers: ['/chrome'], aliases: ['ui chrome', 'chrome', 'frames', 'panel colors', 'tab colors'], description: 'UI chrome is NMSh\'s frames, rules, tabs, selection and accents; it follows the theme or a custom preset. Not Chroma (animated color treatment).'}, @@ -241,6 +241,12 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'compare', label: 'Compare output', support: 'actionable', open: '/compare', covers: ['/compare'], aliases: ['compare output', 'compare outputs', 'diff output', 'compare runs', 'what changed between runs', 'compare with previous run'], description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'}, + {id: 'paste-batch', label: 'Paste and queue a batch', support: 'actionable', open: '/ps', covers: ['/ps', '/clips'], + aliases: ['paste commands', 'paste batch', 'paste from clipboard', 'queue pasted commands', 'review pasted commands', 'ps command'], + description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch before you queue it; quotes, loops and heredocs stay whole. C deliberately keeps a clip in memory; /clips reviews it before insertion. Clips disappear when this frontend exits. Nothing executes automatically.'}, + {id: 'pins', label: 'Pins and recipes', support: 'actionable', open: '/pins', covers: ['/pins'], + aliases: ['pin command', 'pins', 'recipes', 'save queue as recipe', 'reuse command', 'saved commands'], + description: '/pins lists commands you pinned (Actions → Pin command) and recipes saved from the queue (s in /queue). Enter on a pin puts it in the composer; Enter on a recipe asks for its {{placeholders}}, shows the expanded commands, and queues them only when you confirm. Stored in pins.json; secrets are refused.'}, {id: 'homebrew', label: 'Homebrew packages', support: 'actionable', open: '/tools', covers: [], aliases: ['homebrew', 'brew packages', 'brew', 'formulae', 'casks', 'package manager'], description: 'Ask can inspect Homebrew: installed packages, versions, search, info, dependencies and what is outdated, and install, upgrade or uninstall after showing the exact brew command and your Yes. Ownership is only claimed from Homebrew\'s own evidence.'}, @@ -346,14 +352,14 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, - {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'compare', 'transcripts']}, + {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'compare', 'transcripts', 'pins']}, {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you; /worktrees and /github show its worktrees, pull requests and issues.', concepts: ['worktrees', 'githubWorkspace'], notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers run as NMSh-managed background tasks: a live status row, detected URLs to open, and "stop the dev server" to end them.']}, - {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview']}, + {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview', 'paste-batch']}, {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, diff --git a/src/clipboard/ClipStore.ts b/src/clipboard/ClipStore.ts new file mode 100644 index 00000000..88451844 --- /dev/null +++ b/src/clipboard/ClipStore.ts @@ -0,0 +1,41 @@ +/** Deliberately kept text, owned by this frontend only. Never persisted or executed. */ +export interface Clip { + readonly id: number; + readonly text: string; + readonly source: 'composer' | 'history' | 'command' | 'output' | 'paste'; + readonly cwd?: string; + readonly session?: string; +} + +export const CLIP_LIMIT = 12; +export const CLIP_MAX_BYTES = 1024 * 1024; +type ClipContext = Omit; + +export class ClipStore { + private clips: Clip[] = []; + private nextId = 1; + + list(): readonly Clip[] { return this.clips.map(clip => ({...clip})); } + get(id: number): Clip | undefined { const clip = this.clips.find(clip => clip.id === id); return clip ? {...clip} : undefined; } + remove(id: number): boolean { + const before = this.clips.length; + this.clips = this.clips.filter(clip => clip.id !== id); + return this.clips.length !== before; + } + clear(): void { this.clips = []; } + + add(text: string, context: ClipContext): {ok: true; clip: Clip; dropped: number} | {ok: false; reason: string} { + if (!text.trim()) return {ok: false, reason: 'There is no text to keep.'}; + const bytes = Buffer.byteLength(text, 'utf8'); + if (bytes > CLIP_MAX_BYTES) return {ok: false, reason: 'A clip cannot exceed 1 MiB.'}; + const clip: Clip = {...context, id: this.nextId++, text}; + this.clips.unshift(clip); + let total = this.clips.reduce((sum, item) => sum + Buffer.byteLength(item.text, 'utf8'), 0); + let dropped = 0; + while (this.clips.length > CLIP_LIMIT || total > CLIP_MAX_BYTES) { + total -= Buffer.byteLength(this.clips.pop()!.text, 'utf8'); + dropped++; + } + return {ok: true, clip: {...clip}, dropped}; + } +} diff --git a/src/clipboard/ClipboardChanges.ts b/src/clipboard/ClipboardChanges.ts new file mode 100644 index 00000000..cb5970b7 --- /dev/null +++ b/src/clipboard/ClipboardChanges.ts @@ -0,0 +1,80 @@ +import {execFile} from 'node:child_process'; + +/** Fixed native metadata query: no string, image, type or clipboard item is requested. */ +const COUNTER_SCRIPT = "ObjC.import('AppKit'); $.NSPasteboard.generalPasteboard.changeCount"; + +export function parseClipboardCounter(raw: string): number { + const text = raw.trim(); + const count = Number(text); + if (raw.length > 64 || !/^(?:0|[1-9]\d{0,15})$/u.test(text) || !Number.isSafeInteger(count)) + throw new Error('Clipboard change metadata is unavailable.'); + return count; +} + +/** Conventional Linux clipboard tools expose text, not a reliable change counter. Fail closed there. */ +export function clipboardCounter(platform = process.platform): (() => Promise) | undefined { + if (platform !== 'darwin') return undefined; + return () => new Promise((resolve, reject) => { + execFile('/usr/bin/osascript', ['-l', 'JavaScript', '-e', COUNTER_SCRIPT], {encoding: 'utf8', timeout: 1500, maxBuffer: 64}, (error, stdout) => { + if (error) { reject(new Error('Clipboard change metadata is unavailable.')); return; } + try { resolve(parseClipboardCounter(stdout)); } catch (error) { reject(error); } + }); + }); +} + +interface WatchOptions { + counter: () => Promise; + idle: () => boolean; + changed: () => void; + failed: () => void; + schedule?: (callback: () => void) => () => void; +} + +/** Opt-in, frontend-only suggestions. The only dependency is a metadata counter, never a text reader. */ +export class ClipboardChangeWatch { + private active = false; + private generation = 0; + private previous?: number; + private cancel?: () => void; + + constructor(private readonly options: WatchOptions) {} + get enabled(): boolean { return this.active; } + + start(): void { + this.stop(); + this.active = true; + this.previous = undefined; + void this.poll(this.generation); + } + + stop(): void { + this.active = false; + this.generation++; + this.cancel?.(); this.cancel = undefined; + this.previous = undefined; + } + + private async poll(generation: number): Promise { + if (!this.active || generation !== this.generation) return; + this.cancel = undefined; + try { + if (this.options.idle()) { + const count = await this.options.counter(); + if (!this.active || generation !== this.generation) return; + if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid change counter'); + const changed = this.previous !== undefined && count !== this.previous; + this.previous = count; + if (changed) this.options.changed(); + } + } catch { + if (!this.active || generation !== this.generation) return; + this.stop(); this.options.failed(); return; + } + const schedule = this.options.schedule ?? (callback => { + const timer = setTimeout(callback, 5000); + timer.unref(); + return () => clearTimeout(timer); + }); + this.cancel = schedule(() => { void this.poll(generation); }); + } +} diff --git a/src/clipboard/clipboard.ts b/src/clipboard/clipboard.ts index 4248dac8..889dbbd4 100644 --- a/src/clipboard/clipboard.ts +++ b/src/clipboard/clipboard.ts @@ -99,3 +99,56 @@ export async function writeClipboard(text: string, options: ClipboardEnvironment child.stdin.end(); }); } + +/** The tool that prints the clipboard: only ever started by an explicit /ps, never in the background. */ +export function selectClipboardReadBackend(options: ClipboardEnvironment = {}): ClipboardBackend | undefined { + const {platform = process.platform, env = process.env, resolve = (name: string) => resolveCommand(name)} = options; + if (platform === 'darwin') return {command: 'pbpaste', args: []}; + if (platform !== 'linux') return undefined; + const candidates: [string, string[]][] = []; + if (env.WAYLAND_DISPLAY) candidates.push(['wl-paste', ['--no-newline']]); + if (env.DISPLAY) candidates.push(['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]); + for (const [name, args] of candidates) { + const command = resolve(name); + if (command) return {command, args}; + } + return undefined; +} + +/** + * The clipboard's text, read once on request. Bounded like a write (1 MiB, 3 s); a tool that fails, hangs or prints + * too much is an error with a reason, and nothing it printed is kept. + */ +export async function readClipboard(options: ClipboardEnvironment = {}): Promise { + const backend = selectClipboardReadBackend(options); + if (!backend) { + const platform = options.platform ?? process.platform; + throw new ClipboardUnavailableError(platform === 'linux' ? 'install wl-paste (Wayland) or xclip/xsel (X11)' : `unsupported platform ${platform}`); + } + const timeoutMs = options.timeoutMs ?? CLIPBOARD_TIMEOUT_MS; + return await new Promise((resolve, reject) => { + const child = spawn(backend.command, backend.args, {detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'ignore']}); + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const killTree = () => { + if (child.pid && process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already ended */ } + } else child.kill('SIGKILL'); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { killTree(); reject(error); } else resolve(Buffer.concat(chunks).toString('utf8')); + }; + const timer = setTimeout(() => finish(new Error(`${backend.command} timed out`)), timeoutMs); + child.once('error', finish); + child.stdout.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > CLIPBOARD_MAX_BYTES) { chunks.length = 0; finish(new ClipboardUnavailableError(`the clipboard holds more than ${CLIPBOARD_MAX_BYTES / 1024 / 1024} MiB`)); return; } + chunks.push(chunk); + }); + child.once('close', code => { if (code !== 0) finish(new Error(`${backend.command} exited with code ${code}`)); else finish(); }); + }); +} diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 9f9f612f..c3798ab7 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -40,6 +40,9 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status'}, {name: '/copy settings', insertion: '/copy settings', description: 'Quick Copy or Interactive Picker, and whether copies include completion status'}, {name: '/compare', insertion: '/compare', description: 'Compare two outputs: /compare (latest with its previous run), /compare N, /compare N M (numbers as /copy)'}, + {name: '/pins', insertion: '/pins', description: 'Commands and recipes you kept: stage a pin, or review and queue a recipe (nothing runs by itself)'}, + {name: '/ps', insertion: '/ps', description: 'Paste from the clipboard, reviewed first: read when you run it, never in the background'}, + {name: '/clips', insertion: '/clips', description: 'Text deliberately kept in memory for this frontend; review before inserting, remove or clear'}, {name: '/cp', insertion: '/cp', description: 'Same as /copy (every form: /cp 2, /cp -3, /cp ui, /cp latest, --status)'}, {name: '/appearance', insertion: '/appearance', description: 'Configure terminal appearance'}, {name: '/motion', insertion: '/motion', description: 'Motion: context transitions, command launch, completion highlight and effects (same as /appearance → Motion)'}, @@ -140,6 +143,11 @@ export type ParsedSlashCommand = /** `args` is everything after /copy, parsed by parseCopyArgs (so a mistake gets a precise answer). */ | {kind: 'copy'; args: string} | {kind: 'copySettings'} + /** /ps: read the clipboard now and bring it to paste review. */ + | {kind: 'pasteClipboard'} + | {kind: 'clips'; suggestions?: boolean} + /** /pins: the pins and recipes panel. */ + | {kind: 'pins'} /** /compare [A [B]]: outputs by /copy number; A defaults to the latest, B to A's previous run (or a choice). */ | {kind: 'compare'; first?: number; second?: number; error?: string} | {kind: 'appearance'} @@ -222,6 +230,10 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (words.length > 2 || words.some(word => !/^[1-9]\d{0,2}$/u.test(word))) return {kind: 'compare', error: 'Use /compare, /compare N or /compare N M (numbers as /copy shows them, 1 is the latest).'}; return {kind: 'compare', ...(words[0] ? {first: Number(words[0])} : {}), ...(words[1] ? {second: Number(words[1])} : {})}; } + if (/^\/ps\s*$/u.test(input)) return {kind: 'pasteClipboard'}; + const clips = /^\/clips(?:\s+suggestions\s+(on|off))?\s*$/u.exec(input); + if (clips) return {kind: 'clips', ...(clips[1] ? {suggestions: clips[1] === 'on'} : {})}; + if (/^\/pins\s*$/u.test(input)) return {kind: 'pins'}; // /cp is /copy: one parser, one handler, every form. if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'}; const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input); diff --git a/src/context/capabilities/runtimes.ts b/src/context/capabilities/runtimes.ts index 45726e87..2c969ea1 100644 --- a/src/context/capabilities/runtimes.ts +++ b/src/context/capabilities/runtimes.ts @@ -272,4 +272,37 @@ export const javaRuntime = runtimeCapability('runtime.java', 'Java runtime and b return value ? {value, evidence: [requested?.from, build?.name, active.active ? 'JDK release file' : undefined].filter(Boolean).join(', ') || 'java'} : undefined; }); -export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime] as CapabilityDefinition[]; +export const rubyRuntime = runtimeCapability('runtime.ruby', 'Ruby version', ['.ruby-version', 'Gemfile (ruby "x")', '.tool-versions', 'mise.toml', + 'the ruby executable the shell resolves: its install location (never run)'], [], +{requested: '3.3', requestedFrom: '.ruby-version', active: '3.3.5', manager: 'rbenv', buildTool: 'bundler'}, async context => { + let requested = await versionFile(context, ['.ruby-version']); + const gemfile = await findNearest(context, ['Gemfile']); + if (!requested && gemfile) { + const declared = /^[ \t]*ruby[ \t]+['"](\d[\w.+-]{0,31})['"]/mu.exec(await readMetadataText(gemfile.path, 64 * 1024) ?? '')?.[1]; + if (declared) requested = {version: declared, from: 'Gemfile'}; + } + requested ??= await toolRequest(context, ['ruby']); + if (!requested && !gemfile && !context.fields.has('active')) return undefined; + const active = await activeVersion(context, ['ruby']); + const value = compact({requested: requested?.version.replace(/^ruby-(?=\d)/u, ''), requestedFrom: requested?.from, ...active, ...(gemfile ? {buildTool: 'bundler'} : {})}); + return value ? {value, evidence: [requested?.from, gemfile?.name, active.active ? 'ruby on PATH' : undefined].filter(Boolean).join(', ') || 'ruby'} : undefined; +}); + +export const phpRuntime = runtimeCapability('runtime.php', 'PHP version', ['.php-version', 'composer.json (require.php)', '.tool-versions', 'mise.toml', + 'the php executable the shell resolves: its install location (never run)'], [], +{requested: '8.3', requestedFrom: '.php-version', active: '8.3.12', manager: 'Homebrew', buildTool: 'composer'}, async context => { + let requested = await versionFile(context, ['.php-version']); + const manifest = await findNearest(context, ['composer.json']); + if (!requested && manifest) { + const pkg = parseJsonData(await readMetadataText(manifest.path, LARGE_METADATA_BYTES)); + const constraint = record(pkg) && record(pkg.require) ? text(pkg.require.php, 64) : undefined; + if (constraint) requested = {version: constraint, from: 'composer.json'}; + } + requested ??= await toolRequest(context, ['php']); + if (!requested && !manifest && !context.fields.has('active')) return undefined; + const active = await activeVersion(context, ['php']); + const value = compact({requested: requested?.version, requestedFrom: requested?.from, ...active, ...(manifest ? {buildTool: 'composer'} : {})}); + return value ? {value, evidence: [requested?.from, manifest?.name, active.active ? 'php on PATH' : undefined].filter(Boolean).join(', ') || 'php'} : undefined; +}); + +export const RUNTIME_CAPABILITIES: readonly CapabilityDefinition[] = [nodeRuntime, pythonRuntime, goRuntime, rustRuntime, javaRuntime, rubyRuntime, phpRuntime] as CapabilityDefinition[]; diff --git a/src/context/engine.ts b/src/context/engine.ts index 429e7ea3..d97cd3a3 100644 --- a/src/context/engine.ts +++ b/src/context/engine.ts @@ -33,6 +33,8 @@ export interface CapabilityStatus { evidence?: string; error?: string; durationMs?: number; + /** A previous resolved value can survive a failed refresh; absence is not inferred from failure. */ + hasValue?: boolean; } export interface EngineStats { @@ -251,7 +253,7 @@ export class ContextEngine { : entry.status === 'failed' || entry.status === 'timeout' ? entry.status : entry.status === 'absent' ? 'absent' : entry.status === 'resolved' ? (entry.dirty || now - (entry.collectedAt ?? 0) > definition.ttlMs ? 'stale' : 'fresh') : 'idle'; - return {id, state, ...(entry.collectedAt !== undefined ? {collectedAt: entry.collectedAt} : {}), ...(entry.evidence ? {evidence: entry.evidence} : {}), + return {id, state, hasValue: entry.present, ...(entry.collectedAt !== undefined ? {collectedAt: entry.collectedAt} : {}), ...(entry.evidence ? {evidence: entry.evidence} : {}), ...(entry.error ? {error: entry.error} : {}), ...(entry.durationMs !== undefined ? {durationMs: entry.durationMs} : {})}; } diff --git a/src/context/packs/builtin/nmsh.project.json b/src/context/packs/builtin/nmsh.project.json index bcb53345..014cfe06 100644 --- a/src/context/packs/builtin/nmsh.project.json +++ b/src/context/packs/builtin/nmsh.project.json @@ -7,7 +7,7 @@ "license": "GPL-3.0-only", "provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation; tool behavior studied from public documentation only."}, "compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"}, - "requires": ["project.package", "runtime.node", "runtime.python", "runtime.go", "runtime.rust", "runtime.java"], + "requires": ["project.package", "runtime.node", "runtime.python", "runtime.go", "runtime.ruby", "runtime.php", "runtime.rust", "runtime.java"], "modules": [ { "id": "package", "label": "Package", "description": "Version and package manager from the nearest declarative manifest.", @@ -55,6 +55,30 @@ {"fact": "runtime.go", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} ]}] }, + { + "id": "ruby", "label": "Ruby", "description": "Ruby version from .ruby-version, Gemfile or the install location; never run.", + "category": "runtime", "priority": 43, "role": "runtime", "icon": "ruby", "condition": "onCommand", + "triggers": ["ruby", "bundle", "bundler", "rails", "rake", "gem"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "ruby", "parts": [ + {"fact": "runtime.ruby", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.ruby", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.ruby", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.ruby", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, + { + "id": "php", "label": "PHP", "description": "PHP version from .php-version, composer.json or the install location; never run.", + "category": "runtime", "priority": 42, "role": "runtime", "icon": "php", "condition": "onCommand", + "triggers": ["php", "composer", "artisan", "phpunit"], + "surfaces": {"preferred": "contextRail"}, + "segments": [{"label": "php", "parts": [ + {"fact": "runtime.php", "field": "active", "format": "version", "optional": true}, + {"fact": "runtime.php", "field": "requested", "optional": true, "when": {"field": "active", "present": false}}, + {"fact": "runtime.php", "field": "requestedFrom", "join": " · ", "optional": true, "when": {"field": "mismatch", "equals": true}}, + {"fact": "runtime.php", "field": "requested", "prefix": " ", "optional": true, "when": {"field": "mismatch", "equals": true}} + ]}] + }, { "id": "rust", "label": "Rust", "description": "The rustup toolchain this directory selects, or the installed rustc.", "category": "runtime", "priority": 44, "role": "runtime", "icon": "rust", "condition": "onCommand", diff --git a/src/doctor/doctor.ts b/src/doctor/doctor.ts index 50e08c64..667ef8e2 100644 --- a/src/doctor/doctor.ts +++ b/src/doctor/doctor.ts @@ -39,10 +39,22 @@ export interface DoctorEnvironment { understanding: {mode: string; model?: {label: string; runtime: string; path?: string; owned: boolean}; runtimeAvailable: boolean; state?: string}; agents: Array<{label: string; installed: boolean}>; virtualEnv?: string; + /** The Context Engine's own counters since NMSh started (nothing is collected to read them). */ + contextEngine?: {started: number; completed: number; cancelled: number; timedOut: number; failed: number; cacheHits: number; coalesced: number}; } const join = (directory: string, name: string) => `${directory.replace(/\/$/u, '')}/${name}`; +/** Prompt data health: how often collection ran, was served from cache, timed out or failed. A few timeouts are normal on a cold disk. */ +export function contextEngineCheck(stats: NonNullable): DoctorCheck { + const section = 'NMSh'; + const total = stats.completed + stats.timedOut + stats.failed; + const detail = `${stats.started} collections started · ${stats.cacheHits} served from cache · ${stats.coalesced} merged · ${stats.cancelled} cancelled · ${stats.timedOut} timed out · ${stats.failed} failed. /modules shows each module's own timing`; + const bad = total >= 20 && (stats.timedOut + stats.failed) / total > 0.2; + return bad ? {section, label: 'Prompt data often times out or fails', state: 'attention', detail, action: {label: 'Review modules', kind: 'slash', command: '/modules'}} + : {section, label: 'Prompt data collection healthy', state: stats.started ? 'ok' : 'info', detail}; +} + function nmshChecks(env: DoctorEnvironment): DoctorCheck[] { const section = 'NMSh'; const checks: DoctorCheck[] = [env.nmsh.configurationLoaded @@ -54,6 +66,7 @@ function nmshChecks(env: DoctorEnvironment): DoctorCheck[] { } else checks.push({section, label: 'Running in-process (no session service)', state: 'info'}); checks.push(env.writable(env.nmsh.transcriptDirectory) ? {section, label: 'Transcript store writable', state: 'ok'} : {section, label: 'Transcript store is not writable', state: 'failure', detail: env.nmsh.transcriptDirectory}); + if (env.contextEngine) checks.push(contextEngineCheck(env.contextEngine)); return checks; } diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index b304e004..cbcfd945 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -73,6 +73,14 @@ Use /history with plain text or combine cwd:, project:, exit:, before:, after:, Enter or Tab restores the selected command without executing it. Ctrl+X removes the selected record from NMSh search. Session transcripts and the original zsh/Atuin history remain intact; deletion is remembered locally. +To keep the selected history command, open the palette (F1 or Ctrl+Shift+P) and choose **Pin selected history command**. It keeps the command and its recorded directory without executing it; /pins lists it later. + +At the prompt, F8 focuses the newest failed command, then walks to older failures. Shift+F8 returns to newer failures. These keys preserve your draft; the palette also offers Previous/Next failed command. Your terminal may reserve function keys. + +Use C in Paste Review to keep an ephemeral clip, or a block's Actions to keep its command or output. The palette can keep your composer draft or selected history command. /clips reviews kept text before insertion; B opens the existing batch review. No clip runs automatically or replaces a draft. Clips stay in this frontend's memory (up to 12 clips and 1 MiB total), can be discarded, and disappear when it exits, including detach. + +/clips suggestions on optionally checks macOS's native clipboard change counter while the composer is idle. It never reads clipboard contents. A changed counter suggests /ps; only that explicit command reads text for review. Suggestions default off, last only for this frontend, and stop with /clips suggestions off or if metadata fails. Linux clipboard tools have no supported metadata counter; background text polling is unsupported. + ## Directory navigation Use /dirs or the command palette to find recorded directories. Native ranks frequency and recency from approved command history. Select with arrows and Enter or Tab to insert a literal cd command, then press Enter separately to run it in zsh. Ordinary cd keeps its normal behavior. diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts new file mode 100644 index 00000000..4ff2f25d --- /dev/null +++ b/src/input/BatchReview.ts @@ -0,0 +1,209 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {classifyCommand, KIND_LABELS, primaryKind, splitCommands, type PasteKind} from './pasteGuard.js'; +import {displaySafe} from './PasteReview.js'; +import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; +import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; + +/** + * Batch Review: a pasted sequence split into the commands the shell would run, to look at before any of it is queued. + * Nothing runs because it was pasted or shown here. Enter queues exactly the commands listed, in the order listed, + * into this session's own queue (one at a time, in the same shell, pausing on a failure); every other key leaves the + * paste as text. + */ +export interface BatchEntry extends BatchCommand { + kinds: PasteKind[]; +} + +export interface BatchReviewState { + /** The paste exactly as it arrived. */ + source: string; + entries: BatchEntry[]; + cursor: number; + /** First visible list row. */ + top: number; + /** Free places in the session's queue. */ + capacity: number; + /** Where the commands come from, when not a paste (a recipe): shown in the header. */ + title?: string; + /** What the commands expect (the recipe's folder and the current one), shown above the list. */ + note?: string; +} + +export type BatchReviewAction = {kind: 'queue'} | {kind: 'text'} | {kind: 'edit'} | {kind: 'back'} | {kind: 'cancel'}; + +/** Contents of `$( )`, `<( )`, `>( )` and backtick substitutions in a command (balanced; an approximation that errs towards finding more). */ +function substitutions(text: string): string[] { + const found: string[] = []; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (char === '`') { + const end = text.indexOf('`', index + 1); + if (end < 0) break; + found.push(text.slice(index + 1, end)); + index = end; + } else if ((char === '$' || char === '<' || char === '>') && text[index + 1] === '(') { + let depth = 1; + let at = index + 2; + while (at < text.length && depth) { if (text[at] === '(') depth += 1; else if (text[at] === ')') depth -= 1; at += 1; } + found.push(text.slice(index + 2, depth ? at : at - 1)); + index += 1; + } + } + return found; +} + +/** + * Everything the whole command can do, not just how its first line starts: every statement in it (heredoc bodies + * and block contents included, which only ever over-reports) and everything inside command substitutions. + */ +export function entryKinds(text: string, depth = 0): PasteKind[] { + const kinds = new Set(); + for (const piece of splitCommands(text)) for (const kind of classifyCommand(piece.text, piece.pipedFrom)) kinds.add(kind); + // A quote the splitter loses track of (an apostrophe in a heredoc, in a comment) must not hide what follows it: + // every line is classified on its own as well, and every statement after a `;`, `&&`, `||`, `|` or `&`. This only + // ever adds findings. + for (const line of text.split('\n')) { + for (const part of line.split(/&&|\|\||[;|&]/u)) { + const trimmed = part.trim().replace(/^[{(]\s*/u, '').replace(/^(?:do|then|else)\s+/u, ''); + if (trimmed && !trimmed.startsWith('#')) for (const kind of classifyCommand(trimmed, false)) if (kind !== 'text') kinds.add(kind); + } + } + if (depth < 3) for (const inner of substitutions(text)) for (const kind of entryKinds(inner, depth + 1)) kinds.add(kind); + return [...kinds]; +} + +/** The paste as separate commands, when the shell grammar says it safely can be (two or more); undefined means "keep it as one block". */ +export function createBatchReview(source: string, shell: BatchShell, capacity: number): BatchReviewState | undefined { + const result = splitBatch(source, shell); + if (!result.ok || result.commands.length < 2) return undefined; + return {source, entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), cursor: 0, top: 0, capacity}; +} + +/** A reviewed list of commands that are already separate (a recipe's steps): the same review, queue and safety. */ +export function batchFromCommands(commands: readonly string[], capacity: number, details: {title: string; note?: string}): BatchReviewState { + const entries = commands.map((text, index): BatchEntry => ({text, firstLine: index + 1, lastLine: index + 1, kinds: entryKinds(text)})); + return {source: commands.join('\n'), entries, cursor: 0, top: 0, capacity, title: details.title, ...(details.note ? {note: details.note} : {})}; +} + +/** Why a paste is not offered as a batch (for the review's own note), or undefined when it is. */ +export function batchRefusal(source: string, shell: BatchShell): string | undefined { + const result = splitBatch(source, shell); + if (!result.ok) return result.reason; + return result.commands.length < 2 ? 'It is a single command.' : undefined; +} + +export function batchReviewKey(state: BatchReviewState, key: Key, height: number): BatchReviewAction | undefined { + const rows = listRows(height); + const last = state.entries.length - 1; + const select = (to: number) => { state.cursor = Math.max(0, Math.min(last, to)); }; + const move = (by: number) => { + const to = state.cursor + by; + if (to < 0 || to > last) return; + const [entry] = state.entries.splice(state.cursor, 1); + state.entries.splice(to, 0, entry!); + state.cursor = to; + }; + if (key.kind === 'enter') return state.entries.length && state.entries.length <= state.capacity ? {kind: 'queue'} : undefined; + if (key.kind === 'escape') return {kind: 'back'}; + if (key.kind === 'interrupt') return {kind: 'cancel'}; + if (key.kind === 'up' || key.kind === 'wheelUp') select(state.cursor - 1); + else if (key.kind === 'down' || key.kind === 'wheelDown') select(state.cursor + 1); + else if (key.kind === 'pageUp') select(state.cursor - rows); + else if (key.kind === 'pageDown') select(state.cursor + rows); + else if (key.kind === 'delete' || key.kind === 'backspace') remove(state); + else if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'x') remove(state); + else if (value === 'k') move(-1); + else if (value === 'j') move(1); + else if (value === 't') return {kind: 'text'}; + else if (value === 'e' && state.entries.length) return {kind: 'edit'}; + else if (value === 'q' && state.entries.length && state.entries.length <= state.capacity) return {kind: 'queue'}; + } + state.top = Math.max(0, Math.min(state.top, Math.max(0, state.entries.length - rows))); + if (state.cursor < state.top) state.top = state.cursor; + else if (state.cursor >= state.top + rows) state.top = state.cursor - rows + 1; + return undefined; +} + +function remove(state: BatchReviewState): void { + if (!state.entries.length) return; + state.entries.splice(state.cursor, 1); + state.cursor = Math.max(0, Math.min(state.cursor, state.entries.length - 1)); +} + +/** The commands as they will be queued (the reviewed list, one entry per command). */ +export function batchTexts(state: BatchReviewState): string[] { + return state.entries.map(entry => entry.text); +} + +/** A command line as rows of at most `width` cells: wrapped, never cut, so nothing of it is out of sight. */ +function wrapRows(line: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + let used = 0; + for (const char of displaySafe(line)) { + // Characters a terminal may draw wide (ambiguous, combining, emoji) count as two cells, so no row can overflow. + const cells = Math.max(displayWidth(char), (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1); + if (used + cells > width && row) { rows.push(row); row = ''; used = 0; } + row += char; + used += cells; + } + rows.push(row); + return rows; +} + +const FIXED_ROWS = 10; +const listRows = (height: number) => Math.max(1, Math.min(8, height - FIXED_ROWS - 3)); +const RISKY: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline']); + +/** At most `height` rows: header, the list (scrolling), the selected command in full, the notes and the controls. */ +export function renderBatchReview(state: BatchReviewState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const accent = foreground(UI_COLORS.accent); + const failure = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const count = state.entries.length; + const width = Math.max(10, columns - 4); + const out: string[] = [` ${primary}Review commands${reset} ${subtle}${truncateText(`${state.title ? `${state.title} · ` : ''}${count} command${count === 1 ? '' : 's'}, in this order`, Math.max(8, width - 18))}${reset}`]; + if (state.note) out.push(` ${subtle}${truncateText(state.note, width)}${reset}`); + out.push(''); + const rows = listRows(height); + const numberWidth = String(count).length; + const labelWidth = columns >= 76 ? 26 : 0; + if (!count) out.push(` ${secondary}Nothing left to queue. Esc goes back to the paste.${reset}`); + for (let index = state.top; index < Math.min(count, state.top + rows); index += 1) { + const entry = state.entries[index]!; + const focused = index === state.cursor; + const kind = primaryKind(entry.kinds); + const color = RISKY.has(kind) ? failure : kind === 'install' || kind === 'modifies' ? accent : subtle; + const label = labelWidth ? `${color}${padCells(truncateText(KIND_LABELS[kind], labelWidth - 1), labelWidth)}${reset}` : RISKY.has(kind) ? `${failure}! ${reset}` : ''; + const lines = entry.text.split('\n'); + const first = displaySafe(lines[0]!) + (lines.length > 1 ? ` ⏎ +${lines.length - 1}` : ''); + out.push(truncateAnsi(`${focused ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${label}${focusForeground(focused)}${truncateText(first, Math.max(8, width - numberWidth - labelWidth - 4))}${reset}`, columns)); + } + if (count > rows) out.push(` ${subtle}${state.top + 1}–${Math.min(count, state.top + rows)} of ${count}${reset}`); + const selected = state.entries[state.cursor]; + const notes = 2; + const controls = renderControlRows([['↑↓', 'select'], ['x', 'remove'], ['k j', 'move'], ['Enter', `queue ${count}`], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); + if (selected) { + const kind = primaryKind(selected.kinds); + out.push('', ` ${subtle}Command ${state.cursor + 1}, exactly as it will be queued${reset}${RISKY.has(kind) || kind === 'install' || kind === 'modifies' ? `${RISKY.has(kind) ? failure : accent} · ${KIND_LABELS[kind]} somewhere in it${reset}` : ''}`); + const wrapped = selected.text.split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); + const room = Math.max(2, height - out.length - notes - controls.length - 1); + const shown = wrapped.length > room ? room - 1 : room; + for (const row of wrapped.slice(0, shown)) out.push(` ${secondary}${row}${reset}`); + if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); + } + out.push(''); + if (count > state.capacity) out.push(` ${failure}${truncateText(`The queue has room for ${state.capacity} more; remove ${count - state.capacity} to queue these.`, width)}${reset}`); + else out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. Then the shell runs them one at a time, and the queue stops if one fails.', width)}${reset}`); + out.push(...controls.map(row => ` ${row}`)); + const fixed = Math.max(4, height); + return (out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out).map(row => truncateAnsi(row, columns)); +} diff --git a/src/input/PasteReview.ts b/src/input/PasteReview.ts index 0cd0fd45..ffb6eb74 100644 --- a/src/input/PasteReview.ts +++ b/src/input/PasteReview.ts @@ -21,11 +21,13 @@ export interface PasteReviewState { starts: Array; /** First visible source line. */ top: number; + /** The paste splits into separate commands that could be queued (offered as B). */ + batch: boolean; } -export type PasteReviewResult = 'insert' | 'back' | 'cancel' | undefined; +export type PasteReviewResult = 'insert' | 'back' | 'cancel' | 'batch' | 'clip' | undefined; -export function createPasteReview(text: string, analysis: PasteAnalysis): PasteReviewState { +export function createPasteReview(text: string, analysis: PasteAnalysis, batch = false): PasteReviewState { const lines = text.replace(/\r\n?/gu, '\n').split('\n'); const starts: Array = new Array(lines.length).fill(undefined); // Locate each command in order from where the previous one ended; display-only, linear in the paste size. @@ -42,7 +44,7 @@ export function createPasteReview(text: string, analysis: PasteAnalysis): PasteR while (lineIndex + 1 < lines.length && offsets[lineIndex + 1]! <= found) lineIndex += 1; starts[lineIndex] ??= primaryKind(command.kinds); } - return {text, analysis, lines, starts, top: 0}; + return {text, analysis, lines, starts, top: 0, batch}; } /** The body rows available for source lines: header, a blank, a note row and the footer are fixed. */ @@ -58,6 +60,8 @@ export function pasteReviewKey(state: PasteReviewState, key: Key, height: number if (key.kind === 'escape') return 'back'; if (key.kind === 'interrupt') return 'cancel'; if (key.kind === 'text' && key.value.toLowerCase() === 'r') return 'back'; + if (key.kind === 'text' && key.value.toLowerCase() === 'c') return 'clip'; + if (key.kind === 'text' && key.value.toLowerCase() === 'b' && state.batch) return 'batch'; if (key.kind === 'up' || key.kind === 'wheelUp') move(state.top - (key.kind === 'wheelUp' ? 3 : 1)); else if (key.kind === 'down' || key.kind === 'wheelDown') move(state.top + (key.kind === 'wheelDown' ? 3 : 1)); else if (key.kind === 'pageUp') move(state.top - page); @@ -102,7 +106,7 @@ export function renderPasteReview(state: PasteReviewState, columns: number, heig } const exact = state.analysis.commands.some(command => primaryKind(command.kinds) === 'text' || primaryKind(command.kinds) === 'unknown'); out.push(truncateAnsi(` ${subtle}${exact ? PASTE_EXACT_NOTE : 'Nothing runs until you press Enter again.'}${reset}`, columns)); - out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ['Esc', 'back']])); + out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ...(state.batch ? [['B', 'queue as commands'] as const] : []), ['Esc', 'back'], ['C', 'keep clip']])); // A very short terminal drops the note before the controls; the panel never exceeds its height. const fixed = Math.max(3, height); return out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out; diff --git a/src/input/pasteBatch.ts b/src/input/pasteBatch.ts new file mode 100644 index 00000000..122318c3 --- /dev/null +++ b/src/input/pasteBatch.ts @@ -0,0 +1,304 @@ +/** + * Paste batches: where a pasted block of shell text can safely be cut into separate commands for a reviewed queue. + * + * Only a COMPLETE top-level statement ends at a newline. A newline inside a quote, a heredoc, a `$( )` or `( )` group, + * an `if`/`for`/`while`/`case`/`function` block (`begin`/`switch`/`end` in Fish), after a trailing `\`, `&&`, `||` or `|` + * stays inside the statement, and a chain such as `a && b` is one command: splitting it would change what runs. + * Anything this scanner cannot account for (unterminated quotes, stray closers, constructs it does not model) is not + * guessed at: the result says why, and the caller offers the paste as one block of text. + * + * Each command is the exact characters from the paste (line endings normalised to \n), never re-quoted or trimmed + * inside. Nothing here runs, resolves or expands anything. + */ +export type BatchShell = 'zsh' | 'bash' | 'fish'; + +export interface BatchCommand { + text: string; + /** 1-based first and last source line (comment lines before a command belong to it). */ + firstLine: number; + lastLine: number; +} + +export type BatchResult = {ok: true; commands: BatchCommand[]} | {ok: false; reason: string}; + +/** Nesting deeper than this is not a paste to reason about. */ +const MAX_DEPTH = 200; +/** Pastes larger than this are one block of text. */ +export const MAX_BATCH_CHARACTERS = 1_000_000; + +class Unsplittable extends Error {} +const fail = (reason: string): never => { throw new Unsplittable(reason); }; + +const OPENERS_POSIX = new Set(['if', 'while', 'until', 'for', 'select']); +const CLOSERS_POSIX = new Set(['fi', 'done', 'esac']); +const OPENERS_FISH = new Set(['if', 'while', 'for', 'switch', 'begin', 'function']); +/** zsh constructs with block forms this scanner does not model. */ +const UNMODELLED = new Set(['foreach', 'repeat', 'coproc']); +/** Words after which a command can start again. */ +const CONTINUES_COMMAND = new Set(['then', 'do', 'else', 'elif', '!', 'time', 'builtin', 'command', 'exec', 'noglob', 'nocorrect', 'sudo']); + +export function splitBatch(input: string, shell: BatchShell): BatchResult { + const text = input.replace(/\r\n?/gu, '\n'); + if (text.length > MAX_BATCH_CHARACTERS) return {ok: false, reason: 'The paste is too large to split safely.'}; + try { return {ok: true, commands: scan(text, shell)}; } + catch (error) { if (error instanceof Unsplittable) return {ok: false, reason: error.message}; throw error; } +} + +function scan(text: string, shell: BatchShell): BatchCommand[] { + const fish = shell === 'fish'; + const length = text.length; + const lineStarts = [0]; + for (let index = 0; index < length; index += 1) if (text[index] === '\n') lineStarts.push(index + 1); + const lineOf = (index: number) => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { const mid = (low + high + 1) >> 1; if (lineStarts[mid]! <= index) low = mid; else high = mid - 1; } + return low + 1; + }; + + /** Index just past a quoted or substituted span that starts at `from`, or fail. */ + const skipSingle = (from: number, escapes: boolean): number => { + for (let index = from; index < length; index += 1) { + if (escapes && text[index] === '\\') { index += 1; continue; } + if (text[index] === '\'') return index + 1; + } + return fail('A single quote is never closed.'); + }; + const skipDouble = (from: number, depth: number): number => { + for (let index = from; index < length; index += 1) { + const char = text[index]!; + if (char === '\\') { index += 1; continue; } + if (char === '"') return index + 1; + if (char === '$' && text[index + 1] === '(') index = skipGroup(index + 2, ')', depth + 1, text[index + 2] === '(') - 1; + else if (char === '$' && text[index + 1] === '{') index = skipGroup(index + 2, '}', depth + 1) - 1; + else if (char === '`') index = skipBacktick(index + 1) - 1; + } + return fail('A double quote is never closed.'); + }; + const skipBacktick = (from: number): number => { + for (let index = from; index < length; index += 1) { + if (text[index] === '\\') { index += 1; continue; } + if (text[index] === '`') return index + 1; + } + return fail('A backtick is never closed.'); + }; + /** Past the matching `close` of a `$(`, `${` or `(` group that started before `from`. */ + const skipGroup = (from: number, close: ')' | '}', depth: number, arithmetic = false): number => { + if (depth > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + const open = close === ')' ? '(' : '{'; + for (let index = from; index < length; index += 1) { + const char = text[index]!; + if (char === '\\') { index += 1; continue; } + if (char === '\'') { index = skipSingle(index + 1, fish || text[index - 1] === '$') - 1; continue; } + if (char === '"') { index = skipDouble(index + 1, depth) - 1; continue; } + if (char === '`') { index = skipBacktick(index + 1) - 1; continue; } + // A comment inside a substitution can hide a quote or a parenthesis from the shell (or show one to it): not split. + if (close === ')' && !arithmetic && char === '#' && (index === from || /[\s;&|(]/u.test(text[index - 1]!))) fail('A comment inside a substitution is not split.'); + // A here-document inside a substitution has a body this scanner does not read: say so instead of guessing where the group ends. + if (!arithmetic && close === ')' && char === '<' && text[index + 1] === '<' && text[index + 2] !== '<' && text[index - 1] !== '<') fail('A here-document inside a substitution is not split.'); + if (char === '$' && text[index + 1] === '(') { index = skipGroup(index + 2, ')', depth + 1, arithmetic || text[index + 2] === '(') - 1; continue; } + if (char === '$' && text[index + 1] === '{') { index = skipGroup(index + 2, '}', depth + 1, arithmetic) - 1; continue; } + if (char === open && close === ')') { index = skipGroup(index + 1, ')', depth + 1, arithmetic) - 1; continue; } + if (char === close) return index + 1; + } + return fail(close === ')' ? 'A parenthesis is never closed.' : 'A brace is never closed.'); + }; + + /** + * Past a `#` comment to the end of its line. Interactive zsh only treats `#` as a comment when the person turned + * interactive_comments on, which is not known here; otherwise everything after it is ordinary shell text, including + * `;` and `$( )`. A comment with no shell syntax in it reads the same either way. One with syntax in it is refused, + * so the review can never show something as a comment that the shell would run. + */ + const skipComment = (from: number): number => { + let end = from; + while (end < length && text[end] !== '\n') end += 1; + if (shell === 'zsh' && /['"`$;&|()<>\\{}]/u.test(text.slice(from, end))) fail('A comment contains shell syntax, and zsh runs that unless interactive comments are on.'); + return end; + }; + + const commands: BatchCommand[] = []; + const stack: Array<'(' | '{'> = []; + let blocks = 0; + let cases = 0; + let inTest = false; + let continuation = false; + let commandStart = true; + let functionPending = false; + let statementStart = 0; + let previousWord = ''; + let sawCode = false; + const heredocs: Array<{tag: string; strip: boolean}> = []; + + const emit = (end: number) => { + const raw = text.slice(statementStart, end); + const trimmed = raw.replace(/^\s+/u, '').replace(/\s+$/u, ''); + if (!trimmed) return; + const first = statementStart + (raw.length - raw.replace(/^\s+/u, '').length); + commands.push({text: trimmed, firstLine: lineOf(first), lastLine: lineOf(first + trimmed.length - 1)}); + }; + const complete = () => !continuation && !stack.length && !blocks && !cases && !inTest && !functionPending; + const nextWordAfterBreak = (from: number): string => { + let index = from; + while (index < length) { + const char = text[index]!; + if (char === '\n' || char === ' ' || char === '\t') { index += 1; continue; } + if (char === '#') { while (index < length && text[index] !== '\n') index += 1; continue; } + break; + } + return /^(?:&&|\|\||\||[A-Za-z_][\w-]*)/u.exec(text.slice(index, index + 40))?.[0] ?? ''; + }; + + let index = 0; + while (index < length) { + const char = text[index]!; + if (char === '\n') { + index += 1; + if (!continuation) commandStart = true; + if (heredocs.length) { + // The bodies follow the line that named them; each ends at a line that is exactly its tag. + for (const heredoc of heredocs.splice(0)) { + for (;;) { + if (index >= length) fail('A here-document is never closed.'); + const end = text.indexOf('\n', index); + const line = text.slice(index, end < 0 ? length : end); + index = end < 0 ? length : end + 1; + if ((heredoc.strip ? line.replace(/^\t+/u, '') : line) === heredoc.tag) break; + } + } + if (complete() && sawCode) { emit(index); statementStart = index; sawCode = false; commandStart = true; } + continue; + } + if (complete() && sawCode) { + const next = nextWordAfterBreak(index); + if (/^(?:&&|\|\||\|)/u.test(next)) { if (!fish) fail('A line starts with an operator.'); } + else if (fish && (next === 'and' || next === 'or')) { /* a Fish modifier continues the statement above */ } + else { emit(index - 1); statementStart = index; sawCode = false; commandStart = true; } + } else if (!sawCode) { + // Blank or comment-only lines wait for the command they precede. + } + continue; + } + if (char === ' ' || char === '\t') { index += 1; continue; } + if (char === '#') { index = skipComment(index); continue; } + if (char === '\\') { + if (text[index + 1] === '\n') { index += 2; continue; } + sawCode = true; commandStart = false; continuation = false; + index += 2; + continue; + } + // Operators. + if (char === '&' && text[index + 1] === '&') { continuation = true; commandStart = true; index += 2; sawCode = true; continue; } + if (char === '|') { + continuation = !inTest; commandStart = true; sawCode = true; + index += text[index + 1] === '|' || text[index + 1] === '&' ? 2 : 1; + continue; + } + if (char === ';') { + sawCode = true; continuation = false; commandStart = true; + index += text[index + 1] === ';' ? 2 : 1; + if (cases && text[index - 1] === ';' && text[index - 2] === ';') { /* end of a case arm */ } + continue; + } + if (char === '&') { sawCode = true; continuation = false; commandStart = true; index += 1; continue; } + if (char === '(' && commandStart && text[index + 1] === '(' && !fish) { + // (( expression )): arithmetic, whose << is a shift and not a here-document. + const end = skipGroup(index + 2, ')', 1, true); + if (text[end] === ')') { index = end + 1; sawCode = true; continuation = false; commandStart = false; continue; } + } + if (char === '(') { + if (stack.length >= MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + stack.push('('); sawCode = true; continuation = false; commandStart = true; index += 1; continue; + } + if (char === ')') { + if (stack.at(-1) === '(') stack.pop(); + else if (cases) { /* the ) after a case pattern */ } + else fail('A closing parenthesis has nothing to close.'); + sawCode = true; continuation = false; commandStart = true; index += 1; + continue; + } + if (char === '<' || char === '>') { + // Redirections; `<<` names a here-document (not in Fish, which has none), `<<<` is a here-string. + if (!fish && char === '<' && text[index + 1] === '<' && text[index + 2] !== '<' && text[index - 1] !== '<') { + let at = index + 2; + const strip = text[at] === '-'; + if (strip) at += 1; + while (text[at] === ' ' || text[at] === '\t') at += 1; + // The marker is a word: the shell removes its quotes and backslashes before comparing lines with it. + const match = /^(?:[^\s;&|<>()'"\\]|'[^'\n]*'|"[^"\n]*"|\\.)+/u.exec(text.slice(at, at + 200)); + if (!match) fail('A here-document has no readable end marker.'); + const tag = match![0].replace(/'([^']*)'|"([^"]*)"|\\(.)/gu, (_all, single?: string, double?: string, escaped?: string) => single ?? double ?? escaped ?? ''); + if (!tag || /[$`]/u.test(tag)) fail('A here-document marker uses an expansion.'); + heredocs.push({tag, strip}); + index = at + match![0].length; + sawCode = true; continuation = false; commandStart = false; + continue; + } + // Process substitution <( ) and >( ). + if (text[index + 1] === '(') { index = skipGroup(index + 2, ')', 1); sawCode = true; continuation = false; commandStart = false; continue; } + sawCode = true; continuation = false; commandStart = false; index += 1; + continue; + } + // A word, possibly with quoted or substituted parts. + let word = ''; + let plain = true; + sawCode = true; + while (index < length) { + const c = text[index]!; + if (c === '\n' || c === ' ' || c === '\t' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || c === '<' || c === '>') break; + if (c === '\\') { if (text[index + 1] === '\n') { index += 2; continue; } plain = false; index += 2; continue; } + if (c === '\'') { plain = false; index = skipSingle(index + 1, fish || text[index - 1] === '$'); continue; } + if (c === '"') { plain = false; index = skipDouble(index + 1, 1); continue; } + if (c === '`') { plain = false; index = skipBacktick(index + 1); continue; } + if (c === '$' && text[index + 1] === '(') { plain = false; index = skipGroup(index + 2, ')', 1, text[index + 2] === '('); continue; } + if (c === '$' && text[index + 1] === '{') { plain = false; index = skipGroup(index + 2, '}', 1); continue; } + if (c === '#' && word === '' ) break; + word += c; + index += 1; + } + continuation = false; + if (!plain || !word) { commandStart = false; functionPending = false; continue; } + if (commandStart) { + if (!fish && UNMODELLED.has(word) && shell === 'zsh') fail(`"${word}" blocks are not split.`); + if (fish) { + if (OPENERS_FISH.has(word) && !(word === 'if' && previousWord === 'else')) { blocks += 1; if (blocks > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); } + else if (word === 'end') { if (!blocks) fail('"end" has nothing to close.'); blocks -= 1; } + } else { + if (OPENERS_POSIX.has(word)) { blocks += 1; if (blocks > MAX_DEPTH) fail('The paste nests too deeply to split safely.'); } + else if (word === 'case') { cases += 1; blocks += 1; } + else if (CLOSERS_POSIX.has(word)) { + if (!blocks) fail(`"${word}" has nothing to close.`); + blocks -= 1; + if (word === 'esac') { if (!cases) fail('"esac" has nothing to close.'); cases -= 1; } + } else if (word === 'function') functionPending = true; + else if (word === '[[') inTest = true; + } + } + if (word === '{' && !fish) { + if (stack.length >= MAX_DEPTH) fail('The paste nests too deeply to split safely.'); + stack.push('{'); functionPending = false; commandStart = true; continue; + } + if (word === '}' && !fish && commandStart) { + if (stack.at(-1) !== '{') fail('A closing brace has nothing to close.'); + stack.pop(); commandStart = false; continue; + } + if (word === ']]') inTest = false; + const wasStart = commandStart; + commandStart = commandStart && CONTINUES_COMMAND.has(word); + if (fish && wasStart && (word === 'and' || word === 'or' || word === 'not')) commandStart = true; + previousWord = wasStart ? word : ''; + } + if (heredocs.length) fail('A here-document is never closed.'); + if (continuation) fail('The paste ends in the middle of a command.'); + if (stack.length || blocks || cases || inTest) fail('A block or group is never closed.'); + if (sawCode) emit(length); + else if (commands.length) { + // Trailing comment lines stay with the command above them. + const rest = text.slice(statementStart).replace(/\s+$/u, '').replace(/^\n+/u, ''); + if (rest) { + const last = commands[commands.length - 1]!; + commands[commands.length - 1] = {text: `${last.text}\n${rest}`, firstLine: last.firstLine, lastLine: lineOf(text.replace(/\s+$/u, '').length - 1)}; + } + } + return commands; +} diff --git a/src/input/pasteBatchCheck.ts b/src/input/pasteBatchCheck.ts new file mode 100644 index 00000000..306ec172 --- /dev/null +++ b/src/input/pasteBatchCheck.ts @@ -0,0 +1,58 @@ +import {spawn} from 'node:child_process'; +import {shellSubmission} from '../shell/submission.js'; +import type {BatchShell} from './pasteBatch.js'; + +/** + * The final gate for a paste split into commands: the real shell, in parse-only mode (`bash -n`, `zsh -n`, + * `fish --no-execute`: it reads and checks syntax and never runs anything), must accept EACH command on its own, + * exactly as it would be submitted. A boundary the splitter drew through a quote, a group, a block or a heredoc + * leaves a fragment the shell rejects, so the split is refused instead of shown. + * + * This does not decide what a command does, only that the pieces are whole. It fails closed: a shell that cannot + * be started, or that takes too long, means "not verified". + */ +const COMMAND: Record = { + bash: ['bash', '--noprofile', '--norc', '-n'], + zsh: ['zsh', '-f', '-n'], + fish: ['fish', '--no-config', '--no-execute'], +}; +const TIMEOUT_MS = 4000; +const CONCURRENCY = 8; + +export type BatchCheck = {ok: true} | {ok: false; reason: string}; + +function parses(shell: BatchShell, command: string): Promise<'yes' | 'no' | 'unavailable'> { + return new Promise(resolve => { + const [file, ...args] = COMMAND[shell]; + let settled = false; + const finish = (result: 'yes' | 'no' | 'unavailable') => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } }; + let child; + // Fish 3.7 on Linux cannot read Node's socket-backed stdin in parse-only mode. + // -c supplies data under --no-execute; it never evaluates the command. An OS argv + // size refusal fails closed through the ordinary unavailable path. + const script = shellSubmission(command, shell); + try { child = spawn(file!, shell === 'fish' ? [...args, '-c', script] : args, {stdio: ['pipe', 'ignore', 'ignore']}); } + catch { resolve('unavailable'); return; } + const timer = setTimeout(() => { child.kill('SIGKILL'); finish('unavailable'); }, TIMEOUT_MS); + child.once('error', () => finish('unavailable')); + child.once('close', code => finish(code === 0 ? 'yes' : 'no')); + child.stdin.once('error', () => { /* the shell may exit before reading it all; its status decides */ }); + child.stdin.end(shell === 'fish' ? undefined : `${script}\n`); + }); +} + +export async function verifyCommands(commands: readonly string[], shell: BatchShell): Promise { + let next = 0; + let failure: BatchCheck | undefined; + const worker = async () => { + while (!failure) { + const index = next++; + if (index >= commands.length) return; + const result = await parses(shell, commands[index]!); + if (result === 'no') failure ??= {ok: false, reason: `Command ${index + 1} is not complete ${shell} syntax by itself, so the paste was not split.`}; + else if (result === 'unavailable') failure ??= {ok: false, reason: `${shell} could not be asked to check the commands, so the paste was not split.`}; + } + }; + await Promise.all(Array.from({length: Math.min(CONCURRENCY, commands.length)}, worker)); + return failure ?? {ok: true}; +} diff --git a/src/output/references.ts b/src/output/references.ts new file mode 100644 index 00000000..a0076446 --- /dev/null +++ b/src/output/references.ts @@ -0,0 +1,127 @@ +import {findSourceReferences} from '../host/HostActions.js'; +import {stripAnsi} from '../util/text.js'; + +/** + * References in one command's stored output that can be acted on from its block menu: source locations, web links, + * Git commit ids (only in Git's own output) and Android serials (only in `adb devices`). Recognition is conservative, + * local and non-executing; it runs only when the person asks for a block's references, never while output arrives. + * + * Deterministic: file locations (`path:line[:column]`, the same finder `/open` uses), http(s) links without + * credentials. Heuristic, so limited to the command that prints them: commit ids and device serials. + */ +export type ReferenceKind = 'file' | 'url' | 'commit' | 'device'; + +export interface OutputReference { + /** Stable for this output: kind and value, so an action resolves the same reference again when it runs. */ + id: string; + kind: ReferenceKind; + /** The path as printed (files), the link, the commit id or the serial. */ + value: string; + /** What the menu shows: the reference as it was printed. */ + label: string; + line?: number; + column?: number; + /** Devices: the adb state printed next to the serial; only `device` can take a follow-up command. */ + state?: string; +} + +/** Output lines read, counted from the end, and references kept: a bound for any output size. */ +export const MAX_REFERENCE_LINES = 20_000; +export const MAX_REFERENCES = 50; +const MAX_VALUE_LENGTH = 2000; + +/** Nothing a terminal or a reader could misread: no control, format (zero-width, bidi) or line/paragraph separator characters. */ +const clean = (text: string) => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(text); + +const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`\u007f-\u009f]+/giu; +const PAIRS: Record = {')': '(', ']': '[', '}': '{'}; + +function linkReference(raw: string): OutputReference | undefined { + let text = raw; + // Sentence punctuation after a link is not part of it; a closing bracket is only when the link opened one. + for (;;) { + const last = text.at(-1) ?? ''; + const opener = PAIRS[last]; + const count = (char: string) => text.split(char).length - 1; + if (/[.,;:!?'">]/u.test(last) || (opener && count(opener) < count(last))) text = text.slice(0, -1); + else break; + } + if (!text || text.length > MAX_VALUE_LENGTH || !clean(text)) return undefined; + let url: URL; + try { url = new URL(text); } catch { return undefined; } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + // Credentials in a link are a secret, not a destination. + if (url.username || url.password || !url.hostname) return undefined; + // The normalised form shows an international name as punycode, so a look-alike host cannot pass as a familiar one. + return {id: `url:${url.href}`, kind: 'url', value: url.href, label: url.href}; +} + +const GIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:command\s+|noglob\s+)?git(?:\s|$)/u; +const ADB_DEVICES = /^\s*adb(?:\s+-\S+(?:\s+\S+)?)*\s+devices(?:\s+-l)?\s*$/u; +const COMMIT_ID = /^[0-9a-f]{7,40}$/u; +const isCommitId = (token: string) => COMMIT_ID.test(token) && /\d/u.test(token) && /[a-f]/u.test(token); +const COMMIT_CONTEXTS = [ + /^[\s*|\\/_]*commit\s+([0-9a-f]{7,40})\b/u, + /^[\s*|\\/_]*([0-9a-f]{7,40})(?:\s|$)/u, + /^\[[^\]\n]*\s([0-9a-f]{7,40})\]/u, + /^(?:HEAD is now at|Merge:)\s+([0-9a-f]{7,40})\b/u, +]; + +const SERIAL = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,79}$/u; +const DEVICE_LINE = /^(\S+)\s+(device|offline|unauthorized|recovery|sideload|bootloader|no permissions|authorizing|connecting|host)\b/u; + +export interface ReferenceSource { + command: string; + output: string; +} + +/** The references in this command's output, in the order they were printed, without duplicates. */ +export function findOutputReferences(record: ReferenceSource): OutputReference[] { + const found: OutputReference[] = []; + const seen = new Set(); + const add = (reference: OutputReference) => { + if (found.length >= MAX_REFERENCES || seen.has(reference.id)) return; + seen.add(reference.id); + found.push(reference); + }; + const isGit = GIT_COMMAND.test(record.command); + const isDevices = ADB_DEVICES.test(record.command); + const lines = stripAnsi(record.output).split(/\r\n?|\n/u); + for (const line of lines.slice(-MAX_REFERENCE_LINES)) { + if (found.length >= MAX_REFERENCES) break; + if (line.length > 4000) continue; + for (const match of line.matchAll(URL_PATTERN)) { + const reference = linkReference(match[0]); + if (reference) add(reference); + } + for (const source of findSourceReferences(line)) { + // Dotted numbers (versions, IP addresses with a port) look like "name.ext:line" but are not files. + if (/^\d+(?:\.\d+)+$/u.test(source.path) || !clean(source.path) || source.path.length > MAX_VALUE_LENGTH || !source.line || source.line < 1) continue; + add({id: `file:${source.path}:${source.line}:${source.column ?? ''}`, kind: 'file', value: source.path, label: `${source.path}:${source.line}${source.column ? `:${source.column}` : ''}`, + line: source.line, ...(source.column ? {column: source.column} : {})}); + } + if (isGit) { + for (const pattern of COMMIT_CONTEXTS) { + const id = pattern.exec(line)?.[1]; + if (id && isCommitId(id)) { add({id: `commit:${id}`, kind: 'commit', value: id, label: id}); break; } + } + } + if (isDevices) { + const device = DEVICE_LINE.exec(line); + if (device && SERIAL.test(device[1]!) && clean(device[1]!)) add({id: `device:${device[1]}`, kind: 'device', value: device[1]!, label: `${device[1]} (${device[2]})`, state: device[2]!}); + } + } + return found; +} + +/** + * A command to put in the composer for review (never run): inspect a commit, or open a shell on a connected device. + * undefined when the reference has no follow-up. + */ +export function referenceFollowUp(reference: OutputReference): string[] | undefined { + if (reference.kind === 'commit' && isCommitId(reference.value)) return ['git', 'show', reference.value]; + if (reference.kind === 'device' && reference.state === 'device' && SERIAL.test(reference.value)) return ['adb', '-s', reference.value, 'shell']; + return undefined; +} + +export const REFERENCE_KIND_LABELS: Record = {file: 'File', url: 'Link', commit: 'Commit', device: 'Device'}; diff --git a/src/pins/PinStore.ts b/src/pins/PinStore.ts new file mode 100644 index 00000000..fed3fb6b --- /dev/null +++ b/src/pins/PinStore.ts @@ -0,0 +1,224 @@ +import {closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync} from 'node:fs'; +import {randomBytes} from 'node:crypto'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {scanSensitive} from '../clipboard/report.js'; + +/** + * Pins and recipes: commands the person chose to keep, in one file they can read (`pins.json`, next to config.json). + * + * - Written only by an explicit save, rename, scope change or delete. Nothing is pinned by itself, and command + * output, clipboard text and history are never stored here. + * - A command that looks like it contains a secret is refused, not stored: use a {{placeholder}} for the secret part. + * - Every change re-reads the file under a lock and replaces it with one atomic rename, so two NMSh windows cannot + * overwrite each other's saves, and a crash leaves the old file or the new one, never half of one. + * - A file this version cannot read is reported and left alone, never replaced. + */ +export type PinScope = 'global' | 'directory'; + +export interface Pin { + id: string; + command: string; + /** Optional human name; empty when the person gave none. */ + name: string; + scope: PinScope; + /** The directory the command was pinned from; `directory` pins show first there. */ + cwd?: string; + createdAt: number; +} + +export interface Recipe { + id: string; + name: string; + description: string; + /** Commands in order; each runs as one queue entry. `{{name}}` marks a value asked for when the recipe is used. */ + steps: string[]; + scope: PinScope; + cwd?: string; + createdAt: number; +} + +export interface PinData { + version: 1; + pins: Pin[]; + recipes: Recipe[]; +} + +export const MAX_PINS = 500; +export const MAX_RECIPES = 200; +export const MAX_STEPS = 100; +export const MAX_COMMAND_LENGTH = 64 * 1024; +export const MAX_NAME_LENGTH = 80; + +export const pinsPath = (env: NodeJS.ProcessEnv = process.env): string => join(nmshConfigDirectory(env), 'pins.json'); + +export type PinResult = {ok: true; value: T} | {ok: false; reason: string}; +const failure = (reason: string): {ok: false; reason: string} => ({ok: false, reason}); + +/** One line of plain text for a name: controls, format and bidi characters removed, spaces collapsed, bounded. */ +export function cleanName(text: string): string { + return text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, MAX_NAME_LENGTH); +} + +/** Why a command cannot be kept (a secret in it, or a size), or undefined when it can. */ +export function keepProblem(command: string): string | undefined { + if (!command.trim()) return 'There is nothing to keep.'; + if (command.length > MAX_COMMAND_LENGTH) return 'That command is too long to keep.'; + const secrets = scanSensitive(command).filter(item => item.kind === 'token' || item.kind === 'credential' || item.kind === 'private key'); + if (secrets.length) return 'That command looks like it contains a secret, so it was not saved. Use a {{placeholder}} for the secret part.'; + return undefined; +} + +const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const scopeOf = (value: unknown): PinScope => value === 'directory' ? 'directory' : 'global'; +const absolute = (value: unknown): string | undefined => typeof value === 'string' && value.startsWith('/') && value.length < 4096 && !/[\p{Cc}]/u.test(value) ? value : undefined; + +function parsePin(raw: unknown): Pin | undefined { + if (!isRecord(raw) || typeof raw.id !== 'string' || !/^[\w-]{1,40}$/u.test(raw.id) || typeof raw.command !== 'string' || keepProblem(raw.command) === 'There is nothing to keep.' || raw.command.length > MAX_COMMAND_LENGTH) return undefined; + const cwd = absolute(raw.cwd); + const scope = scopeOf(raw.scope); + return {id: raw.id, command: raw.command, name: typeof raw.name === 'string' ? cleanName(raw.name) : '', scope: scope === 'directory' && !cwd ? 'global' : scope, ...(cwd ? {cwd} : {}), + createdAt: Number.isSafeInteger(raw.createdAt) ? raw.createdAt as number : 0}; +} + +function parseRecipe(raw: unknown): Recipe | undefined { + if (!isRecord(raw) || typeof raw.id !== 'string' || !/^[\w-]{1,40}$/u.test(raw.id) || !Array.isArray(raw.steps) || !raw.steps.length || raw.steps.length > MAX_STEPS) return undefined; + const steps = raw.steps.filter((step): step is string => typeof step === 'string' && step.trim() !== '' && step.length <= MAX_COMMAND_LENGTH); + const name = typeof raw.name === 'string' ? cleanName(raw.name) : ''; + if (steps.length !== raw.steps.length || !name) return undefined; + const cwd = absolute(raw.cwd); + const scope = scopeOf(raw.scope); + return {id: raw.id, name, description: typeof raw.description === 'string' ? cleanName(raw.description) : '', steps, scope: scope === 'directory' && !cwd ? 'global' : scope, ...(cwd ? {cwd} : {}), + createdAt: Number.isSafeInteger(raw.createdAt) ? raw.createdAt as number : 0}; +} + +/** The stored data, or why it cannot be used. A missing file is simply empty. */ +export function loadPins(path = pinsPath()): PinResult { + let text: string; + try { text = readFileSync(path, 'utf8'); } + catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? {ok: true, value: {version: 1, pins: [], recipes: []}} : failure(`Could not read ${path}.`); } + let raw: unknown; + try { raw = JSON.parse(text); } catch { return failure(`${path} is not valid JSON, so it was left alone.`); } + if (!isRecord(raw) || raw.version !== 1) return failure(`${path} is from a different version, so it was left alone.`); + const pins = (Array.isArray(raw.pins) ? raw.pins : []).map(parsePin).filter((pin): pin is Pin => Boolean(pin)).slice(0, MAX_PINS); + const recipes = (Array.isArray(raw.recipes) ? raw.recipes : []).map(parseRecipe).filter((recipe): recipe is Recipe => Boolean(recipe)).slice(0, MAX_RECIPES); + return {ok: true, value: {version: 1, pins, recipes}}; +} + +const LOCK_STALE_MS = 10_000; + +function withLock(path: string, work: () => T): T { + const lock = `${path}.lock`; + const deadline = Date.now() + 3000; + for (;;) { + try { const handle = openSync(lock, 'wx', 0o600); writeSync(handle, String(process.pid)); closeSync(handle); break; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) rmSync(lock, {force: true}); } catch { /* released meanwhile */ } + if (Date.now() > deadline) throw new Error('pins.json is being changed by another NMSh window; try again.'); + const until = Date.now() + 20; + while (Date.now() < until) { /* brief wait; the lock is held for a few milliseconds */ } + } + } + try { return work(); } finally { rmSync(lock, {force: true}); } +} + +/** + * Apply a change to the stored data and save it atomically. `change` receives the freshly re-read data and returns + * the new data, or a reason to refuse (nothing is written then). + */ +export function updatePins(change: (data: PinData) => PinResult<{data: PinData; value: T}>, path = pinsPath()): PinResult { + try { + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + return withLock(path, () => { + const loaded = loadPins(path); + if (!loaded.ok) return loaded; + const changed = change(structuredClone(loaded.value)); + if (!changed.ok) return changed; + const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(changed.value.data, null, 2)}\n`, {mode: 0o600}); + renameSync(temporary, path); + } catch (error) { rmSync(temporary, {force: true}); throw error; } + return {ok: true, value: changed.value.value}; + }); + } catch (error) { return failure(error instanceof Error ? error.message : String(error)); } +} + +const newId = (prefix: string) => `${prefix}${Date.now().toString(36)}${randomBytes(3).toString('hex')}`; + +export function addPin(input: {command: string; name?: string; scope?: PinScope; cwd?: string}, path = pinsPath(), now = Date.now()): PinResult { + const problem = keepProblem(input.command); + if (problem) return failure(problem); + return updatePins(data => { + if (data.pins.length >= MAX_PINS) return failure(`NMSh keeps at most ${MAX_PINS} pins; remove one first.`); + const existing = data.pins.find(pin => pin.command === input.command && pin.scope === (input.scope ?? 'global') && (pin.scope === 'global' || pin.cwd === input.cwd)); + if (existing) return failure('That command is already pinned.'); + const cwd = absolute(input.cwd); + const scope = input.scope === 'directory' && cwd ? 'directory' : 'global'; + const pin: Pin = {id: newId('p'), command: input.command, name: cleanName(input.name ?? ''), scope, ...(cwd ? {cwd} : {}), createdAt: now}; + return {ok: true, value: {data: {...data, pins: [pin, ...data.pins]}, value: pin}}; + }, path); +} + +export function addRecipe(input: {name: string; description?: string; steps: readonly string[]; scope?: PinScope; cwd?: string}, path = pinsPath(), now = Date.now()): PinResult { + const name = cleanName(input.name); + if (!name) return failure('A recipe needs a name.'); + if (!input.steps.length) return failure('A recipe needs at least one command.'); + if (input.steps.length > MAX_STEPS) return failure(`A recipe holds at most ${MAX_STEPS} commands.`); + for (const step of input.steps) { const problem = keepProblem(step); if (problem) return failure(problem); } + return updatePins(data => { + if (data.recipes.length >= MAX_RECIPES) return failure(`NMSh keeps at most ${MAX_RECIPES} recipes; remove one first.`); + if (data.recipes.some(recipe => recipe.name.toLowerCase() === name.toLowerCase())) return failure(`A recipe named "${name}" already exists.`); + const cwd = absolute(input.cwd); + const scope = input.scope === 'directory' && cwd ? 'directory' : 'global'; + const recipe: Recipe = {id: newId('r'), name, description: cleanName(input.description ?? ''), steps: [...input.steps], scope, ...(cwd ? {cwd} : {}), createdAt: now}; + return {ok: true, value: {data: {...data, recipes: [recipe, ...data.recipes]}, value: recipe}}; + }, path); +} + +export type PinChange = {name: string} | {scope: PinScope} | {steps: readonly string[]}; + +/** Rename, change scope, or (recipes) replace the steps of the pin or recipe with this id. */ +export function changeItem(id: string, change: PinChange, path = pinsPath()): PinResult { + return updatePins(data => { + const pin = data.pins.find(item => item.id === id); + const recipe = data.recipes.find(item => item.id === id); + const item = pin ?? recipe; + if (!item) return failure('That item no longer exists.'); + if ('name' in change) { + const name = cleanName(change.name); + if (recipe && !name) return failure('A recipe needs a name.'); + if (recipe && data.recipes.some(other => other.id !== id && other.name.toLowerCase() === name.toLowerCase())) return failure(`A recipe named "${name}" already exists.`); + item.name = name; + } else if ('scope' in change) { + if (change.scope === 'directory' && !item.cwd) return failure('It was not pinned from a directory, so it can only be global.'); + item.scope = change.scope; + } else { + if (!recipe) return failure('Only a recipe has steps.'); + if (!change.steps.length || change.steps.length > MAX_STEPS) return failure(`A recipe holds 1 to ${MAX_STEPS} commands.`); + for (const step of change.steps) { const problem = keepProblem(step); if (problem) return failure(problem); } + recipe.steps = [...change.steps]; + } + return {ok: true, value: {data, value: true as const}}; + }, path); +} + +export function removeItem(id: string, path = pinsPath()): PinResult { + return updatePins(data => { + const pins = data.pins.filter(item => item.id !== id); + const recipes = data.recipes.filter(item => item.id !== id); + if (pins.length === data.pins.length && recipes.length === data.recipes.length) return failure('That item no longer exists.'); + return {ok: true, value: {data: {...data, pins, recipes}, value: true as const}}; + }, path); +} + +/** A directory item applies in its own directory and inside it; a global one applies everywhere. */ +export const applies = (item: {scope: PinScope; cwd?: string}, cwd: string): boolean => + item.scope === 'global' || (item.cwd !== undefined && (cwd === item.cwd || cwd.startsWith(`${item.cwd.replace(/\/+$/u, '')}/`))); + +/** What to show in a directory: the items that apply there (this directory's own first), newest first within each; `all` shows every item. */ +export function inScope(items: readonly T[], cwd: string, all = false): T[] { + const here = (item: T) => item.scope === 'directory' && item.cwd !== undefined && applies(item, cwd); + return items.filter(item => all || applies(item, cwd)).sort((a, b) => Number(here(b)) - Number(here(a)) || b.createdAt - a.createdAt); +} diff --git a/src/pins/PinsPanel.ts b/src/pins/PinsPanel.ts new file mode 100644 index 00000000..43d588a9 --- /dev/null +++ b/src/pins/PinsPanel.ts @@ -0,0 +1,199 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {displaySafe} from '../input/PasteReview.js'; +import {inScope, type Pin, type PinData, type Recipe} from './PinStore.js'; +import {placeholdersIn, valueProblem} from './recipes.js'; +import {displayWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; + +/** + * `/pins`: the commands and recipes the person chose to keep, as one keyboard-first list. It stores nothing itself + * (every change is an action the app applies to pins.json) and runs nothing: Enter on a pin puts its command in the + * composer for review, and Enter on a recipe asks for its values, shows the expanded commands, and only queues them + * when the person confirms there. + */ +export type PanelItem = {kind: 'pin'; pin: Pin} | {kind: 'recipe'; recipe: Recipe}; + +export interface PinsPanelState { + data: PinData; + cwd: string; + /** Show items kept for other directories too. */ + all: boolean; + selected: number; + mode: 'list' | 'rename' | 'delete' | 'fill' | 'name'; + /** The text being typed in rename / fill / name. */ + input: string; + /** Asking for a recipe's values, one at a time. */ + fill?: {recipe: Recipe; names: string[]; index: number; values: Record}; + /** Steps waiting for a name (a queue saved as a recipe). */ + naming?: {steps: string[]}; + /** What the last action did or why it could not. */ + message?: {text: string; error: boolean}; + /** The composer has text a new pin could keep; an editor can edit a recipe. */ + composerText: string; + canEdit: boolean; +} + +export type PinsPanelAction = + | {kind: 'close'} + | {kind: 'stage'; command: string} + | {kind: 'use'; recipe: Recipe; values: Record} + | {kind: 'rename'; id: string; name: string} + | {kind: 'scope'; id: string; scope: 'global' | 'directory'} + | {kind: 'delete'; id: string} + | {kind: 'pinComposer'} + | {kind: 'edit'; recipe: Recipe} + | {kind: 'saveRecipe'; name: string; steps: string[]}; + +export function createPinsPanel(data: PinData, cwd: string, options: {composerText?: string; canEdit?: boolean; naming?: string[]} = {}): PinsPanelState { + const state: PinsPanelState = {data, cwd, all: false, selected: 0, mode: 'list', input: '', composerText: options.composerText ?? '', canEdit: options.canEdit ?? false}; + if (options.naming?.length) { state.mode = 'name'; state.naming = {steps: options.naming}; } + return state; +} + +export function panelItems(state: PinsPanelState): PanelItem[] { + const pins = inScope(state.data.pins, state.cwd, state.all).map((pin): PanelItem => ({kind: 'pin', pin})); + const recipes = inScope(state.data.recipes, state.cwd, state.all).map((recipe): PanelItem => ({kind: 'recipe', recipe})); + return [...recipes, ...pins]; +} + +/** Items kept for other directories and not shown, for the header. */ +export const hiddenCount = (state: PinsPanelState): number => state.data.pins.length + state.data.recipes.length - panelItems({...state, all: false}).length; + +const idOf = (item: PanelItem) => item.kind === 'pin' ? item.pin.id : item.recipe.id; +const text = (key: Key): string | undefined => key.kind === 'text' ? key.value : key.kind === 'paste' ? key.value.replace(/\s+/gu, ' ') : undefined; + +export function pinsPanelKey(state: PinsPanelState, key: Key): PinsPanelAction | undefined { + const items = panelItems(state); + state.selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const item = items[state.selected]; + if (state.mode === 'delete') { + state.mode = 'list'; + if (key.kind === 'text' && key.value.toLowerCase() === 'y' && item) return {kind: 'delete', id: idOf(item)}; + return undefined; + } + if (state.mode === 'rename' || state.mode === 'name' || state.mode === 'fill') return inputKey(state, key, item); + state.message = undefined; + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { + if (items.length) state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + items.length) % items.length; + return undefined; + } + if (key.kind === 'text' && key.value === 'a') { state.all = !state.all; state.selected = 0; return undefined; } + if (key.kind === 'text' && key.value === 'n') return state.composerText.trim() ? {kind: 'pinComposer'} : (state.message = {text: 'Type or recall a command in the composer first; n pins what is there.', error: false}, undefined); + if (!item) return undefined; + if (key.kind === 'enter') { + if (item.kind === 'pin') return {kind: 'stage', command: item.pin.command}; + const names = placeholdersIn(item.recipe.steps); + if (!names.length) return {kind: 'use', recipe: item.recipe, values: {}}; + state.mode = 'fill'; + state.fill = {recipe: item.recipe, names, index: 0, values: {}}; + state.input = ''; + return undefined; + } + if (key.kind === 'text' && key.value === 'r') { state.mode = 'rename'; state.input = item.kind === 'pin' ? item.pin.name : item.recipe.name; return undefined; } + if (key.kind === 'text' && key.value === 's' && item.kind === 'pin') { + return {kind: 'scope', id: item.pin.id, scope: item.pin.scope === 'global' ? 'directory' : 'global'}; + } + if (key.kind === 'text' && key.value === 's' && item.kind === 'recipe') return {kind: 'scope', id: item.recipe.id, scope: item.recipe.scope === 'global' ? 'directory' : 'global'}; + if (key.kind === 'text' && key.value === 'e' && item.kind === 'recipe' && state.canEdit) return {kind: 'edit', recipe: item.recipe}; + if (key.kind === 'delete' || key.kind === 'backspace' || (key.kind === 'text' && key.value === 'x')) { state.mode = 'delete'; return undefined; } + return undefined; +} + +function inputKey(state: PinsPanelState, key: Key, item: PanelItem | undefined): PinsPanelAction | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') { + state.mode = 'list'; + state.input = ''; + state.fill = undefined; + state.naming = undefined; + return undefined; + } + if (key.kind === 'backspace') { state.input = Array.from(state.input).slice(0, -1).join(''); return undefined; } + const typed = text(key); + if (typed !== undefined) { state.input = (state.input + typed.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')).slice(0, 500); return undefined; } + if (key.kind !== 'enter') return undefined; + const value = state.input.trim(); + if (state.mode === 'rename' && item) { + state.mode = 'list'; + state.input = ''; + return {kind: 'rename', id: idOf(item), name: value}; + } + if (state.mode === 'name' && state.naming) { + const steps = state.naming.steps; + state.mode = 'list'; + state.naming = undefined; + state.input = ''; + return {kind: 'saveRecipe', name: value, steps}; + } + if (state.mode === 'fill' && state.fill) { + const problem = valueProblem(value); + if (problem) { state.message = {text: problem, error: true}; return undefined; } + state.message = undefined; + const fill = state.fill; + fill.values[fill.names[fill.index]!] = value; + fill.index += 1; + state.input = ''; + if (fill.index >= fill.names.length) { state.mode = 'list'; state.fill = undefined; return {kind: 'use', recipe: fill.recipe, values: fill.values}; } + } + return undefined; +} + +function label(item: PanelItem): string { + // Drawn text only: control and format characters are made visible wherever the label is used. + return displaySafe(item.kind === 'recipe' ? item.recipe.name : item.pin.name || item.pin.command.split('\n')[0]!.trim()); +} + +/** The panel's rows (unframed). */ +export function renderPinsPanel(state: PinsPanelState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const failure = foreground(UI_COLORS.failure); + const reset = '\u001b[0m'; + const width = Math.max(10, columns - 4); + const items = panelItems(state); + const hidden = hiddenCount(state); + const out = [` ${primary}Pins and recipes${reset} ${subtle}${truncateText(`${items.length} here${hidden && !state.all ? `, ${hidden} kept for other folders (a)` : ''}`, Math.max(4, width - 20))}${reset}`, '']; + if (!items.length) out.push(` ${secondary}${truncateText(state.all ? 'Nothing kept yet.' : 'Nothing kept for this folder. a shows the other folders\' items.', width)}${reset}`, + ` ${subtle}${truncateText('Pin a command from its block (Actions → Pin command), or n pins what is in the composer. A recipe is saved from the queue.', width)}${reset}`); + const controls = renderControlRows(state.mode === 'list' + ? [['↑↓', 'select'], ['Enter', 'use'], ['r', 'rename'], ['s', 'scope'], ['x', 'remove'], ['n', 'pin composer'], ['a', 'all folders'], ...(state.canEdit ? [['e', 'edit recipe'] as const] : []), ['Esc', 'close']] + : [['Enter', state.mode === 'fill' ? 'next' : 'save'], ['Esc', 'cancel']], width); + const listRoom = Math.max(1, height - out.length - controls.length - 7); + const first = Math.max(0, Math.min(state.selected - Math.floor(listRoom / 2), items.length - listRoom)); + items.slice(first, first + listRoom).forEach((item, offset) => { + const index = first + offset; + const selected = index === state.selected; + const owner = item.kind === 'pin' ? item.pin : item.recipe; + const kind = item.kind === 'recipe' ? `recipe · ${item.recipe.steps.length} step${item.recipe.steps.length === 1 ? '' : 's'}` : 'pin'; + const where = owner.scope === 'directory' ? ' · this folder' : ''; + const tail = `${kind}${where}`; + const body = truncateText(displaySafe(label(item)), Math.max(8, width - displayWidth(tail) - 6)); + out.push(truncateAnsi(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${focusForeground(selected)}${padCells(body, Math.max(8, width - displayWidth(tail) - 4))}${reset} ${subtle}${tail}${reset}`, columns)); + }); + const selected = items[state.selected]; + if (selected && state.mode === 'list') { + out.push(''); + if (selected.kind === 'pin') { + out.push(` ${subtle}Stays as written; Enter puts it in the composer, it does not run:${reset}`); + for (const line of selected.pin.command.split('\n').slice(0, 4)) out.push(` ${secondary}${truncateText(displaySafe(line), width - 2)}${reset}`); + if (selected.pin.cwd) out.push(` ${subtle}${truncateText(`Pinned in ${selected.pin.cwd}`, width)}${reset}`); + } else { + const names = placeholdersIn(selected.recipe.steps); + out.push(` ${subtle}${truncateText(`Enter asks for ${names.length ? names.map(name => `{{${name}}}`).join(', ') : 'nothing'}, then shows the commands before anything is queued:`, width)}${reset}`); + for (const [index, step] of selected.recipe.steps.slice(0, 3).entries()) out.push(` ${secondary}${truncateText(`${index + 1} ${displaySafe(step.split('\n')[0]!)}`, width - 2)}${reset}`); + if (selected.recipe.steps.length > 3) out.push(` ${subtle}… ${selected.recipe.steps.length - 3} more${reset}`); + } + } + out.push(''); + if (state.mode === 'delete' && selected) out.push(` ${failure}Remove “${truncateText(label(selected), Math.max(4, width - 24))}”? y / n${reset}`); + else if (state.mode === 'rename') out.push(` ${accent}Name${reset} ${state.input}${accent}▏${reset}${subtle} (empty clears a pin's name)${reset}`); + else if (state.mode === 'name') out.push(` ${accent}Recipe name${reset} ${state.input}${accent}▏${reset}${subtle} (${state.naming?.steps.length ?? 0} command${state.naming?.steps.length === 1 ? '' : 's'})${reset}`); + else if (state.mode === 'fill' && state.fill) out.push(` ${accent}{{${state.fill.names[state.fill.index]}}}${reset} ${state.input}${accent}▏${reset}${subtle} (${state.fill.index + 1} of ${state.fill.names.length}; used as one quoted word)${reset}`); + if (state.message) out.push(` ${state.message.error ? failure : subtle}${truncateText(state.message.text, width)}${reset}`); + out.push(...controls.map(row => ` ${row}`)); + return out.map(row => truncateAnsi(row, columns)).slice(0, Math.max(4, height)); +} diff --git a/src/pins/recipes.ts b/src/pins/recipes.ts new file mode 100644 index 00000000..03788b0d --- /dev/null +++ b/src/pins/recipes.ts @@ -0,0 +1,64 @@ +import {posixQuote} from '../shell/adapters/ShellAdapter.js'; + +/** + * Recipe placeholders: `{{name}}` in a step marks a value asked for when the recipe is used. The value is put into + * the command as ONE shell word, quoted for the shell that will run it, so it can never add an option, a second + * command or an expansion. Nothing is taken from the environment, and nothing but the person's answer fills a + * placeholder. + */ +export const PLACEHOLDER = /\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/gu; +export const MAX_VALUE_LENGTH = 500; + +/** The placeholder names in these steps, in order of first use. */ +export function placeholdersIn(steps: readonly string[]): string[] { + const names: string[] = []; + for (const step of steps) for (const match of step.matchAll(PLACEHOLDER)) if (!names.includes(match[1]!)) names.push(match[1]!); + return names; +} + +/** + * A placeholder inside quotes would end up quoted twice. Returns the first such placeholder's name, or undefined. + * The scan follows shell quoting: single quotes, double quotes and backslashes (a backtick or `$( )` is not tracked, + * which can only miss, never invent, a problem). + */ +export function quotedPlaceholder(step: string): string | undefined { + let quote: '"' | '\'' | undefined; + for (let index = 0; index < step.length; index += 1) { + const char = step[index]!; + if (quote === '\'') { + if (char === '\'') quote = undefined; + else if (step.startsWith('{{', index)) { const match = /^\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/u.exec(step.slice(index)); if (match) return match[1]; } + continue; + } + if (char === '\\') { index += 1; continue; } + if (quote === '"') { if (char === '"') { quote = undefined; continue; } } + else if (char === '"' || char === '\'') { quote = char; continue; } + if (quote && step.startsWith('{{', index)) { const match = /^\{\{([A-Za-z][A-Za-z0-9_]{0,31})\}\}/u.exec(step.slice(index)); if (match) return match[1]; } + } + return undefined; +} + +/** Why an answer cannot be used as a command argument, or undefined when it can. */ +export function valueProblem(value: string): string | undefined { + if (!value) return 'Enter a value.'; + if (value.length > MAX_VALUE_LENGTH) return `Keep it under ${MAX_VALUE_LENGTH} characters.`; + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return 'It cannot contain line breaks or control characters.'; + if (value.startsWith('-')) return 'It cannot start with "-": the command would read it as an option. Use ./ in front of a file name.'; + return undefined; +} + +/** The steps with every placeholder replaced by its quoted value; a placeholder with no value is an error, never left in. */ +export function expandSteps(steps: readonly string[], values: Readonly>, quote: (value: string) => string = posixQuote): {ok: true; steps: string[]} | {ok: false; reason: string} { + const expanded: string[] = []; + for (const step of steps) { + let missing: string | undefined; + const text = step.replace(PLACEHOLDER, (_match, name: string) => { + const value = values[name]; + if (value === undefined || valueProblem(value)) { missing ??= name; return ''; } + return quote(value); + }); + if (missing) return {ok: false, reason: `No usable value for {{${missing}}}.`}; + expanded.push(text); + } + return {ok: true, steps: expanded}; +} diff --git a/src/prompt/ModulesPanel.ts b/src/prompt/ModulesPanel.ts index df205d2e..9605623d 100644 --- a/src/prompt/ModulesPanel.ts +++ b/src/prompt/ModulesPanel.ts @@ -44,7 +44,11 @@ export interface ModulesManagerState { search?: {query: string; editing: boolean}; /** Pack modules shown or hidden during this visit stay listed under Modules, so a row never vanishes under the cursor. */ touched?: string[]; - detail?: {kind: 'module' | 'pack'; id: string}; + detail?: { + kind: 'module' | 'pack'; id: string; scroll?: number; + /** Ephemeral bounds from the rendered detail window; never saved as settings. */ + viewport?: {maxScroll: number; page: number}; + }; /** A reviewed change awaiting Enter (Claude Code settings edit, pack removal). */ confirm?: {request: ModulesRequest; title: string; lines: string[]}; request?: ModulesRequest; @@ -69,7 +73,9 @@ export interface PackListing { } export interface ModulesContext { - status?(capability: string): {state: string; collectedAt?: number; evidence?: string; error?: string}; + /** Existing live display context; a false result is distinct from unavailable collection. */ + conditionMet?(module: ContextModuleConfig): boolean; + status?(capability: string): {state: string; collectedAt?: number; evidence?: string; error?: string; durationMs?: number; hasValue?: boolean}; packs?: readonly PackListing[]; recommendations?: readonly Recommendation[]; claudeBridge?: {state: string; detail?: string}; @@ -257,6 +263,13 @@ export function handleModulesManagerKey(key: Key, state: ModulesState, context: } if (state.detail) { if (key.kind === 'escape' || key.kind === 'interrupt') { state.detail = undefined; return true; } + if (key.kind === 'up' || key.kind === 'down' || key.kind === 'pageUp' || key.kind === 'pageDown') { + const detail = state.detail; + const amount = key.kind === 'pageUp' || key.kind === 'pageDown' ? detail.viewport?.page ?? 1 : 1; + const direction = key.kind === 'up' || key.kind === 'pageUp' ? -1 : 1; + detail.scroll = Math.max(0, Math.min(detail.viewport?.maxScroll ?? 0, (detail.scroll ?? 0) + direction * amount)); + return true; + } if (state.detail.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); if (pack && !pack.builtIn && key.kind === 'text' && /^[eE]$/u.test(key.value)) { state.request = {kind: 'packEnabled', id: pack.id, enabled: pack.state !== 'enabled'}; return true; } @@ -364,7 +377,7 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, rows.push(truncateAnsi(`${PRIMARY}${clean(definition.label, 48)}${RESET} ${SUBTLE}${CATEGORY_LABELS[definition.category]}${narrow ? '' : ` · ${origin}`}${RESET}`, columns), ...(narrow ? wrapped(origin, columns, SUBTLE) : []), ...wrapped(clean(definition.description, 240), columns, SECONDARY, '', 4), ''); const surface = routeModule({...module, visible: true}); - rows.push(...line('Shown', `${module.visible ? 'yes' : 'no'} · ${CONDITION_TEXT[module.condition]} · ${surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]}` + rows.push(...line('Enabled', `${module.visible ? 'yes' : 'no'} · ${CONDITION_TEXT[module.condition]} · ${surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]}` + `${module.surface === 'auto' || module.surface === undefined ? '' : ` (prefers ${MODULE_SURFACE_LABELS[definition.preferredSurface as ModuleSurface]})`}`)); if (surface === 'statusStrip') rows.push(...line('Strip group', `${({left: 'Left', center: 'Center', right: 'Right'})[module.stripZone ?? 'right']} · /strip arranges groups and order`)); if (surface !== 'hidden') { @@ -378,20 +391,35 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, return capability ? capability.reads.map(read => `${capability.title}: ${read}`) : LEGACY_READS[id] ? [LEGACY_READS[id]!] : []; }); rows.push(`${SUBTLE}Reads${RESET}`, ...[...new Set(reads)].flatMap(read => wrapped(read, columns - 2, SECONDARY, ' ', 4).map((text, index) => index ? ` ${text}` : text.replace(/^ {2}/u, ' · ')))); - rows.push(...wrapped('Nothing is executed from the workspace, no network is used, and secret values are never read.', columns, SECONDARY, ' ')); + rows.push(...wrapped('Nothing is executed from the workspace and no network is used. Only approved metadata is exposed; secret values do not belong in module output or history.', columns, SECONDARY, ' ')); const capabilities = [...definition.facts.keys()].map(id => coreCapability(id)).filter(Boolean); const history = capabilities.length && capabilities.every(capability => capability!.persistence === 'snapshot-safe'); const privateFields = capabilities.flatMap(capability => Object.entries(capability!.fieldPolicy ?? {}).filter(([, policy]) => policy.persistence !== 'snapshot-safe').map(([field]) => field)); rows.push(...line('History', definition.pack ? (history ? `may be kept with commands when shown in the Main Prompt${privateFields.length ? ` (never: ${privateFields.join(', ')})` : ''}` : 'live only; never kept in command history') : 'kept with commands when shown in the Main Prompt')); for (const capability of capabilities) { + // Declarative policy and existing snapshots only: opening details never demands a collector. + rows.push(...line('Source', clean(`${capability!.id} · ${capability!.scope} scope · ${capability!.trust}`, 200))); + const ttl = `${capability!.ttlMs} ms freshness TTL`; + const invalidation = capability!.invalidateOn.length ? `invalidated by ${capability!.invalidateOn.join(', ')}` : 'no event invalidation'; + const refresh = capability!.refreshMs === undefined ? '' : ` · refresh every ${capability!.refreshMs} ms while demanded`; + rows.push(...line('Cache', clean(`${ttl} · ${invalidation}${refresh}`, 240))); const status = context.status?.(capability!.id); if (!status) continue; const age = status.collectedAt !== undefined && context.now !== undefined ? ` · ${Math.max(0, Math.round((context.now - status.collectedAt) / 1000))}s ago` : ''; - const text = status.state === 'fresh' ? `fresh${age}` : status.state === 'stale' ? `last known${age}, refreshing` : status.state === 'absent' ? 'nothing to show here' - : status.state === 'pending' ? 'checking…' : status.state === 'timeout' || status.state === 'failed' ? `unavailable (${status.error ?? status.state})` : 'not checked (module not in use)'; - rows.push(...line(narrow ? capability!.title : capability!.title.slice(0, 11), clean(`${text}${status.evidence ? ` · ${status.evidence}` : ''}`, 200))); + const text = status.state === 'fresh' ? `fresh${age}` : status.state === 'stale' ? `last known${age}, refresh waits for demand` : status.state === 'absent' ? 'nothing to show here' + : status.state === 'pending' ? `checking…${status.hasValue ? ' · last known data retained' : ''}` + : status.state === 'timeout' || status.state === 'failed' ? `unavailable (${status.state})${status.hasValue ? ' · last known data retained' : ''}` : 'not checked'; + // How long the last collection took is measured by the engine; nothing is collected to show it. + const took = status.durationMs === undefined ? '' : ` · took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; + rows.push(...line(narrow ? capability!.title : capability!.title.slice(0, 11), clean(`${text}${took}${status.evidence ? ` · ${status.evidence}` : ''}`, 200))); } + if (capabilities.length) rows.push(...line('Timing', 'last collection wall time; excludes queue delay. Opening details does not collect data.')); + const availability = surface === 'hidden' ? undefined : surfaceAvailability(surface, configuration); + rows.push(...line('Why', !module.visible ? 'turned off in Modules' + : availability && !availability.available ? availability.reason + : context.conditionMet?.(module) === false ? `hidden: current condition does not match (${CONDITION_TEXT[module.condition]})` + : whyText(module, surface, capabilities.map(capability => context.status?.(capability!.id)).filter((status): status is NonNullable => Boolean(status)), definition.triggers))); if (definition.facts.has('agent.claude')) { const bridge = context.claudeBridge; rows.push('', ...line('Claude Code', bridge?.state === 'configured' ? 'reports to NMSh through its status line · B to review removal' @@ -401,6 +429,28 @@ function moduleDetailRows(module: ContextModuleConfig, context: ModulesContext, return rows; } +/** One sentence on why this module is or is not on screen right now, from its settings and what the engine reported. */ +export function whyText(module: ContextModuleConfig, surface: string, statuses: ReadonlyArray<{state: string; error?: string; hasValue?: boolean}>, triggers?: readonly string[]): string { + if (!module.visible) return 'turned off in Modules'; + if (surface === 'hidden') return 'routed to Hidden'; + const failed = statuses.filter(status => status.state === 'timeout' || status.state === 'failed'); + if (failed.length) { + const reason = failed.some(status => status.state === 'timeout') ? 'its data timed out' : 'its data could not be read; collection failed'; + const cache = failed.some(status => status.hasValue) ? 'last known data is retained; no new value was collected' + : failed.every(status => status.hasValue === false) ? 'no cached value is available' : 'cached-data availability is unknown'; + return `${reason}; ${cache}`; + } + if (statuses.length && statuses.every(status => status.state === 'absent')) return 'there is nothing for it to show in this directory'; + if (statuses.length && statuses.every(status => status.state === 'idle') && module.condition === 'onCommand') { + return `shown after a command that uses it${triggers?.length ? ` (${clean(triggers.slice(0, 4).join(', '), 60)}…)` : ''}; no collection evidence is available yet`; + } + if (statuses.some(status => status.state === 'pending')) return 'being collected now'; + if (statuses.some(status => status.state === 'stale')) return 'last known data is available; refresh waits for demand. Visibility follows the module condition and available space'; + if (statuses.length && statuses.every(status => status.state === 'idle')) return 'not collected yet; only modules in use demand collection'; + if (statuses.some(status => status.state === 'fresh')) return 'data is available; visibility follows the module condition and available space'; + return 'collection evidence is not available; visibility follows the module condition and available space'; +} + function packDetailRows(pack: PackListing, columns: number): string[] { const rows = [truncateAnsi(`${PRIMARY}${clean(pack.name, 48)}${RESET} ${SUBTLE}${clean(pack.id, 64)} ${clean(pack.version, 48)} · ${pack.builtIn ? 'bundled with NMSh' : clean(pack.state, 24)}${RESET}`, columns), truncateAnsi(`${SECONDARY}${clean(pack.description, 240)}${RESET}`, columns), '']; @@ -434,17 +484,34 @@ function whereText(module: ContextModuleConfig, configuration: PromptConfigurati const label = (id: string) => clean(moduleDefinition(id)?.label ?? `${id.slice(0, 40)} (missing pack)`, 64); /** A window of list rows around the selection, with counts of what is above and below. */ -function windowed(rows: string[], selectedRow: number, budget: number): string[] { - if (rows.length <= budget || budget < 5) return rows; +function windowed(rows: string[], selectedRow: number, budget: number, fromStart = false): string[] { + if (rows.length <= budget) return rows; + if (budget < 5) { + const start = Math.max(0, Math.min(rows.length - Math.max(0, budget), selectedRow)); + return rows.slice(start, start + Math.max(0, budget)); + } const size = budget - 2; - const start = Math.max(0, Math.min(rows.length - size, selectedRow - Math.floor(size / 2))); + const start = Math.max(0, Math.min(rows.length - size, selectedRow - (fromStart ? 0 : Math.floor(size / 2)))); const end = start + size; return [start ? `${SUBTLE} ↑ ${start} more${RESET}` : '', ...rows.slice(start, end), end < rows.length ? `${SUBTLE} ↓ ${rows.length - end} more${RESET}` : '']; } export function renderModulesManager(state: ModulesState, columns: number, context: ModulesContext = state.context ?? {}, budget = Infinity): string[] { const all = renderManagerRows(state, columns, context); - if (state.detail || state.confirm || all.rows.length <= budget) return all.rows; + if (state.detail) { + // Only ephemeral view bounds change here. Facts, preferences and demand do + // not change when the terminal is resized or the reader pages through data. + const head = Math.min(all.head, Math.max(0, budget - 1)); + const content = all.rows.slice(head); + const available = Math.max(0, budget - head); + const page = Math.max(1, Math.min(content.length, available < 5 ? available : available - 2)); + const maxScroll = Math.max(0, content.length - page); + const scroll = state.detail.scroll ?? 0; + state.detail.scroll = Number.isSafeInteger(scroll) ? Math.max(0, Math.min(maxScroll, scroll)) : 0; + state.detail.viewport = {maxScroll, page}; + return [...all.rows.slice(0, head), ...windowed(content, state.detail.scroll, available, true)]; + } + if (state.confirm || all.rows.length <= budget) return all.rows; return [...all.rows.slice(0, all.head), ...windowed(all.rows.slice(all.head), all.selectedRow - all.head, budget - all.head)]; } @@ -470,6 +537,7 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module return {rows, head: rows.length, selectedRow: 0}; } if (state.detail) { + const head = rows.length; if (state.detail.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); rows.push(...(pack ? packDetailRows(pack, columns) : [`${SECONDARY}That pack is no longer available.${RESET}`])); @@ -477,7 +545,7 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module const module = state.draft.modules.find(item => item.id === state.detail!.id); rows.push(...(module ? moduleDetailRows(module, context, columns, state.draft) : [`${SECONDARY}That module is no longer configured.${RESET}`])); } - return {rows, head: rows.length, selectedRow: 0}; + return {rows, head: Math.min(rows.length, head + 1), selectedRow: head}; } const item = (index: number, text: string) => { if (index === state.selectedIndex) selectedRow = rows.length; @@ -534,9 +602,10 @@ function renderManagerRows(state: ModulesState, columns: number, context: Module export function modulesManagerControls(state: ModulesState, context: ModulesContext = state.context ?? {}): Array<[string, string]> { if (state.confirm) return [['Enter', 'apply'], ['Esc', 'cancel']]; + const read: Array<[string, string]> = state.detail?.viewport?.maxScroll ? [['↑↓', 'scroll'], ['PgUp/PgDn', 'page']] : []; if (state.detail?.kind === 'pack') { const pack = context.packs?.find(item => item.id === state.detail!.id); - return pack && !pack.builtIn ? [['E', pack.state === 'enabled' ? 'disable' : 'enable'], ['D', 'remove'], ['Esc', 'back']] : [['Esc', 'back']]; + return pack && !pack.builtIn ? [...read, ['E', pack.state === 'enabled' ? 'disable' : 'enable'], ['D', 'remove'], ['Esc', 'back']] : [...read, ['Esc', 'back']]; } const done: [string, string] = ['Esc', state.standalone ? 'close' : 'back to /prompt']; const apply: Array<[string, string]> = !state.standalone && modulesDirty(state) ? [['A', 'save modules now']] : []; @@ -546,7 +615,7 @@ export function modulesManagerControls(state: ModulesState, context: ModulesCont const module = state.draft.modules.find(item => item.id === state.detail!.id); if (!module || !moduleDefinition(module.id)) return [['Esc', 'back']]; const claude = moduleDefinition(state.detail.id)?.facts.has('agent.claude'); - return [['Space', 'show/hide'], ...(conditionChoosable(module) ? [['←→', 'option'] as [string, string]] : []), ['S', 'surface'], + return [...read, ['Space', 'show/hide'], ...(conditionChoosable(module) ? [['←→', 'option'] as [string, string]] : []), ['S', 'surface'], ...(claude ? [['B', 'Claude Code bridge'] as [string, string]] : []), ...apply, ['Esc', 'back']]; } const tab = state.modulesTab ?? 'modules'; diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index 5a6555fb..906bc8d9 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -304,14 +304,21 @@ export function isCleanWorkingTree(git: NonNullable): bool return !git.staged && !git.modified && !git.untracked && !git.conflicts && !git.operation; } +/** The shared display-condition check, also used by diagnostics; no data is collected here. */ +export function moduleConditionMet(config: ContextModuleConfig, context: PromptContext): boolean { + if (config.condition === 'inRepository' && !context.branch) return false; + if (config.condition === 'nonzeroExit' && (context.exitStatus ?? 0) === 0) return false; + // Toolchains match only detected toolchains, as their segments do below. + if (config.condition === 'onCommand' && !(config.id === 'toolchain' + ? (context.toolchains ?? []).some(id => matchesCommand(TOOLCHAIN_TRIGGERS[id], context.commandWords)) + : isOnCommandRelevant(config.id, context.commandWords ?? []))) return false; + if (config.condition === 'shellDiffers' && !context.shell?.differs) return false; + return true; +} + function moduleSegments(config: ContextModuleConfig, context: PromptContext, icons: NativeIconMode, richGit: boolean, pathLevel: number): Array<{text: string; role: PromptRole; compact?: boolean}> { const status = context.exitStatus ?? 0; - if (!config.visible) return []; - if (config.condition === 'inRepository' && !context.branch) return []; - if (config.condition === 'nonzeroExit' && status === 0) return []; - // Toolchains filter per toolchain below; other on-command modules need a matching command. - if (config.condition === 'onCommand' && config.id !== 'toolchain' && !isOnCommandRelevant(config.id, context.commandWords ?? [])) return []; - if (config.condition === 'shellDiffers' && !context.shell?.differs) return []; + if (!config.visible || !moduleConditionMet(config, context)) return []; switch (config.id) { case 'project': return [{text: safePromptText(context.project), role: 'project'}]; @@ -367,9 +374,7 @@ function moduleSegments(config: ContextModuleConfig, context: PromptContext, ico function packModuleSegments(config: ContextModuleConfig, context: PromptContext, facts: ReturnType, icons: NativeIconMode, purpose: 'display' | 'snapshot'): Array<{text: string; role: PromptRole; compact?: boolean}> { const definition = moduleDefinition(config.id); - if (!definition?.pack || !config.visible) return []; - if (config.condition === 'inRepository' && !context.branch) return []; - if (config.condition === 'onCommand' && !matchesCommand(definition.triggers ?? [], context.commandWords)) return []; + if (!definition?.pack || !config.visible || !moduleConditionMet(config, context)) return []; return declarativeSegments(definition, facts, {icons, glyphs: getCurrentGlyphMode(), now: context.now ?? presentationNow().getTime(), purpose}); } diff --git a/src/queue/QueuePanel.ts b/src/queue/QueuePanel.ts index 672ab265..78ba9906 100644 --- a/src/queue/QueuePanel.ts +++ b/src/queue/QueuePanel.ts @@ -1,5 +1,5 @@ import type {Key} from '../terminal/keys.js'; -import type {QueueOp, QueueState} from '../session/CommandQueue.js'; +import type {QueueCondition, QueueEntry, QueueOp, QueueState} from '../session/CommandQueue.js'; import {renderControls} from '../ui/controls.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; @@ -22,9 +22,18 @@ export type QueuePanelAction = | {kind: 'close'} | {kind: 'change'; change: QueueOp} /** Load the entry into the composer for editing in place. */ - | {kind: 'edit'; id: number}; + | {kind: 'edit'; id: number} + /** Keep the queued commands, in order, as a named recipe (asks for the name). */ + | {kind: 'saveRecipe'}; -export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueState): QueuePanelAction | undefined { +/** The condition `w` moves to next: after success (the default), also after a failure, or only once approved. */ +export const nextCondition = (condition: QueueCondition | undefined): QueueCondition | undefined => condition === undefined ? 'always' : condition === 'always' ? 'approve' : undefined; + +export const conditionNote = (entry: QueueEntry): string => + entry.condition === 'always' ? 'runs even after a failure' : entry.condition === 'approve' ? (entry.approved ? 'approved' : 'asks first') : ''; + +/** `conditions`: the queue's owner understands conditions and approval (older session services do not, and must never be sent them). */ +export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueState, conditions = false): QueuePanelAction | undefined { const entries = queue.entries; panel.selected = Math.max(0, Math.min(panel.selected, entries.length - 1)); if (panel.confirmClear) { @@ -38,6 +47,7 @@ export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueStat if (entries.length) panel.selected = (panel.selected + (key.kind === 'up' ? -1 : 1) + entries.length) % entries.length; return undefined; } + if (key.kind === 'text' && key.value === 's' && entries.length) return {kind: 'saveRecipe'}; if (key.kind === 'text' && key.value === 'p') return {kind: 'change', change: {op: queue.paused ? 'resume' : 'pause'}}; if (!entry) return undefined; if (key.kind === 'selectUp' || key.kind === 'selectDown') { @@ -49,11 +59,13 @@ export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueStat if (key.kind === 'enter' || (key.kind === 'text' && key.value === 'e')) return {kind: 'edit', id: entry.id}; if (key.kind === 'delete' || key.kind === 'backspace' || (key.kind === 'text' && key.value === 'd')) return {kind: 'change', change: {op: 'remove', id: entry.id}}; if (key.kind === 'text' && key.value === 'c') { panel.confirmClear = true; return undefined; } + if (conditions && key.kind === 'text' && key.value === 'w') return {kind: 'change', change: {op: 'condition', id: entry.id, ...(nextCondition(entry.condition) ? {condition: nextCondition(entry.condition)!} : {})}}; + if (conditions && key.kind === 'text' && key.value === 'a' && entry.condition === 'approve' && !entry.approved) return {kind: 'change', change: {op: 'approve', id: entry.id}}; return undefined; } /** The panel's rows (unframed): what runs now, what runs next, and every queued command in order. */ -export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, running: string | undefined, columns: number, height: number): string[] { +export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, running: string | undefined, columns: number, height: number, conditions = false): string[] { const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); const subtle = foreground(UI_COLORS.subtle); @@ -73,9 +85,10 @@ export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, runn const index = first + offset; const selected = index === panel.selected; const label = index === 0 && !queue.paused ? 'next' : `${index + 1}`; - const editing = queue.editing === entry.id ? ' (editing)' : ''; + const note = conditionNote(entry); + const editing = (queue.editing === entry.id ? ' (editing)' : '') + (note ? ` · ${note}` : ''); const text = queuePreview(entry.text, Math.max(4, width - displayWidth(label) - displayWidth(editing) - 4)); - out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${label.padStart(4)}${reset} ${focusForeground(selected)}${text}${reset}${subtle}${editing}${reset}`); + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${label.padStart(4)}${reset} ${focusForeground(selected)}${text}${reset}${entry.condition === 'approve' && !entry.approved ? accent : subtle}${editing}${reset}`); }); const selected = entries[panel.selected]; if (selected && selected.text.includes('\n') && height >= out.length + 8) { @@ -85,6 +98,10 @@ export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, runn } out.push(''); if (panel.confirmClear) out.push(` ${accent}Clear ${entries.length} queued command${entries.length === 1 ? '' : 's'}? y / n${reset}`); - else out.push(renderControls([['↑↓', 'select'], ['Shift+↑↓', 'move'], ['Enter', 'edit'], ['Del', 'remove'], ['p', queue.paused ? 'resume' : 'pause'], ['c', 'clear'], ['Esc', 'close']])); + else { + const approvable = conditions && selected?.condition === 'approve' && !selected.approved; + out.push(renderControls([['↑↓', 'select'], ['Shift+↑↓', 'move'], ['Enter', 'edit'], ['Del', 'remove'], ...(conditions ? [['w', 'when it runs'] as const] : []), ...(approvable ? [['a', 'approve'] as const] : []), + ['s', 'save as recipe'], ['p', queue.paused ? 'resume' : 'pause'], ['c', 'clear'], ['Esc', 'close']])); + } return out.map(line => truncateAnsi(line, columns)); } diff --git a/src/session/CommandQueue.ts b/src/session/CommandQueue.ts index ce670f88..74afab5c 100644 --- a/src/session/CommandQueue.ts +++ b/src/session/CommandQueue.ts @@ -7,12 +7,22 @@ import {EventEmitter} from 'node:events'; * the in-process client) is the only place entries are taken for running (see QueueDispatcher), so an entry runs at * most once, and only in the shell of the session that owns it. */ +/** + * When an entry may run, beyond "the queue is not paused". Absent is the default: after the commands before it + * succeeded (a failure or interrupt pauses the queue before it). `always` also runs after a failure (never after an + * interrupt: Ctrl+C still stops the queue). `approve` waits for an explicit approval of this exact entry. + */ +export type QueueCondition = 'always' | 'approve'; + export interface QueueEntry { /** Unique within the session, never reused. */ id: number; /** Exactly what is submitted to the shell, multi-line included. */ text: string; addedAt: number; + condition?: QueueCondition; + /** `approve` entries only: approved by the person for this text; editing the entry withdraws it. */ + approved?: boolean; } /** Why the queue does not run its next entry by itself. */ @@ -31,7 +41,11 @@ export interface QueueState { } export type QueueOp = - | {op: 'add'; text: string} + | {op: 'add'; text: string; condition?: QueueCondition} + /** Set (or with no condition, clear) when an entry may run. Withdraws any approval. */ + | {op: 'condition'; id: number; condition?: QueueCondition} + /** Approve the entry that is waiting for approval, exactly as it stands. */ + | {op: 'approve'; id: number} | {op: 'edit'; id: number; text: string} | {op: 'edit-begin'; id: number} | {op: 'edit-cancel'; id: number} @@ -70,7 +84,7 @@ 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()}); + this.entries.push({id: this.nextId++, text: change.text, addedAt: this.now(), ...(change.condition ? {condition: change.condition} : {})}); break; } case 'edit': { @@ -79,10 +93,26 @@ export class CommandQueue extends EventEmitter<{change: [QueueState]}> { if (!change.text.trim()) return 'A queued command cannot be empty; remove it instead.'; if (change.text.length > ENTRY_LIMIT) return 'That command is too long to queue.'; if (this.bytes() - Buffer.byteLength(entry.text) + Buffer.byteLength(change.text) > QUEUE_BYTES_LIMIT) return 'The queue is full; run or remove some commands first.'; + // Approval is for the exact text that was approved. + if (entry.text !== change.text) delete entry.approved; entry.text = change.text; if (this.editing === change.id) this.editing = undefined; break; } + case 'condition': { + const entry = this.entries.find(item => item.id === change.id); + if (!entry) return 'That command already ran or was removed.'; + if (change.condition) entry.condition = change.condition; else delete entry.condition; + delete entry.approved; + break; + } + case 'approve': { + const entry = this.entries.find(item => item.id === change.id); + if (!entry) return 'That command already ran or was removed.'; + if (entry.condition !== 'approve') return 'That command does not ask for approval.'; + entry.approved = true; + break; + } case 'edit-begin': { if (!this.entries.some(item => item.id === change.id)) return 'That command already ran or was removed.'; this.editing = change.id; @@ -138,11 +168,22 @@ export class CommandQueue extends EventEmitter<{change: [QueueState]}> { take(): QueueEntry | undefined { if (this.pause || !this.entries.length) return undefined; if (this.editing === this.entries[0]!.id) return undefined; + // An entry that asks first waits for its own approval, whatever else is true. + if (this.entries[0]!.condition === 'approve' && !this.entries[0]!.approved) return undefined; const entry = this.entries.shift()!; this.changed(); return entry; } + /** The entry that is next but waiting for the person's approval, if that is why the queue is not running. */ + get awaitingApproval(): QueueEntry | undefined { + const next = this.entries[0]; + return next && !this.pause && next.condition === 'approve' && !next.approved ? {...next} : undefined; + } + + /** The next entry, without taking it. */ + get next(): QueueEntry | undefined { return this.entries[0] ? {...this.entries[0]} : undefined; } + /** Everything still queued, removed (the shell ended or the session went away). */ drain(): QueueEntry[] { const entries = this.entries; diff --git a/src/session/QueueDispatcher.ts b/src/session/QueueDispatcher.ts index 4e653015..0293ce43 100644 --- a/src/session/QueueDispatcher.ts +++ b/src/session/QueueDispatcher.ts @@ -24,7 +24,8 @@ export interface QueueEvent { * - an entry is submitted only while the shell is at its prompt (after its prompt marker, before the next exec); * - the entry leaves the queue in the same step (`CommandQueue.take`), so it can never be submitted twice; * - after a command that failed (non-zero status) or was interrupted, the queue pauses with that reason instead of - * running what was prepared to follow it; the person resumes or clears it; + * running what was prepared to follow it; the person resumes or clears it. Only an entry the person marked + * `always` goes on after a plain failure (never after an interrupt), and one marked `approve` waits for approval; * - a shell switch pauses it (entries were written for the previous shell), and a shell exit drops what is left, * reporting it, never replaying it elsewhere. * No timers: silence is never taken as completion. @@ -83,7 +84,8 @@ export class QueueDispatcher extends EventEmitter<{state: [QueueState, QueueEven if (finished && this.queue.size) { const at = Date.now(); if (this.interrupted || exitCode === 130) this.queue.hold({reason: 'interrupted', at, command: this.lastCommand}); - else if (exitCode !== 0) this.queue.hold({reason: 'failed', at, command: this.lastCommand, exitCode}); + // An entry that is to run regardless of the result before it is not held back by a plain failure. + else if (exitCode !== 0 && this.queue.next?.condition !== 'always') this.queue.hold({reason: 'failed', at, command: this.lastCommand, exitCode}); } this.interrupted = false; this.dispatch(); diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index a2bd88ee..d539073b 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -1,4 +1,4 @@ -import {QUEUE_LIMIT, type QueueEntry, type QueueOp, type QueuePause, type QueueState} from './CommandQueue.js'; +import {QUEUE_LIMIT, type QueueCondition, type QueueEntry, type QueueOp, type QueuePause, type QueueState} from './CommandQueue.js'; import type {QueueEvent} from './QueueDispatcher.js'; /** * Versioned wire protocol between an NMSh frontend and whatever owns the @@ -24,7 +24,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'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; /** @@ -323,7 +323,16 @@ export function queueOpFrom(text: string): QueueOp | undefined { const id = Number.isSafeInteger(value.id) ? value.id as number : undefined; const text_ = typeof value.text === 'string' ? value.text : undefined; switch (value.op) { - case 'add': return text_ === undefined ? undefined : {op: 'add', text: text_}; + case 'add': { + if (text_ === undefined) return undefined; + const condition = queueConditionFrom(value.condition); + return value.condition !== undefined && !condition ? undefined : {op: 'add', text: text_, ...(condition ? {condition} : {})}; + } + case 'condition': { + const condition = queueConditionFrom(value.condition); + return id === undefined || (value.condition !== undefined && !condition) ? undefined : {op: 'condition', id, ...(condition ? {condition} : {})}; + } + case 'approve': return id === undefined ? undefined : {op: 'approve', id}; case 'edit': return id === undefined || text_ === undefined ? undefined : {op: 'edit', id, text: text_}; case 'edit-begin': case 'edit-cancel': case 'remove': return id === undefined ? undefined : {op: value.op, id}; case 'move': return id === undefined || !Number.isSafeInteger(value.to) ? undefined : {op: 'move', id, to: value.to as number}; @@ -332,6 +341,8 @@ export function queueOpFrom(text: string): QueueOp | undefined { } } +const queueConditionFrom = (value: unknown): QueueCondition | undefined => value === 'always' || value === 'approve' ? value : undefined; + const PAUSE_REASONS = ['user', 'failed', 'interrupted', 'shell-switched', 'restored'] as const; /** The queue a queue-state message describes; anything malformed is dropped, never guessed. */ @@ -345,7 +356,8 @@ export function queueStateFrom(text: string): QueueState | undefined { for (const entry of value.entries as unknown[]) { const item = entry as Record | null; if (!item || !Number.isSafeInteger(item.id) || typeof item.text !== 'string' || !Number.isSafeInteger(item.addedAt)) return undefined; - entries.push({id: item.id as number, text: item.text, addedAt: item.addedAt as number}); + const condition = queueConditionFrom(item.condition); + entries.push({id: item.id as number, text: item.text, addedAt: item.addedAt as number, ...(condition ? {condition} : {}), ...(condition === 'approve' && item.approved === true ? {approved: true} : {})}); } const pause = value.paused as Record | undefined; let paused: QueuePause | undefined; diff --git a/src/status/queueStatus.ts b/src/status/queueStatus.ts index b75851f9..4cae9781 100644 --- a/src/status/queueStatus.ts +++ b/src/status/queueStatus.ts @@ -60,6 +60,15 @@ export function queueRow(state: QueueState, columns: number): string | undefined return `${accent}${truncateText(`${head} · ${count}`, columns)}${RESET}`; } const next = state.entries[0]!; + // The queue is waiting for the person, not for the shell: say so, in the one line that is always there. + if (next.condition === 'approve' && !next.approved) { + const asks = `${queueGlyph()} Queued (${count}) · next needs your approval`; + for (const hint of [' · /queue', '']) { + const room = columns - displayWidth(`${asks}: `) - displayWidth(hint); + if (room >= 8) return `${accent}${asks}${RESET}${subtle}: ${queuePreview(next.text, room)}${hint}${RESET}`; + } + return `${accent}${truncateText(`${queueGlyph()} Queued (${count}) · needs approval`, columns)}${RESET}`; + } const head = `${queueGlyph()} Queued (${count})`; const editing = state.editing === next.id ? ' (editing)' : ''; for (const hint of [' · /queue', '']) { diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index 7b9ad9b2..c10baa2f 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -6,12 +6,15 @@ export type Key = | {kind: 'left' | 'right' | 'up' | 'down' | 'lineHome' | 'lineEnd' | 'backspace' | 'delete' | 'enter' | 'newline' | 'complete' | 'escape' | 'selectAll'} | {kind: 'selectLeft' | 'selectRight' | 'selectUp' | 'selectDown' | 'selectLineHome' | 'selectLineEnd'} | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} - | {kind: 'historySearch' | 'historyDelete' | 'find'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' + | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ | 'queueCommand' | 'sendToProgram'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ + // F8 walks backwards through failures; Shift+F8 returns towards the newest. + ['\u001B[19~', 'previousFailure'], ['\u001B[19;1~', 'previousFailure'], ['\u001B[57371u', 'previousFailure'], + ['\u001B[19;2~', 'nextFailure'], ['\u001B[57371;2u', 'nextFailure'], ['\u001B[I', 'focusIn'], ['\u001B[O', 'focusOut'], // Kitty keyboard protocol (CSI > 1 u, enabled by TerminalRenderer on entry; diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index 0d204efd..ab7ec107 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -3,8 +3,9 @@ import type {PaletteItem} from './CommandPalette.js'; import type {WrappedRow} from '../output/viewport.js'; import {displayWidth} from '../util/text.js'; import {recordCopyText} from '../clipboard/copySelection.js'; +import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; /** One registry for both pointer-opened and keyboard-opened block palettes. */ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: string}[] = [ {id: 'copyCommand', label: 'Copy command', detail: 'Stored plain command'}, @@ -15,13 +16,18 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'pager', label: 'Open in pager', detail: 'Command and full stored output in your pager (less), read-only'}, {id: 'rerun', label: 'Rerun command', detail: 'Explicitly submit a visible command in the current shell directory'}, {id: 'edit', label: 'Edit & rerun', detail: 'Put command in composer; Enter executes it'}, + {id: 'pin', label: 'Pin command', detail: 'Keep this command to reuse later (/pins); it is never run for you'}, + {id: 'clipCommand', label: 'Keep command as clip', detail: 'Keep text in memory for this frontend; /clips reviews it before insertion'}, + {id: 'clipOutput', label: 'Keep output as clip', detail: 'Keep full plain output in memory, including folded lines; /clips reviews it before insertion'}, {id: 'fold', label: 'Fold / unfold output', detail: 'Same stored disclosure state as Ctrl+O'}, + {id: 'references', label: 'Files, links and more…', detail: 'Open or copy the files, links, commits and devices found in this output'}, {id: 'explain', label: 'Explain failure', detail: "Why it failed, from this block's own output (read-only)"}, ]; export function blockPaletteItems(record: CompletedCommand): PaletteItem[] { - // Explain failure is offered only on failed blocks. - return BLOCK_ACTIONS.filter(action => action.id !== 'explain' || (record.exitCode ?? 0) !== 0).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, + // Explain failure is offered only on failed blocks, and references only when the output has some. + const hasReferences = findOutputReferences(record).length > 0; + return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, detail: action.detail, category: 'Transcript', action: {kind: 'block', id: action.id, startId: record.startId}})); } diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index a48efc51..f1e0e8d1 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -24,7 +24,15 @@ export type PaletteAction = | {kind: 'latest'} | {kind: 'toggleDetails'} | {kind: 'toggleInspector'} - | {kind: 'block'; id: BlockActionId; startId: number}; + | {kind: 'block'; id: BlockActionId; startId: number} + /** An action on one reference found in a block's output; resolved again from the block when it runs. */ + | {kind: 'reference'; startId: number; refId: string; verb: 'open' | 'copy' | 'stage'} + | {kind: 'failure'; direction: 'previous' | 'next'} + | {kind: 'pinHistory'; id: string} + | {kind: 'keepComposerClip'} + | {kind: 'keepHistoryClip'; id: string} + | {kind: 'clip'; id: number; verb: 'review' | 'remove'} + | {kind: 'clearClips'}; export interface PaletteItem { id: string; diff --git a/src/ui/ReferenceMenu.ts b/src/ui/ReferenceMenu.ts new file mode 100644 index 00000000..13af19b6 --- /dev/null +++ b/src/ui/ReferenceMenu.ts @@ -0,0 +1,28 @@ +import type {PaletteItem} from './CommandPalette.js'; +import {referenceFollowUp, REFERENCE_KIND_LABELS, type OutputReference} from '../output/references.js'; + +/** + * The menu for one block's references: for each, the action that fits it and Copy. Every action resolves the + * reference again from the block when it runs, so a menu never acts on stale coordinates or text. + */ +export function referencePaletteItems(startId: number, references: readonly OutputReference[]): PaletteItem[] { + const items: PaletteItem[] = []; + for (const reference of references) { + const kind = REFERENCE_KIND_LABELS[reference.kind]; + const id = `reference:${startId}:${reference.id}`; + const primary = reference.kind === 'file' ? {verb: 'open' as const, label: `Open ${reference.label}`, detail: `${kind} · in your editor${reference.line ? ` at line ${reference.line}` : ''}`} + : reference.kind === 'url' ? {verb: 'open' as const, label: `Open ${reference.label}`, detail: `${kind} · in your browser`} + : referenceFollowUp(reference) ? {verb: 'stage' as const, label: reference.kind === 'commit' ? `Inspect commit ${reference.label}` : `Open a shell on ${reference.label}`, + detail: `${kind} · puts \`${referenceFollowUp(reference)!.join(' ')}\` in the composer to review; nothing runs`} + : undefined; + if (primary) items.push({id: `${id}:${primary.verb}`, label: primary.label, detail: primary.detail, category: 'Transcript', action: {kind: 'reference', startId, refId: reference.id, verb: primary.verb}}); + items.push({id: `${id}:copy`, label: `Copy ${reference.label}`, detail: `${kind} · ${reference.kind === 'file' ? 'path and line' : 'as printed'}`, category: 'Transcript', + action: {kind: 'reference', startId, refId: reference.id, verb: 'copy'}}); + } + return items; +} + +/** What Copy puts on the clipboard for a reference. */ +export function referenceCopyText(reference: OutputReference): string { + return reference.kind === 'file' ? reference.label : reference.value; +} diff --git a/tests/blockActions.test.ts b/tests/blockActions.test.ts index 01a1a9ac..022961b3 100644 --- a/tests/blockActions.test.ts +++ b/tests/blockActions.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {TerminalApp} from '../src/app/TerminalApp.js'; -import {blockCopyPayload, blockPaletteItems, blockAffordance} from '../src/ui/BlockActions.js'; +import {BLOCK_ACTIONS, blockCopyPayload, blockPaletteItems, blockAffordance} from '../src/ui/BlockActions.js'; import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {decodeKeys} from '../src/terminal/keys.js'; import {regionOf} from '../src/app/screenPlan.js'; @@ -23,7 +23,10 @@ function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } test('one block registry supplies keyboard palette actions and copies authoritative full plain data', () => { const output = new OutputBuffer(); const record = fixture(output); - assert.equal(blockPaletteItems(record).length, 9, 'Copy as report… and Compare output… joined the actions'); + const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references'); + assert.deepEqual(blockPaletteItems(record).map(item => item.action.kind === 'block' && item.action.id), passive.map(action => action.id)); + assert.ok(passive.some(action => action.id === 'clipCommand')); + assert.ok(passive.some(action => action.id === 'clipOutput')); assert.equal(blockCopyPayload(record, 'copyCommand'), 'echo hello'); assert.equal(blockCopyPayload(record, 'copyOutput'), record.output); assert.equal(blockCopyPayload(record, 'copyBoth'), record.command + '\n' + record.output); @@ -53,7 +56,7 @@ test('keyboard focus opens actions; edit waits for Enter, rerun uses visible nor instance['handleKey']({kind: 'focusPrevious'}); assert.equal(instance['focusedCommandIndex'], 0); instance['handleKey']({kind: 'enter'}); - assert.equal(instance['paletteState']?.items.length, 9); + assert.deepEqual(instance['paletteState']?.items, blockPaletteItems(record)); instance['paletteState'] = undefined; await instance['runBlockAction'](record.startId, 'edit'); assert.equal(instance['editor'].text, record.command); diff --git a/tests/clipboardChanges.test.ts b/tests/clipboardChanges.test.ts new file mode 100644 index 00000000..9d7ad0ea --- /dev/null +++ b/tests/clipboardChanges.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ClipboardChangeWatch, parseClipboardCounter} from '../src/clipboard/ClipboardChanges.js'; + +test('counter parsing accepts bounded nonnegative metadata only', () => { + assert.equal(parseClipboardCounter('42\n'), 42); + for (const raw of ['-1', 'secret contents', '12\n34', '9'.repeat(200), '9007199254740992']) + assert.throws(() => parseClipboardCounter(raw)); +}); + +test('suggestions are off by default, sample only when idle, and never request clipboard text', async () => { + let count = 3, reads = 0, changes = 0, idle = true; + let next: (() => void) | undefined; + const watch = new ClipboardChangeWatch({counter: async () => { reads++; return count; }, idle: () => idle, + changed: () => { changes++; }, failed: () => assert.fail('unexpected failure'), + schedule: callback => { next = callback; return () => { next = undefined; }; }}); + const settle = async () => { await new Promise(done => setImmediate(done)); }; + assert.equal(watch.enabled, false); + assert.equal(reads, 0); + watch.start(); await settle(); + assert.equal(reads, 1); + assert.equal(changes, 0, 'initial metadata is a baseline'); + idle = false; count++; + next!(); await settle(); + assert.equal(reads, 1, 'foreground input ownership prevents even metadata probing'); + idle = true; + next!(); await settle(); + assert.equal(changes, 1); + watch.stop(); + assert.equal(watch.enabled, false); + assert.equal(next, undefined); +}); + +test('failures disable suggestions; a response after stop cannot raise a suggestion', async () => { + let failed = 0, changes = 0; + let resolve!: (count: number) => void; + const watch = new ClipboardChangeWatch({counter: () => new Promise(done => { resolve = done; }), idle: () => true, + changed: () => { changes++; }, failed: () => { failed++; }}); + watch.start(); watch.stop(); resolve(1); + await new Promise(done => setImmediate(done)); + assert.equal(changes, 0); + assert.equal(watch.enabled, false); + const broken = new ClipboardChangeWatch({counter: async () => { throw new Error('unavailable'); }, idle: () => true, + changed: () => { changes++; }, failed: () => { failed++; }}); + broken.start(); + await new Promise(done => setImmediate(done)); + assert.equal(broken.enabled, false); + assert.equal(failed, 1); +}); diff --git a/tests/clipboardRead.test.ts b/tests/clipboardRead.test.ts new file mode 100644 index 00000000..3d5a5b7a --- /dev/null +++ b/tests/clipboardRead.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ClipboardUnavailableError, readClipboard, selectClipboardReadBackend} from '../src/clipboard/clipboard.js'; + +const installed = (...names: string[]) => (name: string) => names.includes(name) ? `/usr/bin/${name}` : undefined; + +test('the read tool follows platform and session, like the write tool', () => { + assert.deepEqual(selectClipboardReadBackend({platform: 'darwin', env: {}}), {command: 'pbpaste', args: []}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {WAYLAND_DISPLAY: 'w0', DISPLAY: ':0'}, resolve: installed('wl-paste', 'xclip')}), {command: '/usr/bin/wl-paste', args: ['--no-newline']}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {DISPLAY: ':0'}, resolve: installed('xclip')}), {command: '/usr/bin/xclip', args: ['-selection', 'clipboard', '-o']}); + assert.deepEqual(selectClipboardReadBackend({platform: 'linux', env: {DISPLAY: ':0'}, resolve: installed('xsel')}), {command: '/usr/bin/xsel', args: ['--clipboard', '--output']}); + assert.equal(selectClipboardReadBackend({platform: 'linux', env: {}, resolve: installed('xclip')}), undefined); + assert.equal(selectClipboardReadBackend({platform: 'win32', env: {}}), undefined); +}); + +function fake(body: string): {options: Parameters[0]; cleanup: () => void} { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-paste-')); + const script = join(directory, 'xclip'); + writeFileSync(script, `#!/bin/sh\n${body}\n`); + chmodSync(script, 0o755); + return {options: {platform: 'linux', env: {DISPLAY: ':0'}, resolve: name => name === 'xclip' ? script : undefined, timeoutMs: 1500}, cleanup: () => rmSync(directory, {recursive: true, force: true})}; +} + +test('reads exactly what the tool prints: newlines, quotes and non-ASCII intact', async () => { + const {options, cleanup} = fake('printf \'%s\' "line one\n rm -rf \\"x\\"\nüñí 🐈"'); + try { assert.equal(await readClipboard(options), 'line one\n rm -rf "x"\nüñí 🐈'); } finally { cleanup(); } +}); + +test('an empty clipboard reads as an empty string', async () => { + const {options, cleanup} = fake('exit 0'); + try { assert.equal(await readClipboard(options), ''); } finally { cleanup(); } +}); + +test('a tool that fails, hangs or prints too much is an error, never partial text', async () => { + const failing = fake('echo secret; exit 1'); + try { await assert.rejects(readClipboard(failing.options), /exited with code 1/u); } finally { failing.cleanup(); } + const hanging = fake('sleep 30'); + try { await assert.rejects(readClipboard({...hanging.options, timeoutMs: 200}), /timed out/u); } finally { hanging.cleanup(); } + const huge = fake('head -c 3000000 /dev/zero | tr "\\0" "a"'); + try { await assert.rejects(readClipboard(huge.options), (error: unknown) => error instanceof ClipboardUnavailableError && /more than 1 MiB/u.test(error.message)); } finally { huge.cleanup(); } +}); + +test('no clipboard tool is a clear, non-fatal error', async () => { + await assert.rejects(readClipboard({platform: 'linux', env: {}, resolve: () => undefined}), /install wl-paste/u); + await assert.rejects(readClipboard({platform: 'win32', env: {}}), /unsupported platform/u); +}); diff --git a/tests/clips.test.ts b/tests/clips.test.ts new file mode 100644 index 00000000..402e72b1 --- /dev/null +++ b/tests/clips.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ClipStore, CLIP_MAX_BYTES, CLIP_LIMIT} from '../src/clipboard/ClipStore.js'; + +test('clips retain exact text and provenance in memory; remove and clear never write anything', () => { + const store = new ClipStore(); + const added = store.add('echo "$(not executed)"\n', {source: 'history', cwd: '/old', session: 'source-session'}); + assert.ok(added.ok); + if (!added.ok) return; + assert.deepEqual(store.get(added.clip.id), added.clip); + assert.equal(added.clip.text, 'echo "$(not executed)"\n'); + assert.equal(added.clip.cwd, '/old'); + assert.equal(added.clip.session, 'source-session'); + assert.equal(new ClipStore().list().length, 0, 'no persistence or global state'); + assert.equal(store.remove(added.clip.id), true); + assert.equal(store.get(added.clip.id), undefined); + store.add('echo next', {source: 'composer'}); + store.clear(); + assert.deepEqual(store.list(), []); +}); + +test('clips bound both bytes and count, reject empty/oversized text, and evict oldest first', () => { + const store = new ClipStore(); + assert.equal(store.add(' \n', {source: 'paste'}).ok, false); + assert.equal(store.add('é'.repeat(CLIP_MAX_BYTES), {source: 'paste'}).ok, false); + for (let index = 0; index <= CLIP_LIMIT; index++) store.add(`echo ${index}`, {source: 'composer'}); + assert.equal(store.list().length, CLIP_LIMIT); + assert.equal(store.list().at(-1)?.text, 'echo 1'); + const large = store.add('a'.repeat(CLIP_MAX_BYTES), {source: 'output'}); + assert.ok(large.ok); + assert.equal(store.list().length, 1, 'the total memory budget is also bounded'); +}); diff --git a/tests/clipsLive.test.ts b/tests/clipsLive.test.ts new file mode 100644 index 00000000..97136947 --- /dev/null +++ b/tests/clipsLive.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('live: an explicit clipboard read can be kept as a clip, reviewed again and queued only on approval', {skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const readMarker = join(sandbox.root, 'clipboard-read'); + const execution = join(sandbox.home, 'explicit-execution'); + const source = join(sandbox.root, 'clipboard-text'); + writeFileSync(source, `touch '${execution}'\necho CLIP-DONE`); + for (const name of ['pbpaste', 'wl-paste', 'xclip', 'xsel']) { + writeFileSync(join(bin, name), `#!/bin/sh\ntouch '${readMarker}'\ncat '${source}'\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 100, rows: 32}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + assert.equal(existsSync(readMarker), false, 'startup never reads clipboard text'); + let mark = frontend.mark; + frontend.pty.write('/ps\r'); + await frontend.waitFor(/Review paste/u, mark); + assert.equal(existsSync(readMarker), true); + assert.equal(existsSync(execution), false); + frontend.pty.write('c'); + // Complete Escape sequences avoid relying on repaint of an unchanged composer row + // or on a bare Escape's ambiguity timer before the next typed slash. + frontend.pty.write('\u001b[27u\u001b[27u'); + await frontend.run('/clips', /Review clip 1/u); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Review paste/u, mark); + assert.equal(existsSync(execution), false, 'selecting a clip only opens review'); + frontend.pty.write('b'); + await frontend.waitFor(/Review commands/u, mark); + assert.equal(existsSync(execution), false, 'batch review never dispatches'); + frontend.pty.write('q'); + await until(() => existsSync(execution), 15_000, 'explicitly approved queued command'); + await frontend.waitFor(/CLIP-DONE[\s\S]*from queue/u, mark); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/contextCapabilities.test.ts b/tests/contextCapabilities.test.ts index 87e6e77c..f56343fb 100644 --- a/tests/contextCapabilities.test.ts +++ b/tests/contextCapabilities.test.ts @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {createHash} from 'node:crypto'; import type {CapabilityDefinition} from '../src/context/capability.js'; import {projectPackage} from '../src/context/capabilities/project.js'; -import {goRuntime, javaRuntime, nodeRuntime, pythonRuntime, rustRuntime, versionMismatch} from '../src/context/capabilities/runtimes.js'; +import {goRuntime, javaRuntime, nodeRuntime, phpRuntime, pythonRuntime, rubyRuntime, rustRuntime, versionMismatch} from '../src/context/capabilities/runtimes.js'; import {direnv, toolVersions} from '../src/context/capabilities/environment.js'; import {docker, helmChart, kubernetes, pulumi, terraform} from '../src/context/capabilities/infrastructure.js'; import {aws, azure, gcp} from '../src/context/capabilities/cloud.js'; @@ -342,3 +342,51 @@ test('vcs.git reads stash depth and upstream from Git files, including linked wo assert.equal(await resolve(gitExtras, {cwd: root}), undefined, 'outside a repository there is nothing to read'); } finally { await rm(root, {recursive: true, force: true}); } }); + +test('Ruby manifest whitespace stays bounded and declarations never span lines', async () => { + resetServiceCaches(); + const root = await workspace(); + try { + await writeFile(join(root, 'Gemfile'), `${'\n \t'.repeat(20_000)}ruby\n"3.3.0"\n`); + const started = Date.now(); + const result = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested', 'requestedFrom']}); + assert.equal(result?.requested, undefined, 'a different line is not a literal Ruby version declaration'); + assert.ok(Date.now() - started < 1500, `${Date.now() - started} ms`); + await writeFile(join(root, 'Gemfile'), ' \truby "3.3.0"\n'); + assert.equal((await resolve(rubyRuntime, {cwd: root, root, env: {PATH: ''}, fields: ['requested']}))?.requested, '3.3.0'); + } finally { await rm(root, {recursive: true, force: true}); resetServiceCaches(); } +}); + +test('runtime facts: Ruby and PHP from the project\'s own files and the install location, never by running them', async () => { + resetServiceCaches(); + const root = await workspace(); + const tools = await workspace(); + try { + const rbenv = join(tools, '.rbenv', 'versions', '3.3.5', 'bin'); + await mkdir(rbenv, {recursive: true}); + await sentinelExecutable(join(rbenv, 'ruby'), join(tools, 'RUBY_RAN'), 'ruby 9.9.9'); + await writeFile(join(root, '.ruby-version'), 'ruby-3.2.4\n'); + await writeFile(join(root, 'Gemfile'), 'source "https://rubygems.org"\nruby "3.1.0"\n'); + const ruby = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: rbenv}}); + assert.deepEqual(ruby, {requested: '3.2.4', requestedFrom: '.ruby-version', active: '3.3.5', manager: 'rbenv', buildTool: 'bundler', mismatch: true}); + await rm(join(root, '.ruby-version')); + assert.deepEqual((await resolve(rubyRuntime, {cwd: root, root, env: {PATH: rbenv}, fields: ['requested', 'requestedFrom']})), {requested: '3.1.0', requestedFrom: 'Gemfile', buildTool: 'bundler'}); + assert.equal(await exists(join(tools, 'RUBY_RAN')), false); + + const cellar = join(tools, 'opt', 'Cellar', 'php', '8.3.12', 'bin'); + await mkdir(cellar, {recursive: true}); + await sentinelExecutable(join(cellar, 'php'), join(tools, 'PHP_RAN'), 'PHP 9.9.9'); + await writeFile(join(root, 'composer.json'), JSON.stringify({require: {php: '^8.2'}})); + const php = await resolve(phpRuntime, {cwd: root, root, env: {PATH: cellar}}); + assert.deepEqual(php, {requested: '^8.2', requestedFrom: 'composer.json', active: '8.3.12', manager: 'Homebrew', buildTool: 'composer'}); + assert.equal(await exists(join(tools, 'PHP_RAN')), false); + // A ruby inside the workspace is never inspected. + const planted = join(root, 'bin'); + await mkdir(planted); + await sentinelExecutable(join(planted, 'ruby'), join(root, 'PLANTED_RAN'), 'ruby 1.0.0'); + resetServiceCaches(); + const refused = await resolve(rubyRuntime, {cwd: root, root, env: {PATH: planted}}); + assert.equal(refused?.active, undefined); + assert.equal(await exists(join(root, 'PLANTED_RAN')), false); + } finally { await rm(root, {recursive: true, force: true}); await rm(tools, {recursive: true, force: true}); resetServiceCaches(); } +}); diff --git a/tests/contextKernel.test.ts b/tests/contextKernel.test.ts index 7f4cccde..cd538fed 100644 --- a/tests/contextKernel.test.ts +++ b/tests/contextKernel.test.ts @@ -112,6 +112,7 @@ test('timeouts are failures with backoff; refresh retries; a previous value stay mode = 'hang'; now += 200; engine.demand(demand('slow.one')); await engine.settle(1000); assert.equal(engine.status('slow.one').state, 'timeout'); + assert.equal(engine.status('slow.one').hasValue, true, 'diagnostics disclose the retained previous value'); assert.match(engine.status('slow.one').error!, /timed out after 20 ms/); assert.equal(engine.facts()['slow.one']!.freshness, 'stale', 'old value kept, marked stale, never refreshed by invention'); assert.equal(engine.stats.timedOut, 1); @@ -138,6 +139,7 @@ test('a capability\'s first resolution gets a cold-start allowance; later ones k engine.stage(scope('/other')); engine.commit(); engine.demand(demand('cold.one')); await engine.settle(2000); assert.equal(engine.status('cold.one').state, 'timeout', 'once warm, the declared limit applies'); + assert.equal(engine.status('cold.one').hasValue, false, 'a different scope has no previous value to reuse'); assert.match(engine.status('cold.one').error!, /timed out after 30 ms/u); delay = 0; engine.refresh(); await engine.settle(2000); diff --git a/tests/historyReuseLive.test.ts b/tests/historyReuseLive.test.ts new file mode 100644 index 00000000..612f6ca7 --- /dev/null +++ b/tests/historyReuseLive.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +test('live: selected history keeps its command and cwd in pins and ephemeral clips without executing', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh', picker: 'native'}); + try { + const frontend = sandbox.launch(); + await frontend.waitFor(/notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + const origin = join(sandbox.home, 'origin'); + mkdirSync(origin); + await frontend.run('cd origin', /Completed/u); + const original = `printf '%s\\n' PIN-HISTORY`; + await frontend.run(original, /PIN-HISTORY/u); + await frontend.run('cd ..', /Completed/u); + await frontend.run('echo LATEST-IS-DIFFERENT', /LATEST-IS-DIFFERENT/u); + let mark = frontend.mark; + frontend.pty.write('/history PIN-HISTORY'); + await frontend.waitFor(/exit 0 · \d+ms/u, mark); + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Pin selected history command/u, mark); + frontend.pty.write('pin selected\r'); + const pins = join(sandbox.config, 'nmsh', 'pins.json'); + await until(() => existsSync(pins), 15_000, 'explicit pin save'); + const data = JSON.parse(readFileSync(pins, 'utf8')); + assert.deepEqual(data.pins.map((pin: {command: string; cwd: string}) => [pin.command, pin.cwd]), [[original, origin]], + 'the selected history record supplies both command and its original cwd'); + + mark = frontend.mark; + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Keep selected history command as clip/u, mark); + frontend.pty.write('keep selected history\r'); + await frontend.waitFor(/Kept clip 1 in memory/u, mark); + frontend.pty.write('\u0015'); + await frontend.run('/clips', /Review clip 1/u); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Review paste/u, mark); + frontend.pty.write('\u001b[27u\u001b[27u'); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + assert.equal(records.filter(record => record.command === original).length, 1, 'pin, keep and review never resubmit'); + assert.equal(records.some(record => record.command.includes('pin selected') || record.command.includes('keep selected')), false); + assert.equal(existsSync(join(sandbox.config, 'nmsh', 'clips.json')), false, 'clips are not persisted'); + + const [session] = await sandbox.sessions(); + assert.ok(session); + frontend.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'detached source frontend'); + const back = sandbox.launch(['--attach', session.id]); + await back.waitFor(/LATEST-IS-DIFFERENT/u); + await back.run('/clips', /No clips kept/u); + assert.deepEqual(JSON.parse(readFileSync(pins, 'utf8')).pins.map((pin: {command: string}) => pin.command), [original], + 'only the explicitly saved pin survives the frontend'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/keys.test.ts b/tests/keys.test.ts index 130c6551..9ba840fc 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -2,6 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {KeyDecoder, decodeKeys} from '../src/terminal/keys.js'; +test('F8 and Shift+F8 navigate failures in legacy and Kitty keyboard forms', () => { + for (const sequence of ['\u001b[19~', '\u001b[19;1~', '\u001b[57371u']) + assert.deepEqual(decodeKeys(sequence), [{kind: 'previousFailure'}]); + for (const sequence of ['\u001b[19;2~', '\u001b[57371;2u']) + assert.deepEqual(decodeKeys(sequence), [{kind: 'nextFailure'}]); +}); + test('legacy raw Escape (0x1B) still decodes on non-Kitty terminals', () => { assert.deepEqual(decodeKeys('\u001B'), [{kind: 'escape'}]); }); diff --git a/tests/moduleDiagnosticsLive.test.ts b/tests/moduleDiagnosticsLive.test.ts new file mode 100644 index 00000000..724aa196 --- /dev/null +++ b/tests/moduleDiagnosticsLive.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +test('live: module diagnostics remain readable in a short NO_COLOR Safe terminal', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'nmsh', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 50, rows: 24}, {NO_COLOR: '1', NMSH_ICONS: 'safe'}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'the shell authenticated prompt'); + await frontend.run('echo READY', /READY/u); + await frontend.run('/modules', /Modules[\s\S]*Catalog[\s\S]*Packs/u); + const start = frontend.mark; + frontend.pty.write('\t/aws\r\r'); + await frontend.waitFor(/PgUp\/PgDn/u, start); + const labels = ['Reads', 'Source', 'Cache', 'Timing', 'Why']; + const seen = new Set(); + for (let step = 0; step < 70 && seen.size < labels.length; step++) { + const mark = frontend.mark; + // A real resize requests a complete repaint, so assertions inspect the + // displayed rows rather than concatenating incremental changed spans. + frontend.pty.resize(step % 2 ? 50 : 51, 24); + await until(() => frontend.output.length > mark, 3000, 'diagnostic repaint'); + const painted = frontend.output.slice(mark); + assert.doesNotMatch(painted, /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + for (const label of labels) if (strip(painted).includes(label)) seen.add(label); + frontend.pty.write('\u001b[B'); + } + assert.deepEqual([...seen].sort(), [...labels].sort(), 'every diagnostic section is reachable'); + frontend.pty.write('\u001b[5~'); + frontend.pty.write('\u001b[27u\u001b[27u\u001b[27u'); + await frontend.run('echo AFTER-DETAIL', /AFTER-DETAIL/u); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/moduleExplain.test.ts b/tests/moduleExplain.test.ts new file mode 100644 index 00000000..f4c9d4ca --- /dev/null +++ b/tests/moduleExplain.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {contextEngineCheck} from '../src/doctor/doctor.js'; +import {whyText} from '../src/prompt/ModulesPanel.js'; + +const module = (overrides: Record = {}) => ({id: 'x', visible: true, condition: 'always', ...overrides}) as Parameters[0]; + +test('"Why" says in one sentence why a module is or is not on screen', () => { + assert.match(whyText(module({visible: false}), 'contextRail', []), /turned off/u); + assert.match(whyText(module(), 'hidden', []), /routed to Hidden/u); + assert.match(whyText(module(), 'contextRail', [{state: 'timeout'}]), /timed out/u); + assert.match(whyText(module(), 'contextRail', [{state: 'failed', error: 'denied'}]), /could not be read/u); + assert.match(whyText(module(), 'contextRail', [{state: 'absent'}, {state: 'absent'}]), /nothing for it to show/u); + assert.match(whyText(module({condition: 'onCommand'}), 'contextRail', [{state: 'idle'}], ['go', 'gofmt']), /after a command that uses it \(go, gofmt…\)/u); + assert.match(whyText(module(), 'contextRail', [{state: 'pending'}]), /being collected/u); + assert.match(whyText(module(), 'contextRail', [{state: 'fresh'}]), /data is available/u); +}); + +test('module explanations do not invent collection or visibility evidence', () => { + assert.match(whyText(module(), 'contextRail', []), /not available/u); + assert.match(whyText(module(), 'contextRail', [{state: 'idle'}]), /not collected/u); + assert.match(whyText(module(), 'contextRail', [{state: 'stale'}]), /last known.*refresh waits for demand/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'stale'}]), /while refreshing/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'fresh'}]), /conditions are met/u); + assert.doesNotMatch(whyText(module(), 'contextRail', [{state: 'failed', error: 'EACCES /Users/demo/private/config token=do-not-display'}]), /private|token|Users/u); + assert.match(whyText(module(), 'contextRail', [{state: 'timeout', hasValue: true}]), /last known data.*retained/u); + assert.match(whyText(module(), 'contextRail', [{state: 'failed', hasValue: false}]), /no cached value/u); + assert.doesNotMatch(whyText(module({condition: 'onCommand'}), 'contextRail', [{state: 'idle'}]), /none has run/u); +}); + +test('/doctor reports prompt-data health from the engine counters, and only complains with enough evidence', () => { + const quiet = {started: 0, completed: 0, cancelled: 0, timedOut: 0, failed: 0, cacheHits: 0, coalesced: 0}; + assert.equal(contextEngineCheck(quiet).state, 'info'); + assert.equal(contextEngineCheck({...quiet, started: 30, completed: 29, timedOut: 1, cacheHits: 80}).state, 'ok'); + assert.equal(contextEngineCheck({...quiet, started: 4, completed: 1, timedOut: 3}).state, 'ok', 'a few samples prove nothing'); + const bad = contextEngineCheck({...quiet, started: 40, completed: 20, timedOut: 15, failed: 5}); + assert.equal(bad.state, 'attention'); + assert.match(bad.detail ?? '', /15 timed out · 5 failed/u); +}); diff --git a/tests/moduleManager.test.ts b/tests/moduleManager.test.ts index 4e8e73ed..95522db5 100644 --- a/tests/moduleManager.test.ts +++ b/tests/moduleManager.test.ts @@ -92,7 +92,7 @@ test('Packs tab: recommendations with evidence, bundled and installed packs, exp s.modulesTab = 'packs'; const shown = text(s); assert.match(shown, /Recommended here\n.*○ Terraform +main\.tf in this project/u); - assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 6 modules/u); + assert.match(shown, /Bundled with NMSh\n.*Project & runtimes +nmsh\.project 1\.0\.0 · bundled · 8 modules/u); assert.match(shown, /Installed\n.*Acme infra +acme\.infra 1\.0\.0 · enabled · 1 modules/u); handlePromptPanelKey(key({kind: 'text', value: ' '}), s); assert.equal(s.draft.modules.find(module => module.id === 'nmsh.infrastructure:terraform')!.visible, true, 'only an explicit Space turns a recommendation on'); diff --git a/tests/moduleManagerUx.test.ts b/tests/moduleManagerUx.test.ts index 9639813e..2934850e 100644 --- a/tests/moduleManagerUx.test.ts +++ b/tests/moduleManagerUx.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; import {handlePromptPanelKey, promptPanelControls, promptPanelOwnsKey, renderPromptPanel, type PromptPanelState} from '../src/prompt/PromptPanel.js'; -import {catalogEntries, listedModules, modulesDirty, modulesItemCount, packEntries, type PackListing} from '../src/prompt/ModulesPanel.js'; +import {renderModulesManager, catalogEntries, listedModules, modulesDirty, modulesItemCount, packEntries, type PackListing} from '../src/prompt/ModulesPanel.js'; import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; import {paletteItems} from '../src/ui/CommandPalette.js'; import {firstPartyPacks} from '../src/context/modules.js'; @@ -160,6 +160,31 @@ test('at 50 columns details wrap instead of truncating, keep Reads/History/statu assert.match(listRows.join('\n'), /Modules {2}Catalog {2}Packs|Modules.*Catalog.*Packs/u, 'tab labels stay recognisable'); }); +test('module details show measured time and safe failure state without raw exception paths or values', () => { + const s = state(); + s.context = {status: () => ({state: 'failed', durationMs: 4.2, error: 'EACCES /Users/demo/private/config token=do-not-display', evidence: 'AWS config'})}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const shown = renderPromptPanel(s, 50, [], [], Infinity).map(stripAnsi).join('\n'); + assert.match(shown, /unavailable \(failed\).*took 4 ms/su); + assert.match(shown, /AWS config/u); + assert.doesNotMatch(shown, /Users|private|token=|do-not-display/u); + assert.match(shown, /cloud\.aws/u, 'the trusted capability is named'); + assert.match(shown, /freshness TTL/u); + assert.match(shown, /queue delay/u); +}); + +test('module details distinguish enabled settings from a condition that currently does not match', () => { + const s = state(); + s.context = {conditionMet: () => false, status: () => ({state: 'fresh', collectedAt: 1000}), now: 2000}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const shown = renderPromptPanel(s, 100, [], [], Infinity).map(stripAnsi).join('\n'); + assert.match(shown, /Enabled.*yes/u); + assert.match(shown, /current condition does not match/u); + assert.doesNotMatch(shown, /Why.*data is available/u); +}); + test('external prompt providers keep the manager useful: unavailable surfaces say why, settings are kept, the Status Strip still works', async () => { const {surfaceAvailability} = await import('../src/context/surfaceRouter.js'); const starship = {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider: 'starship' as const}; @@ -248,3 +273,32 @@ test('/prompt → Modules: A saves module settings only, leaving other draft edi rmSync(root, {recursive: true, force: true}); } }); + +test('short module details expose every diagnostic row through bounded scrolling without editing settings', () => { + const s = state(true); + s.context = {status: () => ({state: 'failed', durationMs: 4.2, hasValue: false, error: 'private-do-not-show'}), now: 4000}; + s.selectedIndex = listedModules(s).findIndex(module => module.id === 'nmsh.cloud:aws'); + handlePromptPanelKey(ENTER, s); + const before = JSON.stringify(s.draft); + const full = renderModulesManager(s, 50).map(stripAnsi); + const first = renderModulesManager(s, 50, s.context, 12).map(stripAnsi); + assert.ok(first.length <= 12, 'details honor the actual panel budget'); + const seen = new Set(first); + for (let step = 0; step < full.length; step++) { + handlePromptPanelKey({kind: 'down'}, s); + for (const row of renderModulesManager(s, 50, s.context, 12).map(stripAnsi)) seen.add(row); + } + for (const row of full) assert.ok(seen.has(row), 'every source/cache/timing/why row is reachable: ' + row); + assert.match([...seen].join('\n'), /Source[\s\S]*Cache[\s\S]*Timing[\s\S]*Why/u); + assert.doesNotMatch([...seen].join('\n'), /private-do-not-show/u); + assert.equal(JSON.stringify(s.draft), before, 'reading does not edit or persist preferences'); + const bottom = renderModulesManager(s, 50, s.context, 12); + handlePromptPanelKey({kind: 'down'}, s); + assert.deepEqual(renderModulesManager(s, 50, s.context, 12), bottom, 'scroll stops at the final row'); + handlePromptPanelKey({kind: 'pageUp'}, s); + assert.notDeepEqual(renderModulesManager(s, 50, s.context, 12), bottom, 'PageUp reads earlier details'); + renderModulesManager(s, 120, s.context, 1000); + assert.equal(renderModulesManager(s, 50, s.context, 12).length, 12, 'resize clamps the viewport'); + handlePromptPanelKey(ESC, s); + assert.equal(s.detail, undefined, 'Esc returns to the same module selection'); +}); diff --git a/tests/outputReferences.test.ts b/tests/outputReferences.test.ts new file mode 100644 index 00000000..b87e9c89 --- /dev/null +++ b/tests/outputReferences.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {findOutputReferences, MAX_REFERENCES, referenceFollowUp} from '../src/output/references.js'; + +const refs = (command: string, output: string) => findOutputReferences({command, output}); +const labels = (command: string, output: string) => refs(command, output).map(reference => reference.label); + +test('source locations: relative, absolute, with columns; a bare word or a number is not a location', () => { + const found = refs('npm test', 'at src/main.ts:42:7\n /abs/path/file.py:9\nsee 3:45 and version 1.2:3 and foo\n'); + assert.deepEqual(found.map(item => [item.kind, item.value, item.line, item.column]), [['file', 'src/main.ts', 42, 7], ['file', '/abs/path/file.py', 9, undefined]]); +}); + +test('links: http(s) only, trailing punctuation trimmed, brackets kept only when balanced', () => { + assert.deepEqual(labels('curl x', 'see https://example.com/a?b=1. And (https://example.com/x) or https://en.wikipedia.org/wiki/Foo_(bar), ok'), + ['https://example.com/a?b=1', 'https://example.com/x', 'https://en.wikipedia.org/wiki/Foo_(bar)']); + assert.deepEqual(labels('x', 'ftp://example.com/a file:///etc/passwd javascript:alert(1) data:text/html,x'), []); +}); + +test('a link with credentials is a secret, not a destination', () => { + assert.deepEqual(labels('git clone x', 'https://user:token@github.com/a/b.git https://token@github.com/a/b.git'), []); +}); + +test('an international host is shown as the punycode it really is', () => { + const [link] = refs('x', 'https://аpple.com/login'); + assert.equal(link?.kind, 'url'); + assert.match(link!.value, /^https:\/\/xn--/u); +}); + +test('hostile text: controls, bidi and zero-width characters make a reference unusable, never repaired', () => { + assert.deepEqual(labels('x', 'https://exa​mple.com/a https://example.com/‮evil src/\u0007bell.ts:3 src/a‮.ts:3'), []); + // An escape sequence around a path is stripped as terminal styling; the path itself stays. + assert.deepEqual(labels('x', '\u001b[31msrc/main.ts:3\u001b[0m'), ['src/main.ts:3']); + assert.deepEqual(labels('x', 'src/main.ts:3\u001b]8;;https://evil.example\u0007 click\u001b]8;;\u0007'), ['src/main.ts:3']); +}); + +test('commits are only recognised in Git output, in the places Git prints them', () => { + const log = 'commit a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\nAuthor: x\n\n fixed 1234567 things\n* 9fceb02 subject\n[main 4f2a1c9] message\n'; + assert.deepEqual(labels('git log', log), ['a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', '9fceb02', '4f2a1c9']); + assert.deepEqual(labels('cat file', log), []); + assert.deepEqual(labels('FOO=1 git log --oneline', '1234567 all digits is not a commit\ndead6ee ok\n'), ['dead6ee']); +}); + +test('devices are only recognised in `adb devices`, and only a ready device has a follow-up', () => { + const output = 'List of devices attached\nR58M12ABC\tdevice\nemulator-5554\toffline\n192.168.1.5:5555\tdevice\n* daemon started successfully\n'; + const found = refs('adb devices', output); + assert.deepEqual(found.map(item => item.value), ['R58M12ABC', 'emulator-5554', '192.168.1.5:5555']); + assert.deepEqual(refs('adb devices -l', output).length, 3); + assert.deepEqual(refs('echo', output), []); + assert.deepEqual(referenceFollowUp(found[0]!), ['adb', '-s', 'R58M12ABC', 'shell']); + assert.equal(referenceFollowUp(found[1]!), undefined); +}); + +test('follow-ups are argv the person reviews: a hostile value never becomes one', () => { + assert.equal(referenceFollowUp({id: 'x', kind: 'commit', value: 'a1b2c3d; rm -rf ~', label: ''}), undefined); + assert.equal(referenceFollowUp({id: 'x', kind: 'device', value: 'x; reboot', label: '', state: 'device'}), undefined); + assert.equal(referenceFollowUp({id: 'x', kind: 'file', value: 'a.ts', label: '', line: 1}), undefined); + assert.deepEqual(referenceFollowUp({id: 'x', kind: 'commit', value: 'dead6ee', label: ''}), ['git', 'show', 'dead6ee']); +}); + +test('repeats are one reference; the list and the work are bounded for any output', () => { + assert.equal(refs('x', 'src/a.ts:1\nsrc/a.ts:1\nsrc/a.ts:2\n').length, 2); + const huge = Array.from({length: 5000}, (_, index) => `src/file${index}.ts:${index + 1}`).join('\n'); + assert.equal(refs('x', huge).length, MAX_REFERENCES); + const started = Date.now(); + refs('x', `${'a'.repeat(3_000_000)}\n${'https://example.com/'.repeat(2000)}\n`); + assert.ok(Date.now() - started < 3000); +}); diff --git a/tests/outputReferencesApp.test.ts b/tests/outputReferencesApp.test.ts new file mode 100644 index 00000000..ba6a47d1 --- /dev/null +++ b/tests/outputReferencesApp.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {blockPaletteItems} from '../src/ui/BlockActions.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + return app; +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +function run(output: OutputBuffer, command: string, text: string, exitCode: number) { + output.beginCommand(command, [command]); + output.write(text); + output.complete(exitCode); + return output.recent(1)!; +} + +test('the block menu offers references only when the output has some', () => { + const output = new OutputBuffer(); + const plain = run(output, 'echo hi', 'hi\n', 0); + assert.equal(blockPaletteItems(plain).some(item => item.action.kind === 'block' && item.action.id === 'references'), false); + const failing = run(output, 'npm test', 'FAIL src/main.ts:42:7\nsee https://example.com/doc\n', 1); + assert.equal(blockPaletteItems(failing).some(item => item.action.kind === 'block' && item.action.id === 'references'), true); +}); + +test('the references menu lists an action and Copy for each, and says so when there are none', async () => { + const instance = app(); + try { + const record = run(instance['output'], 'npm test', 'FAIL src/main.ts:42:7\nsee https://example.com/doc\n', 1); + await instance['runBlockAction'](record.startId, 'references'); + const items = instance['paletteState']!.items; + assert.deepEqual(items.map(item => item.label), ['Open src/main.ts:42:7', 'Copy src/main.ts:42:7', 'Open https://example.com/doc', 'Copy https://example.com/doc']); + instance['paletteState'] = undefined; + const quiet = run(instance['output'], 'echo hi', 'hi\n', 0); + await instance['runBlockAction'](quiet.startId, 'references'); + assert.equal(instance['paletteState'], undefined); + } finally { dispose(instance); } +}); + +test('Inspect commit stages a command for review: it never runs, and never replaces what is typed', async () => { + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push(instance['editor'].text); }}); + const record = run(instance['output'], 'git log --oneline', '* dead6ee subject\n', 0); + const [reference] = (await import('../src/output/references.js')).findOutputReferences(record); + instance['editor'].insert('ls -la'); + await instance['runReference'](record.startId, reference!.id, 'stage'); + assert.equal(instance['editor'].text, 'ls -la', 'typed text is untouched'); + instance['editor'].clear(); + await instance['runReference'](record.startId, reference!.id, 'stage'); + assert.equal(instance['editor'].text, 'git show dead6ee'); + assert.deepEqual(submitted, [], 'staging never submits'); + } finally { dispose(instance); } +}); + +test('an action on a reference that is gone, or a hostile one, does nothing', async () => { + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + const record = run(instance['output'], 'git log', 'commit dead6ee; rm -rf ~\n', 0); + await instance['runReference'](record.startId, 'commit:dead6ee; rm -rf ~', 'stage'); + await instance['runReference'](999_999, 'commit:dead6ee', 'stage'); + assert.equal(instance['editor'].text, ''); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('failure navigation walks failed blocks by command record: nonzero exits and interrupts count, error text does not', () => { + const instance = app(); + try { + const output = instance['output']; + const first = run(output, 'make a', 'error: looked bad but exited 0\n', 0); + const older = run(output, 'make b', 'boom\n', 2); + run(output, 'make c', 'fine\n', 0); + const newest = run(output, 'sleep 99', '', 130); + assert.deepEqual(instance['failedBlockIndexes'](), [0, 2]); + instance['goToFailure']('previous'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, newest.startId); + instance['goToFailure']('previous'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, older.startId); + const before = instance['focusedCommandIndex']; + instance['goToFailure']('previous'); + assert.equal(instance['focusedCommandIndex'], before, 'no older failure: focus stays'); + instance['goToFailure']('next'); + assert.equal(output.recent((instance['focusedCommandIndex'] ?? 0) + 1)?.startId, newest.startId); + assert.notEqual(first.startId, newest.startId); + } finally { dispose(instance); } +}); + +test('the palette offers failure navigation only when there is a failure', () => { + const instance = app(); + try { + run(instance['output'], 'echo ok', 'ok\n', 0); + instance['openPalette'](); + assert.equal(instance['paletteState']!.items.some(item => item.action.kind === 'failure'), false); + instance['paletteState'] = undefined; + run(instance['output'], 'false', '', 1); + instance['openPalette'](); + assert.equal(instance['paletteState']!.items.filter(item => item.action.kind === 'failure').length, 2); + } finally { dispose(instance); } +}); + +test('failure keys preserve the composer and are inactive while a program owns input', () => { + const instance = app(); + try { + run(instance['output'], 'false', '', 1); + instance['editor'].insert('echo draft'); + instance['handleKey']({kind: 'previousFailure'}); + assert.equal(instance['focusedCommandIndex'], 0); + assert.equal(instance['editor'].text, 'echo draft'); + instance['focusedCommandIndex'] = undefined; + instance['onShellExec']('sleep 30'); + instance['handleKey']({kind: 'previousFailure'}); + assert.equal(instance['focusedCommandIndex'], undefined); + } finally { dispose(instance); } +}); diff --git a/tests/outputReferencesLive.test.ts b/tests/outputReferencesLive.test.ts new file mode 100644 index 00000000..5fb30913 --- /dev/null +++ b/tests/outputReferencesLive.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until, type Frontend} from './helpers/liveFrontend.js'; + +test('live: failure keys and reference copies use restored command records and preserve a narrow composer draft', { + skip: !['darwin', 'linux'].includes(process.platform), timeout: 120_000, +}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + try { + const bin = join(sandbox.root, 'fake-bin'); + const clipboard = join(sandbox.root, 'clipboard'); + mkdirSync(bin); + writeFileSync(clipboard, 'UNCHANGED'); + for (const name of ['pbcopy', 'wl-copy', 'xclip']) { + const path = join(bin, name); + // Publish complete clipboard bytes in one rename; truncation is not completion. + writeFileSync(path, `#!/bin/sh\ntemporary="${clipboard}.pending.$$"\ntrap 'rm -f "$temporary"' EXIT\ncat > "$temporary" && mv "$temporary" "${clipboard}"\n`); + chmodSync(path, 0o755); + } + const env = {PATH: `${bin}:${process.env.PATH}`, NO_COLOR: '1', NMSH_ICONS: 'safe', + ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}; + const first = sandbox.launch([], {cols: 100, rows: 30}, env); + await first.waitFor(/notMyShell|zsh/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + await first.run(`printf 'OLDER-%s\\n' FAILURE; false`, /OLDER-FAILURE/u); + await first.run(`printf 'error:%s\\n' HARMLESS`, /error:HARMLESS/u); + await first.run(`printf 'src/%s.ts:42:7\\nhttps://example.com/%s\\n' latest docs; false`, /src\/latest.ts:42:7/u); + await first.run(`printf 'LAST-%s\\n' SUCCESS`, /LAST-SUCCESS/u); + const [session] = await sandbox.sessions(); + assert.ok(session); + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'detached shell'); + const back = sandbox.launch(['--attach', session.id], {cols: 50, rows: 24}, env); + await back.waitFor(/LAST-SUCCESS/u); + assert.equal(readFileSync(clipboard, 'utf8'), 'UNCHANGED', 'restoring output does not copy anything'); + back.pty.write('echo DRAFT-UNCHANGED'); + + async function palette(target: Frontend, query: string, expect: RegExp) { + const mark = target.mark; + target.pty.write('\u001bOP'); + await target.waitFor(/Command palette/u, mark); + target.pty.write(query); + await target.waitFor(expect, mark); + target.pty.write('\r'); + } + async function copyOutput(key: string, expected: string) { + writeFileSync(clipboard, 'UNCHANGED'); + back.pty.write(key); + await palette(back, 'copy output', /Copy output/u); + await until(() => readFileSync(clipboard, 'utf8') !== 'UNCHANGED', 15_000, 'selected failure clipboard bytes'); + assert.equal(readFileSync(clipboard, 'utf8'), expected); + } + // Successful output containing "error" is skipped. Navigation chooses records, not matching output rows. + await copyOutput('\u001b[19~', 'src/latest.ts:42:7\nhttps://example.com/docs'); + await copyOutput('\u001b[19~', 'OLDER-FAILURE'); + await copyOutput('\u001b[19;2~', 'src/latest.ts:42:7\nhttps://example.com/docs'); + + await palette(back, 'files links', /Files, links and more/u); + const mark = back.mark; + back.pty.write('copy src'); + await back.waitFor(/Copy src\/latest.ts:42:7/u, mark); + writeFileSync(clipboard, 'UNCHANGED'); + back.pty.write('\r'); + await until(() => readFileSync(clipboard, 'utf8') !== 'UNCHANGED', 15_000, 'explicit file reference copy'); + assert.equal(readFileSync(clipboard, 'utf8'), 'src/latest.ts:42:7'); + assert.doesNotMatch(back.output, /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + + // Enter belongs to the focused historical block until Esc returns to the composer. + // Assert that opening its menu cannot execute the preserved draft. + const focusMark = back.mark; + back.pty.write('\r'); + await back.waitFor(/Command palette/u, focusMark); + assert.equal((await sandbox.transcripts().list()).flatMap(item => item.transcript.records) + .some(record => record.command === 'echo DRAFT-UNCHANGED'), false); + back.pty.write('\u001b[27u'); // close the block menu + back.pty.write('\u001b[27u'); // return focus to the composer + back.pty.write('\r'); + await until(async () => (await sandbox.transcripts().list()).some(item => + item.transcript.records.some(record => record.command === 'echo DRAFT-UNCHANGED')), + 15_000, 'the original composer draft executes only on its explicit Enter'); + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + assert.equal(records.filter(record => record.command === 'echo DRAFT-UNCHANGED').length, 1); + assert.equal(records.some(record => record.command.includes('copy output') || record.command === 'copy src'), false, + 'action searches never became shell commands'); + assert.equal(existsSync(join(sandbox.config, 'nmsh', 'pins.json')), false, 'navigation never saved commands'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/pasteBatch.test.ts b/tests/pasteBatch.test.ts new file mode 100644 index 00000000..7530a346 --- /dev/null +++ b/tests/pasteBatch.test.ts @@ -0,0 +1,258 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import {MAX_BATCH_CHARACTERS, splitBatch, type BatchShell} from '../src/input/pasteBatch.js'; +import {shellSubmission} from '../src/shell/submission.js'; +import {mkdtemp, access, rm} from 'node:fs/promises'; +import {join} from 'node:path'; + +const texts = (input: string, shell: BatchShell) => { + const result = splitBatch(input, shell); + assert.ok(result.ok, result.ok ? '' : result.reason); + return result.ok ? result.commands.map(command => command.text) : []; +}; +const refuses = (input: string, shell: BatchShell) => { const result = splitBatch(input, shell); assert.equal(result.ok, false, JSON.stringify(result)); return result.ok ? '' : result.reason; }; + +const POSIX: BatchShell[] = ['zsh', 'bash']; + +test('independent lines are separate commands, in order; blank lines vanish between them', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('cd ~/work\n\nls -la\ngit status\n', shell), ['cd ~/work', 'ls -la', 'git status']); + assert.deepEqual(texts(' echo "a b" \r\necho c', shell), ['echo "a b"', 'echo c']); + } + const result = splitBatch('a\n\nb\nc', 'bash'); + assert.deepEqual(result.ok && result.commands.map(command => [command.firstLine, command.lastLine]), [[1, 1], [3, 3], [4, 4]]); +}); + +test('chains and pipelines on one line stay one command: splitting them would change what runs', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('make && make install\necho a; echo b\nls | wc -l', shell), ['make && make install', 'echo a; echo b', 'ls | wc -l']); + } +}); + +test('a trailing backslash, && , || or | continues the command onto the next line', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('docker run \\\n -it \\\n alpine\nls', shell), ['docker run \\\n -it \\\n alpine', 'ls']); + assert.deepEqual(texts('make &&\n make test ||\n echo failed\nls |\n wc -l', shell), ['make &&\n make test ||\n echo failed', 'ls |\n wc -l']); + } +}); + +test('quotes, including multiline strings, hold their newlines', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('echo "line one\nline two"\necho \'a\nb\'\necho x', shell), ['echo "line one\nline two"', 'echo \'a\nb\'', 'echo x']); + assert.deepEqual(texts('echo "it\'s ok"\necho \'say "hi"\'', shell), ['echo "it\'s ok"', 'echo \'say "hi"\'']); + assert.deepEqual(texts('echo "a \\" b"\necho c', shell), ['echo "a \\" b"', 'echo c']); + } + for (const shell of POSIX) assert.deepEqual(texts("echo $'it\\'s\nnew'\necho z", shell), ["echo $'it\\'s\nnew'", 'echo z']); + assert.deepEqual(texts("echo 'it\\'s'\necho z", 'fish'), ["echo 'it\\'s'", 'echo z']); +}); + +test('command substitutions and groups hold their newlines', () => { + for (const shell of POSIX) { + assert.deepEqual(texts('x=$(\n ls\n pwd\n)\necho "$x"\n(\n cd /tmp\n ls\n)\n{ echo a\n echo b\n}', shell), ['x=$(\n ls\n pwd\n)', 'echo "$x"', '(\n cd /tmp\n ls\n)', '{ echo a\n echo b\n}']); + assert.deepEqual(texts('echo `date\n`\necho ${HOME:-\n}', shell).length, 2); + } + assert.deepEqual(texts('set x (\n ls\n)\necho $x', 'fish'), ['set x (\n ls\n)', 'echo $x']); +}); + +test('heredocs keep their bodies, including lines that look like commands', () => { + for (const shell of POSIX) { + assert.deepEqual(texts('cat < f\n$HOME\nEOF\ncat <<-END\n\tbody\n\tEND\npwd", shell), ["cat <<'EOF' > f\n$HOME\nEOF", 'cat <<-END\n\tbody\n\tEND', 'pwd']); + assert.deepEqual(texts('cat < { + const block = 'if [ -f x ]; then\n echo yes\nelse\n echo no\nfi'; + const loop = 'for f in *.txt; do\n echo "$f"\ndone'; + const cases = 'case "$1" in\n a) echo A ;;\n b|c) echo BC ;;\nesac'; + const fn = 'greet() {\n echo hi\n}'; + const fn2 = 'function bye {\n echo bye\n}'; + const loop2 = 'while read line; do\n echo "$line"\ndone < file'; + const test2 = '[[ -n $x && ( $y == z || $y == w ) ]]'; + for (const shell of POSIX) { + assert.deepEqual(texts([block, loop, cases, fn, fn2, loop2, test2, 'echo done'].join('\n'), shell), [block, loop, cases, fn, fn2, loop2, test2, 'echo done']); + } + const fishBlock = 'if test -f x\n echo yes\nelse if test -d x\n echo dir\nelse\n echo no\nend'; + const fishFor = 'for f in *.txt\n echo $f\nend'; + const fishFn = 'function greet\n echo hi\nend'; + const fishSwitch = 'switch $x\n case a\n echo A\n case b c\n echo BC\nend'; + const fishBegin = 'begin\n echo a\n echo b\nend | cat'; + assert.deepEqual(texts([fishBlock, fishFor, fishFn, fishSwitch, fishBegin, 'echo done'].join('\n'), 'fish'), [fishBlock, fishFor, fishFn, fishSwitch, fishBegin, 'echo done']); + assert.deepEqual(texts('test -f x\nand echo yes\nor echo no\nls', 'fish'), ['test -f x\nand echo yes\nor echo no', 'ls']); +}); + +test('comments stay with the command they precede; trailing ones with the command above', () => { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.deepEqual(texts('# install\nnpm i\n# build\nnpm run build # inline\n# done', shell), ['# install\nnpm i', '# build\nnpm run build # inline\n# done']); + } + for (const shell of ['bash', 'fish'] as const) assert.deepEqual(texts('echo a # it\'s fine\necho b', shell), ['echo a # it\'s fine', 'echo b']); +}); + +test('zsh does not treat # as a comment unless told to, so a comment with syntax in it is refused, never shown as a comment', () => { + // Interactive zsh runs everything after the # as ordinary shell text: `; echo x` and `$( )` execute. + for (const hidden of ['# note; echo EXECUTED', '# note $(touch /tmp/x)', 'ls # `id`', '# a && b', '# it\'s', '# a > b', '# \\']) { + assert.match(refuses(`${hidden}\nls`, 'zsh'), /comment contains shell syntax/u, hidden); + } + // Bash and Fish comments are comments. + assert.deepEqual(texts('# note; echo not-run\nls', 'bash'), ['# note; echo not-run\nls']); + assert.deepEqual(texts('# note; echo not-run\nls', 'fish'), ['# note; echo not-run\nls']); +}); + +test('comments inside substitutions and expansion in heredoc markers are refused; marker quotes are removed like the shell does', () => { + for (const shell of POSIX) { + assert.match(refuses('x=$(echo hi # )\nrm -rf ~\n)\nls', shell), /comment inside a substitution/u); + assert.match(refuses('x=$(echo hi\n# note\n)\nls', shell), /comment inside a substitution/u); + assert.deepEqual(texts('echo $(( 1 + 2 ))\nx=$(echo "a#b")\necho ${#x} $#\nls', shell), ['echo $(( 1 + 2 ))', 'x=$(echo "a#b")', 'echo ${#x} $#', 'ls']); + assert.deepEqual(texts('cat < { + for (const shell of POSIX) { + assert.deepEqual(texts('(( x = 1 << 2 ))\necho $((1<<3))\nls', shell), ['(( x = 1 << 2 ))', 'echo $((1<<3))', 'ls']); + assert.deepEqual(texts('echo $(( (1 << 2) + 1 ))\nls', shell), ['echo $(( (1 << 2) + 1 ))', 'ls']); + assert.match(refuses('x=$(cat < { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + assert.match(refuses('echo "never closed\nls', shell), /double quote/u); + assert.match(refuses("echo 'never closed\nls", shell), /single quote/u); + assert.match(refuses('echo $(ls\nls', shell), /parenthesis/u); + assert.match(refuses('make &&', shell), /middle of a command/u); + assert.match(refuses('echo ok\n)', shell), /parenthesis/u); + } + for (const shell of POSIX) { + assert.match(refuses('fi\nls', shell), /fi/u); + assert.match(refuses('if true; then\n echo hi', shell), /never closed/u); + assert.match(refuses('cat < { + assert.match(refuses(`${'$('.repeat(5000)}${')'.repeat(5000)}`, 'bash'), /deeply/u); + assert.match(refuses(`${'('.repeat(5000)}`, 'bash'), /deeply/u); + assert.match(refuses(`${'if true; then\n'.repeat(1000)}`, 'bash'), /deeply|never closed/u); + const started = Date.now(); + const many = Array.from({length: 40_000}, (_, index) => `echo line ${index}`).join('\n'); + assert.equal(texts(many, 'zsh').length, 40_000); + assert.ok(Date.now() - started < 3000, `${Date.now() - started} ms`); + assert.equal(texts(`${'\n'.repeat(200_000)}echo x${'\n'.repeat(200_000)}`, 'bash').length, 1); +}); + +test('the commands are the paste, in order: joined back they hold every non-blank source character', () => { + const source = '# setup\ncd /tmp && ls\ncat < { + const args = shell === 'fish' ? ['--no-config', '--no-execute', '-c', code] : ['-n']; + const result = spawnSync(shell, args, {input: shell === 'fish' ? undefined : code, encoding: 'utf8', timeout: 5000}); + if (result.status !== 0) console.error(`${shell} parse status ${result.status}: ${result.error?.message ?? result.stderr}`); + return result.error ? undefined : result.status === 0; +}; +const FIXTURES: Record = { + bash: 'cd /tmp\nif [ -f x ]; then\n echo yes\nelse\n echo no\nfi\nfor f in a b; do\n echo "$f"\ndone\ncat < { + assert.equal(parses(shell, FIXTURES[shell]), true, 'the fixture is valid shell'); + const commands = texts(FIXTURES[shell], shell); + assert.ok(commands.length >= 8, String(commands.length)); + for (const command of commands) assert.equal(parses(shell, command), true, `${command}`); + }); +} + +/** + * The differential check: run a paste whole, and run the commands the splitter made of it one after another, each + * submitted the way the queue does (one native shell group). Both must do exactly the same thing. + * Only echo, printf, variables and control flow run here; nothing touches the disk or the network. + */ +const PIECES: Record = { + bash: ['cat < string[]> = { + bash: script => ['bash', '--noprofile', '--norc', '-c', script], + zsh: script => ['zsh', '-f', '-c', script], + fish: script => ['fish', '--no-config', '-c', script], +}; +const execute = (shell: BatchShell, script: string) => { + const [command, ...args] = RUN[shell](script); + const result = spawnSync(command!, args, {encoding: 'utf8', timeout: 8000, cwd: tmpdir(), env: {PATH: process.env.PATH ?? '', HOME: tmpdir()}}); + return `${result.stdout}\n--stderr--\n${result.stderr.replace(/line \d+|-c: line \d+|:\d+:/gu, '')}\n--status ${result.status}`; +}; +for (const shell of ['bash', 'zsh', 'fish'] as const) { + test(`${shell}: running a paste whole and running its split commands one after another do exactly the same`, {skip: parses(shell, 'true') === undefined ? `${shell} is not installed` : false, timeout: 120_000}, () => { + let seed = shell.length * 7919; + const random = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; + let compared = 0; + const rounds = Number(process.env.NMSH_FUZZ_ROUNDS ?? 40); + for (let round = 0; round < rounds; round += 1) { + const count = 2 + Math.floor(random() * 5); + const pieces = Array.from({length: count}, () => PIECES[shell][Math.floor(random() * PIECES[shell].length)]!); + const paste = pieces.join('\n'); + const result = splitBatch(paste, shell); + if (!result.ok) continue; + compared += 1; + const whole = execute(shell, paste); + const parts = execute(shell, result.commands.map(command => shellSubmission(command.text, shell)).join('\n')); + assert.equal(parts, whole, `${shell}: ${JSON.stringify(paste)} split into ${JSON.stringify(result.commands.map(command => command.text))}`); + } + assert.ok(compared >= Math.floor(rounds * 0.6), `only ${compared} comparable pastes`); + }); +} + +test('the real shell checks every command on its own before a split is shown; a boundary through a construct is refused', async () => { + const {verifyCommands} = await import('../src/input/pasteBatchCheck.js'); + for (const shell of ['bash', 'zsh', 'fish'] as const) { + if (parses(shell, 'true') === undefined) continue; + assert.deepEqual(await verifyCommands(texts(FIXTURES[shell], shell), shell), {ok: true}); + // A command cut in the middle is not whole syntax, however the cut came about. + const fragments = shell === 'fish' ? ['echo "never closed', 'if true\n echo hi'] : ['x=$(echo hi # )', 'echo "never closed', 'if true; then\n echo hi', 'cat < { + const {verifyCommands} = await import('../src/input/pasteBatchCheck.js'); + const directory = await mkdtemp(join(tmpdir(), 'nmsh-maple-parse-only-')); + const marker = join(directory, 'must-not-exist'); + try { + for (const shell of ['bash', 'zsh', 'fish'] as const) { + if (parses(shell, 'true') === undefined) continue; + const substitution = shell === 'fish' ? `(touch '${marker}')` : `$(touch '${marker}')`; + assert.deepEqual(await verifyCommands([`echo ${substitution}\ntouch '${marker}'`], shell), {ok: true}); + await assert.rejects(access(marker), {code: 'ENOENT'}, shell); + } + } finally { await rm(directory, {recursive: true, force: true}); } +}); diff --git a/tests/pasteBatchApp.test.ts b/tests/pasteBatchApp.test.ts new file mode 100644 index 00000000..951965b5 --- /dev/null +++ b/tests/pasteBatchApp.test.ts @@ -0,0 +1,264 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {batchReviewKey, createBatchReview, renderBatchReview} from '../src/input/BatchReview.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const SEQUENCE = 'cd /tmp\nrm -rf build\nfor f in a b; do\n echo "$f"\ndone\ncat < ({kind: 'text', value}); + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + // These tests inspect the accepted queue, not execution. Keep it paused even + // if shell startup finishes while the asynchronous syntax check is pending. + app['session'].queue({op: 'pause'}); + return app; +} +/** B verifies every command with the real shell before the review appears. */ +async function reviewing(instance: TerminalApp): Promise { + const deadline = Date.now() + 8000; + while (!instance['batchReview'] && Date.now() < deadline) await new Promise(done => setTimeout(done, 10)); +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +const queued = (instance: TerminalApp) => instance['queueState'].entries.map(entry => entry.text); + +test('/ps is an NMSh command that reads the clipboard once, on request', () => { + assert.deepEqual(parseSlashCommand('/ps'), {kind: 'pasteClipboard'}); + assert.notEqual(parseSlashCommand('/ps extra')?.kind, 'pasteClipboard'); + assert.equal(parseSlashCommand('ps aux'), undefined); +}); + +test('a reviewed batch lists the commands the shell would run; nothing is queued by pasting or reviewing', () => { + const review = createBatchReview(SEQUENCE, 'bash', 100)!; + assert.deepEqual(review.entries.map(entry => entry.text), ['cd /tmp', 'rm -rf build', 'for f in a b; do\n echo "$f"\ndone', 'cat < { + const review = createBatchReview(SEQUENCE, 'bash', 100)!; + const key = (value: Key) => batchReviewKey(review, value, 26); + key({kind: 'down'}); + assert.equal(key(text('x')), undefined); + assert.deepEqual(review.entries.map(entry => entry.text).slice(0, 2), ['cd /tmp', 'for f in a b; do\n echo "$f"\ndone']); + key(text('j')); + assert.equal(review.cursor, 2); + key(text('k')); + key(text('k')); + assert.equal(review.entries[0]!.text.startsWith('for f'), true); + assert.equal(key(text('t'))?.kind, 'text'); + assert.equal(key(text('e'))?.kind, 'edit'); + assert.equal(key({kind: 'escape'})?.kind, 'back'); + assert.equal(key({kind: 'interrupt'})?.kind, 'cancel'); + assert.equal(key({kind: 'enter'})?.kind, 'queue'); + review.capacity = 2; + assert.equal(key({kind: 'enter'}), undefined, 'more commands than the queue has room for: no partial queueing'); +}); + +test('the review fits narrow windows and shows every selected command in full, safely', () => { + const review = createBatchReview('echo ok\nprintf "a\\033[31mred"\n\u001b[2Jclear', 'bash', 100)!; + for (const columns of [30, 50, 80, 120]) { + const rows = renderBatchReview(review, columns, 20); + assert.ok(rows.length <= 20); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.ok(!renderBatchReview(review, 80, 20).join('\n').includes('\u001b[2J')); +}); + +test('pasting a multi-command block offers B; Enter on the review queues exactly the reviewed commands, in order, once', async () => { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: SEQUENCE}); + assert.equal(instance['pastePreview']?.batch, true); + assert.deepEqual(queued(instance), [], 'pasting alone queues nothing'); + instance['handleKey'](text('b')); + await reviewing(instance); + assert.ok(instance['batchReview']); + assert.deepEqual(queued(instance), [], 'reviewing alone queues nothing'); + instance['handleKey'](text('x')); + instance['handleKey']({kind: 'enter'}); + assert.equal(instance['batchReview'], undefined); + assert.equal(instance['pastePreview'], undefined); + assert.deepEqual(queued(instance), ['rm -rf build', 'for f in a b; do\n echo "$f"\ndone', 'cat < { + for (const choice of ['t', 'e'] as const) { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'ls\npwd'}); + instance['handleKey'](text('b')); + await reviewing(instance); + instance['handleKey'](text(choice)); + assert.deepEqual(queued(instance), []); + assert.equal(instance['editor'].text.includes('ls'), true); + assert.equal(instance['batchReview'], undefined); + } finally { dispose(instance); } + } + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'ls\npwd'}); + instance['handleKey'](text('b')); + await reviewing(instance); + instance['handleKey']({kind: 'escape'}); + assert.equal(instance['batchReview'], undefined); + assert.ok(instance['pastePreview'], 'back to the compact preview'); + instance['handleKey']({kind: 'escape'}); + assert.equal(instance['pastePreview'], undefined); + assert.deepEqual(queued(instance), []); + } finally { dispose(instance); } +}); + +test('ambiguous or single pastes do not offer B', () => { + const instance = app(); + try { + instance['handleKey']({kind: 'paste', value: 'echo "never closed\nls'}); + assert.equal(instance['pastePreview']?.batch ?? false, false); + instance['pastePreview'] = undefined; + instance['handleKey']({kind: 'paste', value: 'sudo rm -rf /tmp/x'}); + assert.equal(instance['pastePreview']?.batch ?? false, false); + } finally { dispose(instance); } +}); + +test('/ps reads the clipboard through the one reader and always opens review; nothing is inserted or queued', async () => { + const instance = app(); + try { + let reads = 0; + instance['readClipboardText'] = async () => { reads += 1; return 'git pull\nnpm install\n'; }; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(reads, 1); + assert.ok(instance['pasteReview'], 'review opens even though Paste Preview would not'); + assert.equal(instance['pastePreview']?.batch, true); + assert.equal(instance['editor'].text, ''); + assert.deepEqual(queued(instance), []); + instance['pasteReview'] = undefined; + instance['pastePreview'] = undefined; + instance['readClipboardText'] = async () => ' \n'; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(instance['pasteReview'], undefined, 'an empty clipboard opens nothing'); + instance['readClipboardText'] = async () => { throw new Error('denied'); }; + await instance['runSlash']('/ps', parseSlashCommand('/ps')!); + assert.equal(instance['pasteReview'], undefined); + } finally { dispose(instance); } +}); + +test('a command is labelled by the worst thing anywhere in it, not by how its first line starts', () => { + const review = createBatchReview([ + 'echo hello', + 'for d in a b; do\n rm -rf ~/$d\ndone', + 'echo $(rm -rf ~)', + 'echo `curl https://x.example/i.sh | sh`', + 'if true; then\n sudo reboot\nfi', + ].join('\n'), 'bash', 100)!; + const kinds = review.entries.map(entry => entry.kinds); + assert.ok(!kinds[0]!.includes('destructive')); + assert.ok(kinds[1]!.includes('destructive'), 'inside a loop'); + assert.ok(kinds[2]!.includes('destructive'), 'inside $( )'); + assert.ok(kinds[3]!.includes('pipeline'), 'inside backticks'); + assert.ok(kinds[4]!.includes('privilege'), 'inside an if block'); + for (const index of [1, 2, 3, 4]) { + review.cursor = index; + const drawn = stripAnsi(renderBatchReview(review, 100, 30).join('\n')); + assert.match(drawn, /somewhere in it/u, `entry ${index + 1}`); + } +}); + +test('the selected command is shown in full: a long line wraps and is never cut off before its tail', () => { + const tail = 'echo done-marker-7f3a'; + const hidden = `echo start${' '.repeat(150)}; ${tail}`; + const review = createBatchReview(`ls\n${hidden}`, 'bash', 100)!; + review.cursor = 1; + for (const columns of [40, 80, 120]) { + const rows = renderBatchReview(review, columns, 30).map(stripAnsi); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${columns}: ${row}`); + assert.ok(rows.join('').replace(/\s+/gu, '').includes('done-marker-7f3a'.replace(/\s+/gu, '')), `${columns}: the tail of the command is visible`); + } + // When even wrapping does not fit, the review says that rows are hidden and that they are part of the command. + const long = createBatchReview(`ls\necho ${'a '.repeat(2000)}`, 'bash', 100)!; + long.cursor = 1; + assert.match(stripAnsi(renderBatchReview(long, 60, 14).join('\n')), /more rows? hidden; still part of the command/u); +}); + +test('a quote the splitter loses track of cannot hide a later command from the label', () => { + // The apostrophe in the heredoc body opens a "string" for a naive splitter that runs to the end of the command. + const review = createBatchReview(['echo start', "for x in a; do\n cat < { + const instance = app(); + try { + const content = "printf '%s\\n' '$(kept as data)'\necho next"; + Object.defineProperty(instance, 'readClipboardText', {value: async () => content}); + await instance['pasteClipboard']('/ps'); + assert.equal(instance['clips'].list().length, 0); + instance['handleKey'](text('c')); + const [clip] = instance['clips'].list(); + assert.equal(clip?.text, content); + assert.equal(instance['pasteReview']?.text, content); + assert.equal(instance['queueState'].entries.length, 0); + instance['pasteReview'] = undefined; + instance['pastePreview'] = undefined; + instance['openClips'](); + const item = instance['paletteState']!.items.find(item => item.id === `clip:${clip!.id}:review`)!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](item); + assert.equal(instance['pasteReview']?.text, content); + assert.equal(instance['editor'].text, ''); + instance['handleKey']({kind: 'enter'}); + assert.equal(instance['editor'].text, content); + assert.equal(instance['queueState'].entries.length, 0); + } finally { dispose(instance); } +}); + +test('composer clips preserve the draft, reject staging over text, and disappear with the frontend', async () => { + const instance = app(); + try { + instance['editor'].insert('echo draft'); + instance['openPalette'](); + const keep = instance['paletteState']!.items.find(item => item.id === 'clip:keep-composer')!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](keep); + assert.equal(instance['editor'].text, 'echo draft'); + instance['openClips'](); + const use = instance['paletteState']!.items.find(item => item.id.endsWith(':review'))!; + instance['paletteState'] = undefined; + await instance['runPaletteItem'](use); + assert.equal(instance['pasteReview'], undefined); + assert.equal(instance['editor'].text, 'echo draft'); + instance['stop'](0); + assert.deepEqual(instance['clips'].list(), []); + } finally { dispose(instance); } +}); + +test('clipboard-change suggestions are opt-in metadata only; /ps remains the explicit text read', async () => { + const instance = app(); + let textReads = 0, metadataReads = 0; + try { + Object.defineProperty(instance, 'clipboardChangeCounter', {value: async () => { metadataReads++; return 3; }}); + Object.defineProperty(instance, 'readClipboardText', {value: async () => { textReads++; return 'echo reviewed'; }}); + assert.equal(instance['clipboardChangeWatch'], undefined); + assert.deepEqual(parseSlashCommand('/clips suggestions on'), {kind: 'clips', suggestions: true}); + instance['configureClipboardChanges'](true); + await new Promise(done => setImmediate(done)); + assert.equal(textReads, 0); + assert.ok(metadataReads <= 1); + instance['clipboardChangeWatch']!['options'].changed(); + assert.equal(instance['clipboardChangeSuggested'], true); + assert.equal(instance['editor'].text, ''); + assert.equal(instance['queueState'].entries.length, 0); + await instance['pasteClipboard']('/ps'); + assert.equal(textReads, 1); + assert.equal(instance['clipboardChangeSuggested'], false); + assert.equal(instance['pasteReview']?.text, 'echo reviewed'); + instance['configureClipboardChanges'](false); + assert.equal(instance['clipboardChangeWatch']?.enabled, false); + } finally { dispose(instance); } +}); diff --git a/tests/pasteBatchLive.test.ts b/tests/pasteBatchLive.test.ts new file mode 100644 index 00000000..eea07af7 --- /dev/null +++ b/tests/pasteBatchLive.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * A pasted sequence through the real composer and PTY: reviewed first, nothing runs until Enter in the batch review, + * then the session's queue runs the commands in the same shell, in order, and pauses at a failure. Harmless + * commands only (echo, cd, false inside the sandbox). + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +const SEQUENCES = { + zsh: 'cd sub\necho "in-$(basename $PWD)"\nfor f in a b; do\n echo "loop-$f"\ndone\nfalse\necho "AFTER-FAILURE"', + bash: 'cd sub\necho "in-$(basename $PWD)"\nfor f in a b; do\n echo "loop-$f"\ndone\nfalse\necho "AFTER-FAILURE"', + fish: 'cd sub\necho "in-"(basename $PWD)\nfor f in a b\n echo "loop-$f"\nend\nfalse\necho "AFTER-FAILURE"', +} as const; + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: a pasted sequence is reviewed, queued on Enter, run in order in the same shell, and paused by a failure`, + {skip: !supported ? 'unsupported platform' : !available ? `${shell} is not installed` : false, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + mkdirSync(join(sandbox.home, 'sub')); + const frontend = sandbox.launch([], {cols: 100, rows: 34}, {}); + try { + await frontend.waitFor(/notMyShell|zsh|bash|fish/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + frontend.pty.write(`\u001b[200~${SEQUENCES[shell]}\u001b[201~`); + await frontend.waitFor(/B queue as commands/u, mark); + await pause(300); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'pasting runs nothing'); + frontend.pty.write('b'); + await frontend.waitFor(/Review commands[\s\S]*5 commands/u, mark); + await pause(300); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /in-sub|loop-a/u, 'reviewing runs nothing'); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/in-sub[\s\S]*loop-a[\s\S]*loop-b/u, mark, 30_000); + await frontend.waitFor(/Command failed · exit 1/u, mark, 30_000); + await pause(1200); + assert.doesNotMatch(strip(frontend.output.slice(mark)), /AFTER-FAILURE\n/u, 'the queue paused at the failing command'); + // The rest is still queued; the same shell kept its directory. + await frontend.run('pwd', /\/sub\b/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/pinStore.test.ts b/tests/pinStore.test.ts new file mode 100644 index 00000000..a76cac6b --- /dev/null +++ b/tests/pinStore.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {addPin, addRecipe, applies, changeItem, cleanName, inScope, keepProblem, loadPins, MAX_PINS, removeItem} from '../src/pins/PinStore.js'; +import {expandSteps, placeholdersIn, quotedPlaceholder, valueProblem} from '../src/pins/recipes.js'; +import {fishQuote, posixQuote} from '../src/shell/adapters/ShellAdapter.js'; + +function sandbox() { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-pins-')); + return {path: join(directory, 'nmsh', 'pins.json'), directory, cleanup: () => rmSync(directory, {recursive: true, force: true})}; +} + +test('nothing exists until something is saved; a save creates a private file', () => { + const {path, cleanup} = sandbox(); + try { + assert.deepEqual(loadPins(path), {ok: true, value: {version: 1, pins: [], recipes: []}}); + assert.equal(existsSync(path), false, 'reading never creates the file'); + const pin = addPin({command: 'git status', name: ' my status ', scope: 'directory', cwd: '/work/app'}, path, 5); + assert.ok(pin.ok); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.deepEqual(loadPins(path), {ok: true, value: {version: 1, pins: [{id: pin.ok ? pin.value.id : '', command: 'git status', name: 'my status', scope: 'directory', cwd: '/work/app', createdAt: 5}], recipes: []}}); + assert.deepEqual(readdirSync(join(path, '..')).sort(), ['pins.json'], 'no temporary or lock files are left'); + } finally { cleanup(); } +}); + +test('secrets are refused, not stored; oversized, empty and duplicate pins too', () => { + const {path, cleanup} = sandbox(); + try { + for (const command of ['curl -H "Authorization: Bearer abc.def.ghi" x', 'export TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', 'mysql --password=hunter2 db']) { + const result = addPin({command}, path); + assert.equal(result.ok, false, command); + assert.match(result.ok ? '' : result.reason, /secret/u); + } + assert.equal(existsSync(path), false, 'a refused save writes nothing'); + assert.match(keepProblem(' ') ?? '', /nothing to keep/u); + assert.match(keepProblem('x'.repeat(70_000)) ?? '', /too long/u); + assert.equal(keepProblem('cd ~/Projects && ls'), undefined, 'private paths are fine'); + assert.ok(addPin({command: 'ls'}, path).ok); + assert.match(addPin({command: 'ls'}, path).ok ? '' : (addPin({command: 'ls'}, path) as {reason: string}).reason, /already pinned/u); + } finally { cleanup(); } +}); + +test('a file this version cannot read is reported and never replaced', () => { + const {path, directory, cleanup} = sandbox(); + try { + assert.ok(addPin({command: 'ls'}, path).ok); + for (const content of ['{ not json', '{"version":2,"pins":[]}', '[]']) { + writeFileSync(path, content); + const loaded = loadPins(path); + assert.equal(loaded.ok, false); + const result = addPin({command: 'pwd'}, path); + assert.equal(result.ok, false, 'a save does not overwrite it'); + assert.equal(readFileSync(path, 'utf8'), content); + } + assert.equal(readdirSync(join(directory, 'nmsh')).filter(name => name !== 'pins.json').length, 0); + } finally { cleanup(); } +}); + +test('hostile or damaged entries are dropped on load; names are single clean lines', () => { + const {path, cleanup} = sandbox(); + try { + assert.ok(addPin({command: 'ls'}, path).ok); + writeFileSync(path, JSON.stringify({version: 1, pins: [ + {id: 'good', command: 'ls', name: 'a‮b\nc\u0007d', scope: 'directory', cwd: 'relative/path', createdAt: 1}, + {id: '../escape', command: 'ls'}, {id: 'nocommand'}, {id: 'big', command: 'x'.repeat(70_000)}, 'string', null, + ], recipes: [ + {id: 'r1', name: 'Deploy', steps: ['a', 'b']}, {id: 'r2', name: '', steps: ['a']}, {id: 'r3', name: 'Bad', steps: ['a', 7]}, {id: 'r4', name: 'Empty', steps: []}, + ]})); + const loaded = loadPins(path); + assert.ok(loaded.ok); + if (!loaded.ok) return; + assert.deepEqual(loaded.value.pins.map(pin => [pin.id, pin.name, pin.scope, pin.cwd]), [['good', 'a b c d', 'global', undefined]]); + assert.deepEqual(loaded.value.recipes.map(recipe => recipe.id), ['r1']); + assert.equal(cleanName('x'.repeat(500)).length, 80); + } finally { cleanup(); } +}); + +test('rename, scope and delete act on the item with that id only, and report a vanished item', () => { + const {path, cleanup} = sandbox(); + try { + const a = addPin({command: 'ls', cwd: '/w'}, path, 1); + const b = addPin({command: 'pwd'}, path, 2); + assert.ok(a.ok && b.ok); + if (!a.ok || !b.ok) return; + assert.ok(changeItem(a.value.id, {name: 'list'}, path).ok); + assert.ok(changeItem(a.value.id, {scope: 'directory'}, path).ok); + assert.match((changeItem(b.value.id, {scope: 'directory'}, path) as {reason: string}).reason, /not pinned from a directory/u); + assert.ok(removeItem(b.value.id, path).ok); + assert.match((removeItem(b.value.id, path) as {reason: string}).reason, /no longer exists/u); + const loaded = loadPins(path); + assert.deepEqual(loaded.ok && loaded.value.pins.map(pin => [pin.name, pin.scope]), [['list', 'directory']]); + } finally { cleanup(); } +}); + +test('limits: pins, recipes and steps are bounded', () => { + const {path, cleanup} = sandbox(); + try { + for (let index = 0; index < MAX_PINS; index += 1) assert.ok(addPin({command: `echo ${index}`}, path).ok || index > 0); + const loaded = loadPins(path); + assert.ok(loaded.ok && loaded.value.pins.length === MAX_PINS); + assert.match((addPin({command: 'one more'}, path) as {reason: string}).reason, /at most 500/u); + assert.match((addRecipe({name: 'big', steps: Array.from({length: 101}, () => 'ls')}, path) as {reason: string}).reason, /at most 100/u); + } finally { cleanup(); } +}); + +test('recipes: named once (any case), secrets refused, steps kept in order', () => { + const {path, cleanup} = sandbox(); + try { + const recipe = addRecipe({name: ' Deploy app ', description: 'push it', steps: ['git pull', 'npm run build', 'scp -r dist {{host}}:/srv'], scope: 'directory', cwd: '/w/app'}, path, 9); + assert.ok(recipe.ok); + assert.equal(recipe.ok && recipe.value.name, 'Deploy app'); + assert.match((addRecipe({name: 'deploy APP', steps: ['ls']}, path) as {reason: string}).reason, /already exists/u); + assert.match((addRecipe({name: 'bad', steps: ['ls', 'export API_KEY=sk-abcdefghijklmnopqrstuvwxyz']}, path) as {reason: string}).reason, /secret/u); + assert.match((addRecipe({name: '', steps: ['ls']}, path) as {reason: string}).reason, /needs a name/u); + assert.match((addRecipe({name: 'none', steps: []}, path) as {reason: string}).reason, /at least one/u); + assert.ok(changeItem(recipe.ok ? recipe.value.id : '', {steps: ['one', 'two']}, path).ok); + const loaded = loadPins(path); + assert.deepEqual(loaded.ok && loaded.value.recipes[0]!.steps, ['one', 'two']); + } finally { cleanup(); } +}); + +test('scope: directory items apply in their directory and below; global ones everywhere; "all" shows everything', () => { + const items = [ + {id: 'g', scope: 'global' as const, createdAt: 1}, + {id: 'd', scope: 'directory' as const, cwd: '/w/app', createdAt: 2}, + {id: 'o', scope: 'directory' as const, cwd: '/other', createdAt: 3}, + ]; + assert.deepEqual(inScope(items, '/w/app').map(item => item.id), ['d', 'g']); + assert.deepEqual(inScope(items, '/w/app/src').map(item => item.id), ['d', 'g']); + assert.deepEqual(inScope(items, '/w/application').map(item => item.id), ['g'], 'a sibling with the same prefix is not inside'); + assert.deepEqual(inScope(items, '/elsewhere').map(item => item.id), ['g']); + assert.deepEqual(inScope(items, '/elsewhere', true).map(item => item.id), ['o', 'd', 'g']); + assert.equal(applies(items[1]!, '/w/app'), true); +}); + +test('two NMSh processes saving at once lose nothing', () => { + const {path, cleanup} = sandbox(); + try { + const script = ` + import {addPin} from ${JSON.stringify(join(process.cwd(), 'src/pins/PinStore.ts'))}; + for (let index = 0; index < 15; index += 1) addPin({command: process.argv[2] + ' ' + index}, process.argv[3]); + `; + const scriptPath = join(path, '..', '..', 'writer.mts'); + writeFileSync(scriptPath, script); + const children = ['alpha', 'beta', 'gamma'].map(name => spawnSync(process.execPath, ['--import=tsx', scriptPath, name, path], {encoding: 'utf8', timeout: 60_000})); + for (const child of children) assert.equal(child.status, 0, child.stderr); + const loaded = loadPins(path); + assert.ok(loaded.ok); + assert.equal(loaded.ok && loaded.value.pins.length, 45); + assert.equal(loaded.ok && new Set(loaded.value.pins.map(pin => pin.id)).size, 45); + } finally { cleanup(); } +}); + +test('placeholders: found in order, quoted as one word for the shell, never inside quotes', () => { + assert.deepEqual(placeholdersIn(['scp {{file}} {{host}}:/srv', 'ssh {{host}} ls', 'echo {{bad name}} {{x_1}}']), ['file', 'host', 'x_1']); + assert.equal(quotedPlaceholder('echo "{{name}}"'), 'name'); + assert.equal(quotedPlaceholder("echo '{{name}}'"), 'name'); + assert.equal(quotedPlaceholder('echo {{name}} "done"'), undefined); + assert.equal(quotedPlaceholder('echo \\"{{name}}'), undefined, 'an escaped quote opens nothing'); + const steps = ['scp {{file}} {{host}}:/srv']; + assert.deepEqual(expandSteps(steps, {file: "it's a file.txt", host: 'box'}), {ok: true, steps: ["scp 'it'\\''s a file.txt' 'box':/srv"]}); + assert.deepEqual(expandSteps(['rm {{f}}'], {f: 'a; reboot $(id)'}, fishQuote), {ok: true, steps: ["rm 'a; reboot $(id)'"]}); + assert.deepEqual(expandSteps(['echo {{a}} {{a}}'], {a: 'x y'}, posixQuote), {ok: true, steps: ["echo 'x y' 'x y'"]}); + assert.match((expandSteps(steps, {file: 'x'}) as {reason: string}).reason, /\{\{host\}\}/u); + assert.match((expandSteps(['rm {{f}}'], {f: '-rf'}) as {reason: string}).reason, /\{\{f\}\}/u, 'an option-looking value is refused'); +}); + +test('values: no options, controls, bidi or oversize; a value is never expanded by the shell', () => { + for (const bad of ['', '-rf', '--help', 'a\nb', 'a\u0007b', 'a‮b', 'x'.repeat(501)]) assert.ok(valueProblem(bad), JSON.stringify(bad)); + for (const good of ['./-rf', 'plain', '$(id)', '`id`', '~/x', 'a b', "it's", 'ü😀']) assert.equal(valueProblem(good), undefined, good); + for (const hostile of ['$(touch /tmp/pwn)', '`id`', '$HOME', '*', 'a;b', "x' ; echo y '", '${IFS}']) { + const result = expandSteps(['printf %s {{v}}'], {v: hostile}); + assert.ok(result.ok); + if (!result.ok) return; + const run = spawnSync('sh', ['-c', result.steps[0]!], {encoding: 'utf8', timeout: 5000, cwd: tmpdir()}); + assert.equal(run.stdout, hostile, `the shell printed the value, unexpanded: ${hostile}`); + } +}); diff --git a/tests/pinsLive.test.ts b/tests/pinsLive.test.ts new file mode 100644 index 00000000..9ed843b2 --- /dev/null +++ b/tests/pinsLive.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * Pins through the real composer and PTY: pin a finished command from the palette, find it in /pins, and stage it. + * Staging puts the command in the composer; it is not run until Enter. The pins file is the sandbox's own. + */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +test('live: Pin command keeps a finished command; /pins stages it for review and never runs it', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {}); + try { + await frontend.waitFor(/notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + const flag = join(sandbox.home, 'pinned-flag'); + await frontend.run(`touch ${flag}`, /Completed/u); + assert.ok(existsSync(flag)); + let mark = frontend.mark; + frontend.pty.write('\u001bOP'); + await frontend.waitFor(/Command palette/u, mark); + frontend.pty.write('pin comm'); + await frontend.waitFor(/Pin command[\s\S]*Keep this command to reuse later/u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/Pinned\. \/pins lists what you kept/u, mark); + const file = join(sandbox.config, 'nmsh', 'pins.json'); + await until(() => existsSync(file), 5000, 'pins.json written'); + assert.match(readFileSync(file, 'utf8'), new RegExp(`"command": "touch ${flag}"`, 'u')); + assert.doesNotMatch(readFileSync(file, 'utf8'), /READY/u, 'only the pinned command is kept, never output'); + await frontend.run(`rm ${flag}`, /Completed/u); + assert.equal(existsSync(flag), false); + mark = frontend.mark; + frontend.pty.write('/pins\r'); + await frontend.waitFor(/Pins and recipes[\s\S]*touch /u, mark); + frontend.pty.write('\r'); + await frontend.waitFor(/Staged in the composer for you to review/u, mark); + await pause(800); + // Staged, not run: the command sits in the composer, and nothing created the file. + assert.equal(existsSync(flag), false, 'staging did not run the command'); + frontend.pty.write('\r'); + await until(() => existsSync(flag), 15_000, 'Enter runs the staged command'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/pinsPanel.test.ts b/tests/pinsPanel.test.ts new file mode 100644 index 00000000..d9d04f5c --- /dev/null +++ b/tests/pinsPanel.test.ts @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {addPin, addRecipe, loadPins, pinsPath} from '../src/pins/PinStore.js'; +import {createPinsPanel, hiddenCount, panelItems, pinsPanelKey, renderPinsPanel} from '../src/pins/PinsPanel.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {blockPaletteItems} from '../src/ui/BlockActions.js'; +import {queuePanelKey} from '../src/queue/QueuePanel.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +// Pins are written under XDG_CONFIG_HOME: keep every test out of the real configuration. +const configHome = mkdtempSync(join(tmpdir(), 'nmsh-pins-ui-')); +process.env.XDG_CONFIG_HOME = configHome; +test.after(() => rmSync(configHome, {recursive: true, force: true})); +const reset = () => rmSync(join(configHome, 'nmsh'), {recursive: true, force: true}); + +const text = (value: string): Key => ({kind: 'text', value}); +const typing = (panel: ReturnType, value: string) => { for (const char of value) pinsPanelKey(panel, text(char)); }; +const data = () => { const loaded = loadPins(); assert.ok(loaded.ok); return loaded.ok ? loaded.value : {version: 1 as const, pins: [], recipes: []}; }; + +function app() { + const app = new TerminalApp(); + Object.defineProperty(app, 'render', {value: () => {}}); + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + // Assert on accepted entries without racing or executing the first recipe step. + app['session'].queue({op: 'pause'}); + return app; +} +function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } +const until = async (condition: () => boolean, ms = 8000) => { const end = Date.now() + ms; while (!condition() && Date.now() < end) await new Promise(done => setTimeout(done, 10)); }; + +test('/pins is the one entry point; the block menu offers Pin command', () => { + assert.deepEqual(parseSlashCommand('/pins'), {kind: 'pins'}); + const output = new OutputBuffer(); + output.beginCommand('make test', ['make test']); + output.write('ok\n'); + output.complete(0); + assert.ok(blockPaletteItems(output.recent(1)!).some(item => item.action.kind === 'block' && item.action.id === 'pin')); +}); + +test('the panel: select, show other folders, rename, remove only after y, and nothing is stored by it', () => { + reset(); + addPin({command: 'git status', cwd: '/w/app', scope: 'directory', name: 'st'}, undefined, 1); + addPin({command: 'ls -la'}, undefined, 2); + addPin({command: 'make', cwd: '/other', scope: 'directory'}, undefined, 3); + const panel = createPinsPanel(data(), '/w/app'); + assert.deepEqual(panelItems(panel).map(item => item.kind === 'pin' && item.pin.command), ['git status', 'ls -la']); + assert.equal(hiddenCount(panel), 1); + pinsPanelKey(panel, text('a')); + assert.equal(panelItems(panel).length, 3); + pinsPanelKey(panel, text('a')); + assert.equal(pinsPanelKey(panel, text('x')), undefined, 'x only asks'); + assert.equal(pinsPanelKey(panel, text('n')), undefined, 'n is not y'); + assert.equal(panel.mode, 'list'); + pinsPanelKey(panel, text('x')); + assert.deepEqual(pinsPanelKey(panel, text('y')), {kind: 'delete', id: (panelItems(panel)[0] as {pin: {id: string}}).pin.id}); + pinsPanelKey(panel, text('r')); + assert.equal(panel.input, 'st'); + typing(panel, '!!'); + assert.deepEqual(pinsPanelKey(panel, {kind: 'enter'}), {kind: 'rename', id: (panelItems(panel)[0] as {pin: {id: string}}).pin.id, name: 'st!!'}); + assert.equal(data().pins.length, 3, 'the panel itself changed nothing'); + assert.equal(pinsPanelKey(panel, {kind: 'escape'})?.kind, 'close'); +}); + +test('Enter on a pin stages its command; on a recipe it asks for each value, validated, then returns them', () => { + reset(); + addPin({command: 'echo staged'}, undefined, 1); + const recipe = addRecipe({name: 'Copy', steps: ['cp {{src}} {{dest}}', 'ls {{dest}}']}, undefined, 2); + assert.ok(recipe.ok); + const panel = createPinsPanel(data(), '/w'); + assert.equal(panelItems(panel)[0]!.kind, 'recipe'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + assert.equal(panel.mode, 'fill'); + typing(panel, '-rf'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + assert.match(panel.message?.text ?? '', /start with "-"/u, 'an option-looking value is refused'); + panel.input = ''; + typing(panel, 'a file.txt'); + assert.equal(pinsPanelKey(panel, {kind: 'enter'}), undefined); + typing(panel, './out'); + const action = pinsPanelKey(panel, {kind: 'enter'}); + assert.deepEqual(action && action.kind === 'use' && action.values, {src: 'a file.txt', dest: './out'}); + assert.equal(panel.mode, 'list'); + pinsPanelKey(panel, {kind: 'down'}); + assert.deepEqual(pinsPanelKey(panel, {kind: 'enter'}), {kind: 'stage', command: 'echo staged'}); + // Escape leaves a half-answered recipe without using it. + pinsPanelKey(panel, {kind: 'up'}); + pinsPanelKey(panel, {kind: 'enter'}); + typing(panel, 'x'); + pinsPanelKey(panel, {kind: 'escape'}); + assert.equal(panel.mode, 'list'); + assert.equal(panel.fill, undefined); +}); + +test('the queue panel offers "save as recipe" only when something is queued', () => { + const empty = {entries: []}; + assert.equal(queuePanelKey({selected: 0}, text('s'), empty), undefined); + assert.deepEqual(queuePanelKey({selected: 0}, text('s'), {entries: [{id: 1, text: 'ls', addedAt: 1}]}), {kind: 'saveRecipe'}); +}); + +test('the panel fits narrow windows and draws hostile text safely', () => { + reset(); + addPin({command: 'echo \u001b[2J‮ hidden', name: 'x'.repeat(60)}, undefined, 1); + addRecipe({name: 'R'.repeat(50), steps: ['echo {{a}}', 'echo \u001b[31mred']}, undefined, 2); + const panel = createPinsPanel(data(), '/w', {composerText: 'ls', canEdit: true}); + for (const columns of [30, 50, 100]) { + for (const selected of [0, 1]) { + panel.selected = selected; + const rows = renderPinsPanel(panel, columns, 22); + assert.ok(rows.length <= 22); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + assert.ok(!rows.join('').includes('\u001b[2J') && !rows.join('').includes('‮')); + } + } +}); + +test('from a block: Pin command keeps the command, refuses secrets, and runs nothing', async () => { + reset(); + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + const output = instance['output']; + output.beginCommand('make test', ['make test']); output.write('ok\n'); output.complete(0); + await instance['runBlockAction'](output.recent(1)!.startId, 'pin'); + assert.deepEqual(data().pins.map(pin => [pin.command, pin.scope]), [['make test', 'global']]); + output.beginCommand('export TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789', ['x']); output.write(''); output.complete(0); + await instance['runBlockAction'](output.recent(1)!.startId, 'pin'); + assert.equal(data().pins.length, 1, 'a secret was not stored'); + assert.equal(readFileSync(pinsPath(), 'utf8').includes('ghp_'), false); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('history selection: palette pins the selected record with its own cwd, never the search text', async () => { + reset(); + const a = app(); + try { + a['editor'].insert('/history selected'); + a['historyQuery'] = 'selected'; + a['historyResults'] = [{id: 'history-1', source: 'nmsh', command: 'echo selected', cwd: '/original/project'}]; + a['openPalette'](); + const item = a['paletteState']!.items.find(item => item.id === 'history:pin:history-1'); + assert.ok(item); + await a['runPaletteItem'](item); + assert.equal(data().pins[0]?.command, 'echo selected'); + assert.equal(data().pins[0]?.cwd, '/original/project'); + assert.equal(a['editor'].text, '/history selected'); + assert.equal(a['queueState'].entries.length, 0); + // A deleted/refreshed history entry must not be confused with another selection. + a['historyResults'] = []; + reset(); + await a['runPaletteItem'](item); + assert.equal(data().pins.length, 0); + } finally { dispose(a); } +}); + +test('staging a pin puts it in an empty composer for review and never over typed text', async () => { + reset(); + addPin({command: 'git log --oneline | head'}, undefined, 1); + const instance = app(); + try { + const submitted: string[] = []; + Object.defineProperty(instance, 'submit', {value: async () => { submitted.push('x'); }}); + await instance['runSlash']('/pins', parseSlashCommand('/pins')!); + assert.ok(instance['pinsPanel']); + instance['editor'].insert('ls'); + await instance['handleKey']({kind: 'enter'}); + await until(() => instance['pinsPanel']?.message !== undefined); + assert.equal(instance['editor'].text, 'ls'); + assert.match(instance['pinsPanel']!.message!.text, /composer has text/u); + instance['editor'].clear(); + await instance['handleKey']({kind: 'enter'}); + await until(() => !instance['pinsPanel']); + assert.equal(instance['editor'].text, 'git log --oneline | head'); + assert.deepEqual(submitted, []); + } finally { dispose(instance); } +}); + +test('a recipe saved from the queue: values are quoted, the expanded commands are reviewed, and Enter there queues them', async () => { + reset(); + const instance = app(); + try { + instance['openPins']({naming: ['mkdir -p {{dir}}', 'cd {{dir}} && git init', 'echo done']}); + for (const char of 'Start repo') await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + await until(() => data().recipes.length === 1); + assert.equal(data().recipes[0]!.name, 'Start repo'); + // Use it: the value is asked for, quoted as one word, and the commands wait for confirmation. + await instance['handleKey']({kind: 'enter'}); + for (const char of "my project; touch x") await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + await until(() => Boolean(instance['batchReview'])); + const review = instance['batchReview']!; + assert.match(review.title ?? '', /Recipe: Start repo/u); + assert.deepEqual(review.entries.map(entry => entry.text), ["mkdir -p 'my project; touch x'", "cd 'my project; touch x' && git init", 'echo done']); + assert.deepEqual(instance['queueState'].entries.map(entry => entry.text), [], 'reviewing queues nothing'); + await instance['handleKey']({kind: 'enter'}); + assert.deepEqual(instance['queueState'].entries.map(entry => entry.text), ["mkdir -p 'my project; touch x'", "cd 'my project; touch x' && git init", 'echo done']); + // Editing or deleting the recipe afterwards cannot change what is already queued. + await instance['openPins'](); + await instance['handleKey'](text('x')); + await instance['handleKey'](text('y')); + await until(() => data().recipes.length === 0); + assert.equal(instance['queueState'].entries.length, 3); + } finally { dispose(instance); } +}); + +test('a recipe with a placeholder inside quotes is not saved (it would be quoted twice)', async () => { + reset(); + const instance = app(); + try { + instance['openPins']({naming: ['echo "{{name}}"']}); + for (const char of 'Quoted') await instance['handleKey'](text(char)); + await instance['handleKey']({kind: 'enter'}); + assert.equal(data().recipes.length, 0); + assert.match(instance['pinsPanel']!.message!.text, /inside quotes/u); + assert.equal(instance['pinsPanel']!.mode, 'name', 'still asking, with the name kept'); + } finally { dispose(instance); } +}); + +test('the remove prompt and every label draw hostile pinned text safely', () => { + reset(); + addPin({command: 'echo \u001b[2J\u001b]0;pwned\u0007 ‮ hidden'}, undefined, 1); + const panel = createPinsPanel(data(), '/w'); + panel.mode = 'delete'; + const drawn = renderPinsPanel(panel, 100, 22).join(''); + assert.ok(!drawn.includes('\u001b[2J') && !drawn.includes('\u001b]0;') && !drawn.includes('\u0007') && !drawn.includes('‮')); +}); diff --git a/tests/queueConditionOwnershipLive.test.ts b/tests/queueConditionOwnershipLive.test.ts new file mode 100644 index 00000000..8ef22b39 --- /dev/null +++ b/tests/queueConditionOwnershipLive.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; +import {inForeground, uniqueSleep} from './helpers/processState.js'; + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = ['darwin', 'linux'].includes(process.platform); + +for (const [index, shell] of (['zsh', 'bash', 'fish'] as const).entries()) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: queue conditions survive failure, interrupt and reattach without transferring approval`, { + skip: !supported ? 'unsupported platform' : !available ? `${shell} not installed` : false, timeout: 120_000, + }, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell, liveActivity: {style: 'classic'}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + try { + const first = sandbox.launch(); + await first.waitFor(/notMyShell|zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'authenticated initial prompt'); + mkdirSync(join(sandbox.home, 'scope')); + await first.run('cd scope', /Completed/u); + const marker = join(sandbox.home, 'conditions-ran'); + const follow = (word: string) => `printf '%s\\n' ${word} >> "${marker}"`; + const afterFailure = follow('after-failure'); + const afterInterrupt = follow('after-interrupt'); + const approved = follow('approved'); + const contents = () => existsSync(marker) ? readFileSync(marker, 'utf8') : ''; + const hold = (name: string, code: number) => { + const release = join(sandbox.home, name); + const command = `sh -c 'printf "CONDITION-%s\\n" HOLD; until test -f "$1"; do sleep 0.05; done; exit ${code}' nmsh-fixture "${release}"`; + return {release, command}; + }; + + const failed = hold('release-failure', 3); + let mark = first.mark; + first.pty.write(`${failed.command}\r`); + await first.waitFor(/CONDITION-HOLD/u, mark); + first.pty.write(`${afterFailure}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('\u001b[27u'); + assert.equal(contents(), '', 'setting the condition does not dispatch while the original owns the shell'); + writeFileSync(failed.release, ''); + await until(() => contents() === 'after-failure\n', 20_000, 'one follow-up after exit 3'); + await first.waitFor(/from queue/u, mark); + + const duration = uniqueSleep(91 + index); + mark = first.mark; + first.pty.write(`sleep ${duration}\r`); + await until(() => inForeground(duration), 20_000, 'sleep owns input before interrupt'); + first.pty.write(`${afterInterrupt}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('\u001b[27u'); + await first.waitFor(/Enter queues the next command/u, mark); + first.pty.write('\u0003'); + await first.waitFor(/Queue paused:[\s\S]*was interrupted/u, mark); + await first.run('echo AFTER-INTERRUPT', /AFTER-INTERRUPT/u); + assert.equal(contents(), 'after-failure\n', 'always does not bypass interruption'); + await first.run('/queue clear', /Cleared 1 queued command/u); + + const gated = hold('release-approval', 0); + mark = first.mark; + first.pty.write(`${gated.command}\r`); + await first.waitFor(/CONDITION-HOLD/u, mark); + first.pty.write(`${approved}\r`); + await first.waitFor(/Queued \(1\)/u, mark); + first.pty.write('/queue\r'); + await first.waitFor(/Command queue/u, mark); + first.pty.write('w'); + await first.waitFor(/runs even after a failure/u, mark); + first.pty.write('w'); + await first.waitFor(/asks first/u, mark); + first.pty.write('\u001b[27u'); + writeFileSync(gated.release, ''); + await first.waitFor(/next needs your approval/u, mark); + await until(async () => (await sandbox.transcripts().list()).some(item => + item.transcript.records.some(record => record.command === gated.command && record.exitCode === 0)), + 15_000, 'the holding command completes before detaching'); + const [session] = await sandbox.sessions(); + assert.ok(session); + first.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions()).find(item => item.id === session.id)?.state === 'detached', + 15_000, 'approval remains pending while detached'); + assert.equal(contents(), 'after-failure\n'); + + const back = sandbox.launch(['--attach', session.id], {cols: 100, rows: 30}, {NO_COLOR: '1', NMSH_ICONS: 'safe'}); + await back.waitFor(/next needs your approval/u); + back.pty.resize(50, 24); + await back.run('/queue', /Command queue[\s\S]*asks first/u); + assert.equal(contents(), 'after-failure\n', 'reattach and inspection do not approve'); + mark = back.mark; + back.pty.write('a'); + await until(() => contents() === 'after-failure\napproved\n', 20_000, 'only the explicitly approved entry runs'); + back.pty.write('\u001b[27u'); + await back.run('echo CONDITIONS-DONE', /CONDITIONS-DONE/u); + assert.equal(contents(), 'after-failure\napproved\n', 'no duplicate or inherited approval'); + assert.doesNotMatch(back.output.slice(mark), /\u001b\[38;[25];/u, 'NO_COLOR stays plain'); + + const records = (await sandbox.transcripts().list()).flatMap(item => item.transcript.records); + for (const command of [afterFailure, approved]) { + const matches = records.filter(record => record.command === command); + assert.equal(matches.length, 1, 'each reviewed command recorded once'); + assert.equal(matches[0]!.historicalContext?.cwd, join(sandbox.home, 'scope'), 'the queue keeps the real shell directory'); + assert.match(matches[0]!.lifecycleText, /from queue/u); + } + assert.equal(records.some(record => record.command === afterInterrupt), false, 'interrupted follow-up never became a command'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/queueConditions.test.ts b/tests/queueConditions.test.ts new file mode 100644 index 00000000..1254f5f5 --- /dev/null +++ b/tests/queueConditions.test.ts @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandQueue, type QueueState} from '../src/session/CommandQueue.js'; +import {QueueDispatcher, type QueueShell} from '../src/session/QueueDispatcher.js'; +import {queueOpFrom, queueStateFrom} from '../src/session/SessionProtocol.js'; +import {conditionNote, nextCondition, queuePanelKey, renderQueuePanel} from '../src/queue/QueuePanel.js'; +import {queueRow} from '../src/status/queueStatus.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +function harness() { + const target: QueueShell & {submitted: string[]} = {adapter: {id: 'zsh'}, submitted: [], isReady: true, submit(command: string) { this.submitted.push(command); }}; + const dispatcher = new QueueDispatcher(target); + const finish = (exitCode = 0) => { dispatcher.onExec(target.submitted.at(-1) ?? 'manual'); dispatcher.onPrompt(exitCode); }; + return {target, dispatcher, finish}; +} +const ids = (state: QueueState) => state.entries.map(entry => entry.id); + +test('the default is unchanged: a failure pauses before the next entry, which waits for resume', () => { + const {target, dispatcher, finish} = harness(); + dispatcher.onExec('make'); + dispatcher.request({op: 'add', text: 'echo after'}); + dispatcher.onPrompt(2); + assert.deepEqual(target.submitted, []); + assert.equal(dispatcher.state.paused?.reason, 'failed'); + dispatcher.request({op: 'resume'}); + assert.deepEqual(target.submitted, ['echo after']); + finish(); +}); + +test('"always" runs after a failure, but never after an interrupt', () => { + const failed = harness(); + failed.dispatcher.onExec('make'); + failed.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + failed.dispatcher.onPrompt(2); + assert.deepEqual(failed.target.submitted, ['echo cleanup']); + assert.equal(failed.dispatcher.state.paused, undefined); + + const stopped = harness(); + stopped.dispatcher.onExec('sleep 99'); + stopped.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + stopped.dispatcher.onInterrupt(); + stopped.dispatcher.onPrompt(130); + assert.deepEqual(stopped.target.submitted, [], 'Ctrl+C stops the queue whatever follows'); + assert.equal(stopped.dispatcher.state.paused?.reason, 'interrupted'); + + // An "always" entry further down does not skip the ones before it. + const behind = harness(); + behind.dispatcher.onExec('make'); + behind.dispatcher.request({op: 'add', text: 'echo plain'}); + behind.dispatcher.request({op: 'add', text: 'echo cleanup', condition: 'always'}); + behind.dispatcher.onPrompt(2); + assert.deepEqual(behind.target.submitted, []); + assert.equal(behind.dispatcher.state.paused?.reason, 'failed'); +}); + +test('an entry that asks first never runs until that exact entry is approved, and is approved once', () => { + const {target, dispatcher, finish} = harness(); + dispatcher.onPrompt(0); + dispatcher.request({op: 'add', text: 'echo one'}); + finish(); + dispatcher.request({op: 'add', text: 'echo risky', condition: 'approve'}); + dispatcher.request({op: 'add', text: 'echo last'}); + assert.deepEqual(target.submitted, ['echo one'], 'waits at the entry that asks first, and nothing behind it jumps ahead'); + const [asking] = dispatcher.state.entries; + assert.equal(dispatcher.queue.awaitingApproval?.id, asking!.id); + // Not the wrong entry, not an entry without the condition. + dispatcher.request({op: 'approve', id: dispatcher.state.entries[1]!.id}); + assert.deepEqual(target.submitted, ['echo one']); + dispatcher.request({op: 'approve', id: asking!.id}); + assert.deepEqual(target.submitted, ['echo one', 'echo risky']); + finish(); + assert.deepEqual(target.submitted, ['echo one', 'echo risky', 'echo last'], 'approval does not carry over to the next entry'); +}); + +test('editing an approved entry, or changing its condition, withdraws the approval', () => { + const queue = new CommandQueue(); + queue.apply({op: 'add', text: 'rm -r build', condition: 'approve'}); + const id = queue.state.entries[0]!.id; + assert.equal(queue.apply({op: 'approve', id}), undefined); + assert.equal(queue.take()?.text, 'rm -r build'); + + queue.apply({op: 'add', text: 'rm -r build', condition: 'approve'}); + const second = queue.state.entries[0]!.id; + queue.apply({op: 'approve', id: second}); + queue.apply({op: 'edit', id: second, text: 'rm -rf /'}); + assert.equal(queue.take(), undefined, 'the approval was for the text that was approved'); + queue.apply({op: 'approve', id: second}); + queue.apply({op: 'edit', id: second, text: 'rm -rf /'}); + assert.equal(queue.state.entries[0]!.approved, true, 'saving the same text changes nothing'); + queue.apply({op: 'condition', id: second}); + queue.apply({op: 'condition', id: second, condition: 'approve'}); + assert.equal(queue.take(), undefined, 'a condition change starts again'); + assert.match(queue.apply({op: 'approve', id: 999}) ?? '', /already ran/u); + queue.apply({op: 'condition', id: second}); + assert.match(queue.apply({op: 'approve', id: second}) ?? '', /does not ask/u); +}); + +test('the protocol carries conditions and approval, and drops what it cannot understand', () => { + assert.deepEqual(queueOpFrom('{"op":"add","text":"ls","condition":"approve"}'), {op: 'add', text: 'ls', condition: 'approve'}); + assert.deepEqual(queueOpFrom('{"op":"add","text":"ls"}'), {op: 'add', text: 'ls'}); + assert.equal(queueOpFrom('{"op":"add","text":"ls","condition":"yolo"}'), undefined); + assert.deepEqual(queueOpFrom('{"op":"condition","id":3,"condition":"always"}'), {op: 'condition', id: 3, condition: 'always'}); + assert.deepEqual(queueOpFrom('{"op":"condition","id":3}'), {op: 'condition', id: 3}); + assert.equal(queueOpFrom('{"op":"condition","id":3,"condition":7}'), undefined); + assert.deepEqual(queueOpFrom('{"op":"approve","id":3}'), {op: 'approve', id: 3}); + assert.equal(queueOpFrom('{"op":"approve"}'), undefined); + const state = queueStateFrom(JSON.stringify({entries: [{id: 1, text: 'a', addedAt: 1, condition: 'approve', approved: true}, {id: 2, text: 'b', addedAt: 2, condition: 'always', approved: true}, {id: 3, text: 'c', addedAt: 3, condition: 'bogus', approved: true}]}))!; + assert.deepEqual(state.entries.map(entry => [entry.condition, entry.approved]), [['approve', true], ['always', undefined], [undefined, undefined]]); +}); + +test('the panel offers "when it runs" and "approve" only where the service supports them', () => { + const state: QueueState = {entries: [{id: 1, text: 'one', addedAt: 1}, {id: 2, text: 'two', addedAt: 2, condition: 'approve'}]}; + const panel = {selected: 0}; + assert.equal(queuePanelKey(panel, {kind: 'text', value: 'w'}, state, false), undefined, 'an older service is never sent a condition'); + assert.deepEqual(queuePanelKey(panel, {kind: 'text', value: 'w'}, state, true), {kind: 'change', change: {op: 'condition', id: 1, condition: 'always'}}); + assert.equal(nextCondition('always'), 'approve'); + assert.equal(nextCondition('approve'), undefined); + assert.equal(queuePanelKey(panel, {kind: 'text', value: 'a'}, state, true), undefined, 'only an entry that asks first can be approved'); + panel.selected = 1; + assert.deepEqual(queuePanelKey(panel, {kind: 'text', value: 'a'}, state, true), {kind: 'change', change: {op: 'approve', id: 2}}); + assert.equal(conditionNote(state.entries[1]!), 'asks first'); + for (const columns of [30, 60, 100]) { + const rows = renderQueuePanel(panel, state, undefined, columns, 24, true); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`); + } + assert.match(stripAnsi(renderQueuePanel(panel, state, undefined, 100, 24, true).join('\n')), /asks first/u); +}); + +test('the status line says the queue waits for the person', () => { + const state: QueueState = {entries: [{id: 2, text: 'rm -r build', addedAt: 2, condition: 'approve'}]}; + assert.match(stripAnsi(queueRow(state, 100)!), /next needs your approval: rm -r build · \/queue/u); + for (const columns of [20, 40, 60]) assert.ok(displayWidth(stripAnsi(queueRow(state, columns)!)) <= columns); + assert.doesNotMatch(stripAnsi(queueRow({entries: [{...state.entries[0]!, approved: true}]}, 100)!), /approval/u); + assert.deepEqual(ids(state), [2]); +}); diff --git a/tests/queueConditionsLive.test.ts b/tests/queueConditionsLive.test.ts new file mode 100644 index 00000000..f02f821c --- /dev/null +++ b/tests/queueConditionsLive.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * Queue conditions through the real composer, PTY and session service, with harmless commands only (touch inside the + * sandbox). "Asks first" holds an entry until that entry is approved in /queue; "runs even after a failure" lets one + * entry go on after a plain failure, where the default pauses the queue. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +type Shell = 'zsh' | 'bash' | 'fish'; + +async function start(shell: Shell) { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {}); + try { + await frontend.waitFor(/notMyShell|zsh|bash|fish/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), + 20_000, 'the shell authenticated prompt'); + await frontend.run('echo READY', /READY/u); + } catch (error) { + try { await sandbox.dispose(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'startup and cleanup failed'); } + throw error; + } + return {sandbox, frontend}; +} + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? Boolean(bash) : Boolean(fish)); + test(`live ${shell}: an entry that asks first waits for its approval; approving runs exactly that entry`, + {skip: !supported ? 'unsupported platform' : !available ? `${shell} is not installed` : false, timeout: 120_000}, async () => { + const {sandbox, frontend} = await start(shell); + try { + const marker = join(sandbox.home, 'approved'); + let mark = frontend.mark; + frontend.pty.write('sleep 2\r'); + await frontend.waitFor(/Running sleep 2|Cooking|[A-Z][a-z]+…/u, mark); + frontend.pty.write(`touch ${marker}\r`); + await frontend.waitFor(/≡ Queued \(1\) · next: touch/u, mark); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue[\s\S]*touch/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('\u001b'); + await frontend.waitFor(/next needs your approval: touch/u, mark, 20_000); + await pause(1500); + assert.equal(existsSync(marker), false, 'nothing runs while it waits for approval'); + mark = frontend.mark; + frontend.pty.write('/queue\r'); + await frontend.waitFor(/asks first/u, mark); + frontend.pty.write('a'); + await until(() => existsSync(marker), 15_000, 'the approved entry runs'); + await frontend.waitFor(/from queue/u, mark); + } finally { await sandbox.dispose(); } + }); +} + +test('live: "runs even after a failure" goes on after a plain failure; the default pauses', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const {sandbox, frontend} = await start('zsh'); + try { + const cleanup = join(sandbox.home, 'cleanup'); + const mark = frontend.mark; + frontend.pty.write(`sh -c 'sleep 1; exit 3'\r`); + await frontend.waitFor(/Running sh -c|[A-Z][a-z]+…/u, mark); + frontend.pty.write(`touch ${cleanup}\r`); + await frontend.waitFor(/≡ Queued \(1\)/u, mark); + frontend.pty.write('/queue\r'); + await frontend.waitFor(/Command queue/u, mark); + frontend.pty.write('w'); + await frontend.waitFor(/runs even after a failure/u, mark); + frontend.pty.write('\u001b'); + await until(() => existsSync(cleanup), 20_000, 'the cleanup entry ran after the failure'); + await frontend.waitFor(/Command failed · exit 3/u, mark); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/themeFamilies.test.ts b/tests/themeFamilies.test.ts index 270e9ad8..281fade2 100644 --- a/tests/themeFamilies.test.ts +++ b/tests/themeFamilies.test.ts @@ -22,10 +22,46 @@ import {UI_COLORS} from '../src/ui/palette.js'; import {colorPickerKey, createColorPicker, hsvToRgb, renderColorPicker, rgbToHsv, xterm256} from '../src/ui/ColorPicker.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; import {setIconStyle} from '../src/ui/glyphs.js'; +import {addTheme} from '../src/appearance/themeLibraryActions.js'; const HEX = /^#[0-9a-f]{6}$/u; const plain = (rows: string[]) => rows.map(stripAnsi).join('\n'); +test('Starship and Oh My Posh imports preview lossy mapping before saving and preserve provider files', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-maple-theme-preview-')); + try { + const providers = [ + {name: 'starship.toml', kind: 'starship', text: '[directory]\nstyle = "fg:#112233"\n[custom.untrusted]\ncommand = "touch NEVER_EXECUTED"\nstyle = "fg:#ff0000"\n'}, + {name: 'theme.omp.json', kind: 'oh-my-posh', text: JSON.stringify({blocks: [{segments: [{type: 'path', foreground: '#112233', background: '#334455', template: '{{ .Env.SECRET }} $(touch NEVER_EXECUTED)'}]}]})}, + ]; + for (const provider of providers) { + await writeFile(join(directory, provider.name), provider.text); + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => []}; + const state = createThemeStudio(context, 'import'); + state.importPath = provider.name; + assert.equal(studioKey(state, {kind: 'enter'}, 'none', directory, context), undefined); + assert.ok(state.importPreview, provider.name); + assert.ok(state.importPreview.warnings.length, 'unsupported logic must be disclosed'); + assert.equal(context.themes.length, 0, 'preview stores nothing'); + const shown = plain(renderThemeStudio(state, context, 100, 50, 'none', [])); + assert.match(shown, /Import preview/u); + studioKey(state, {kind: 'escape'}, 'none', directory, context); + assert.equal(state.importPreview, undefined); + assert.equal(context.themes.length, 0); + studioKey(state, {kind: 'enter'}, 'none', directory, context); + const action = studioKey(state, {kind: 'enter'}, 'none', directory, context); + assert.equal(action?.kind, 'importTheme'); + if (action?.kind !== 'importTheme') continue; + assert.equal(action.origin.kind, provider.kind); + const saved = addTheme(structuredClone(DEFAULT_PROMPT_CONFIGURATION), action.theme, action.origin); + assert.ok(saved.ok); + if (saved.ok) assert.equal(saved.config.themes.length, 1); + assert.equal(await readFile(join(directory, provider.name), 'utf8'), provider.text); + await assert.rejects(readFile(join(directory, 'NEVER_EXECUTED')), {code: 'ENOENT'}); + } + } finally { await rm(directory, {recursive: true, force: true}); } +}); + test('theme families: canonical variants, valid ids, licenses recorded, NMSh themes unchanged', () => { assert.deepEqual(THEME_FAMILIES.map(family => family.id), ['nmsh', 'catppuccin', 'dracula', 'tokyonight', 'gruvbox', 'rosepine', 'nord', 'solarized', 'onedark', 'custom']); for (const family of THEME_FAMILIES.filter(item => item.id !== 'nmsh' && item.id !== 'custom')) { diff --git a/tests/themeImporters.test.ts b/tests/themeImporters.test.ts index fed80fce..8068dbff 100644 --- a/tests/themeImporters.test.ts +++ b/tests/themeImporters.test.ts @@ -161,3 +161,65 @@ test('bounded and binary input; unknown formats are refused with the supported l assert.match(errors(run('just some words\nnothing here', 'notes.txt')), /not a recognized theme format/u); assert.match(errors(run('a: [unclosed', 'bad.yaml')), /not a recognized theme format/u); }); + +const STARSHIP = ` +add_newline = false +palette = "mocha" +format = "$directory$git_branch$character" + +[palettes.mocha] +rosewater = "#f5e0dc" +green = "#a6e3a1" +red = "#f38ba8" +blue = "#89b4fa" + +[directory] +style = "bold bg:blue fg:#11111b" + +[git_branch] +style = "fg:green" +format = "[$symbol$branch]($style) " + +[nodejs] +style = "bright-green" + +[golang] +style = "bg:#00add8" + +[character] +success_symbol = "[➜](bold green)" +error_symbol = "[✗](bold red)" + +[custom.dangerous] +command = "rm -rf ~" +when = "true" +style = "fg:#ff0000" +`; + +test('Starship: static colors, palette names and character symbols become a Native theme; commands and names never run or invent colors', () => { + const result = preview(run(STARSHIP, 'starship.toml')); + assert.equal(result.format, 'starship'); + valid(result); + assert.equal(result.theme.prompt.cwd, '#89b4fa', 'bg: from the palette name wins over fg:'); + assert.equal(result.theme.prompt.gitBranch, '#a6e3a1'); + assert.equal(result.theme.prompt.go, '#00add8'); + assert.equal(result.theme.prompt.success, '#a6e3a1'); + assert.equal(result.theme.prompt.failure, '#f38ba8'); + assert.equal(result.theme.basedOn, 'Starship import'); + const warnings = result.warnings.join('\n'); + assert.match(warnings, /bright-green/u, 'an ANSI color name is reported, not turned into a color'); + assert.match(warnings, /1 \[custom\] module ignored: their commands are never run/u); + assert.match(warnings, /configures \d+ modules/u); + assert.doesNotMatch(JSON.stringify(result.theme), /#ff0000/u, 'a custom module\'s color is not imported'); + assert.ok(result.mapping.some(item => item.from === '[directory] style')); +}); + +test('Starship: an unknown palette, no usable colors, hostile names and oversized input are handled without guessing', () => { + assert.match(errors(run('[directory]\nstyle = "bold cyan"\n', 'starship.toml', 'starship')), /No static Starship style colors/u); + const missing = preview(run('palette = "nope"\n[git_branch]\nstyle = "fg:#112233"\n', 'starship.toml')); + assert.match(missing.warnings.join(' '), /"nope" is not defined/u); + const hostile = preview(run('[git_branch]\nstyle = "fg:#112233"\n', '\u001b[31mevil\u0007.toml', 'starship')); + assert.doesNotMatch(hostile.theme.name, /[\u0000-\u001f]/u); + assert.match(errors(run(`# ${'x'.repeat(IMPORT_SIZE_LIMIT)}\n[git_branch]\nstyle = "fg:#112233"\n`, 'starship.toml')), /larger than 256 KiB/u); + assert.match(errors(run('[unrelated]\nvalue = 1\n', 'a.toml')), /not an Oh My Posh config, a Starship config or a WezTerm/u); +});