diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1881c72f..34ccd95d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,8 @@ on: push: branches: [master, dev] pull_request: - branches: [master, dev] + # Stacked PRs need the same gates as integration PRs. + workflow_dispatch: permissions: @@ -16,11 +17,13 @@ concurrency: jobs: verify: - name: Verify - runs-on: macos-latest + name: Verify (${{ matrix.os }}, Node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} timeout-minutes: 10 strategy: + fail-fast: false matrix: + os: [macos-latest, ubuntu-24.04] node-version: [22.x, 26.x] steps: @@ -33,6 +36,28 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' + - name: Install Linux shell and native build prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh build-essential python3 + + - name: Verify secure Linux system completion paths + if: runner.os == 'Linux' + run: | + # Hosted image completion directories can be group-writable. Global + # compinit then prompts before NMSh's isolated startup files run. + insecure="$(zsh -fc 'autoload -Uz compaudit; compaudit' 2>/dev/null || true)" + while IFS= read -r path; do + [ -z "$path" ] && continue + case "$path" in + /usr/share/zsh|/usr/share/zsh/*|/usr/local/share/zsh|/usr/local/share/zsh/*) + sudo chown root:root "$path" + sudo chmod go-w "$path" + ;; + *) echo "::error::Unexpected insecure completion path: $path"; exit 1 ;; + esac + done <<< "$insecure" + zsh -fc 'autoload -Uz compaudit; compaudit' + - name: Install dependencies run: npm ci @@ -42,15 +67,23 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Typecheck benchmark script + run: npx tsc --ignoreConfig --noEmit --types node --target ES2022 --module NodeNext --moduleResolution NodeNext --esModuleInterop --skipLibCheck scripts/benchmarks.ts scripts/platform-benchmarks.ts + + - name: Bounded platform timing smoke + run: | + node --import=tsx scripts/platform-benchmarks.ts + NMSH_BENCH_SAMPLES=5 NMSH_BENCH_WARMUP=1 npm run bench -- completion/configured-cold completion/configured-warm composer/screen-plan transcript/wrap-present-10000 + - name: Run tests - run: npm test + run: npm test -- --test-timeout=120000 - name: Verify working tree clean run: git diff --check - name: Process leak check run: | - if ps -axo pid,ppid,pgid,command | grep -E '[z]sh.*nmsh-semantic|[n]msh-semantic|viewportSyntax' | grep -v grep; then + if ps -axo pid,ppid,pgid,command | grep -E '[z]sh.*nmsh-semantic|[n]msh-semantic|[c]onfigured-completion\.zsh|[c]apture\.zsh|[v]iewportSyntax' | grep -v grep; then echo "::error::NMSh helper or test processes leaked!" exit 1 fi diff --git a/ROADMAP.md b/ROADMAP.md index 7571e782..c6fa80cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,10 @@ |---|---| | **Current release** | [v0.7.0 — UI Foundation & Customization](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) | | **Development branch** | `dev` | -| **Next direction** | Not yet defined; see the Backlog below and open issues | +| **Next direction** | [v0.8.0 — Command Intelligence & Navigation](https://github.com/raiseCatError/notMyShell/milestone/8), in development | +| **Stacked development** | [v0.9.0 — Tools, Integrations & Workflows](https://github.com/raiseCatError/notMyShell/milestone/9), development-complete, unmerged; physical QA pending | +| **Prior stacked milestone** | [v0.10.0 — Terminal Hosts & Compatibility](https://github.com/raiseCatError/notMyShell/milestone/10), development acceptance complete as an unmerged stack; [evidence and limitations](docs/testing/v010-acceptance.md), physical QA pending | +| **Current stacked milestone** | [v0.13.0 — Portability & Platform Foundations](https://github.com/raiseCatError/notMyShell/milestone/13), Linux runtime/CI and Windows feasibility; unmerged, physical QA deferred | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | GitHub issues define actionable remaining work. A merged implementation may still be open with `needs-human-test` while physical terminal checks are pending. Closed issues represent completed work, not a promise that future refinements are finished. @@ -112,6 +115,50 @@ The final release candidate also included the passive-hover selection fix. | [#172](https://github.com/raiseCatError/notMyShell/issues/172) | Semantic motion engine; shimmer migrated onto it | | [#173](https://github.com/raiseCatError/notMyShell/issues/173) | Deterministic presentation mode | +## In development — v0.8.0 Command Intelligence & Navigation + +[Milestone #8](https://github.com/raiseCatError/notMyShell/milestone/8) contains #140, #141, #142, #143, #144, #145, #147 and #152. The [architecture and implemented scope](docs/design/command-intelligence.md) describe the unmerged review stack; the [physical-QA checklist](docs/qa/v0.8.0-physical-qa.md) remains pending. Package version stays at 0.7.0 during development. + +Native structured completion, command-level history, reusable optional pickers, explicit directory navigation and conservative edit-only corrections build on the released UI foundation. External completion sources remain research; #52 stays parked. #155 / #193 supply measurements without absorbing the full performance issue into this milestone. + +The earlier milestone #7 now represents unscheduled Discoverability & Integrations; its unrelated scope remains separate. + +The continuation's final acceptance head is [#251](https://github.com/raiseCatError/notMyShell/pull/251), +`docs/v08-continuation-final-verification` at `2dfbf9c12c54ada8af364bc44db87bc70f0b77c0`. +It includes command inspector, block actions, notifications and fixture hygiene. +These remain implemented/unmerged with cumulative physical QA pending, not reopened backlog. + +## Stacked development — v0.9.0 Tools, Integrations & Workflows + +[Milestone #9](https://github.com/raiseCatError/notMyShell/milestone/9) adopts #9, +#83, #153, #154, #176, #177 and #178. Research children #252 (supported config) +and #253 (mise awareness) are also included. #176/#177/#178 moved from the +unscheduled grouping; #179/#180 remain there. The stack consumes the pinned +final v0.8 head without modifying or merging it; package version remains 0.7.0. + +Review order: #251 → #182 → #181 → #183 → #254 → #255 → #256 (#253) +→ #257 (#153) → final acceptance on `docs/v09-final-acceptance`. +Linguist identity, external welcome adapters, deterministic VHS tooling, +supported Starship configuration, Tools discovery/install, consent-based optional +mise awareness and new-live-session presets are implemented in the unmerged stack. +The ENOSPC checkpoint was resumed and #255 local verification passed; it is ready +for review. See the [final development record](docs/development/v0.9.0-final-acceptance.md) +and [single additive QA checklist](docs/qa/v0.9.0-physical-qa.md). +Physical QA remains pending, issues remain open, version remains 0.7.0, and v0.9 +has not shipped. No unrelated backlog or release preparation is included. + +## Stacked development — v0.13.0 Portability & Platform Foundations + +[Milestone #13](https://github.com/raiseCatError/notMyShell/milestone/13) contains +Linux research #18, Windows research #19, Linux implementation #279 and +portability hardening #281. Its frozen cumulative base is PR #278 at +`cfc9c08a5fb7b5f3bc637400a59da7a714a58ae4`; lower published branches are unchanged. +Review #278 → #280 (Linux baseline/CI) → #282 (portability hardening) → final +v0.13 acceptance/docs PR. Every PR targets its immediate predecessor; no merge +or release is authorized. Package and lockfile remain 0.7.0. Physical QA is +intentionally deferred. Custom prompts #73, graphics #151, layout #79 and new +shell backends are outside this milestone. + ## Backlog — Research and Future Features These remain open and are not scheduled for a release. @@ -120,13 +167,8 @@ These remain open and are not scheduled for a release. |---|---| | [#52](https://github.com/raiseCatError/notMyShell/issues/52) | Configured-zsh completion parity and fzf-tab interoperability | | [#75](https://github.com/raiseCatError/notMyShell/issues/75) | Native parity with common zsh editor plugins | -| [#9](https://github.com/raiseCatError/notMyShell/issues/9) | Optional shell-tool discovery and first-run setup | | [#73](https://github.com/raiseCatError/notMyShell/issues/73) | Custom user-defined prompt modules | | [#78](https://github.com/raiseCatError/notMyShell/issues/78) | Chroma: gradients, animated color treatments, and transient visual effects | -| [#83](https://github.com/raiseCatError/notMyShell/issues/83) | Tool configuration center inside `/settings` | -| [#84](https://github.com/raiseCatError/notMyShell/issues/84) | Command inspector | -| [#85](https://github.com/raiseCatError/notMyShell/issues/85) | Interactive command/output block controls | -| [#106](https://github.com/raiseCatError/notMyShell/issues/106) | Command completion notifications for long-running commands | zsh-autosuggestions and zsh-syntax-highlighting are not required plugins; NMSh provides those UI roles natively. @@ -148,7 +190,7 @@ NMSh provides the surrounding interaction and presentation layer; tools such as ## Longer Term — Shells and Platforms -zsh remains the only first-class backend. ShellAdapter research and Linux/Windows investigations remain future work; multi-shell and those platform targets are not promised today. +zsh remains the only first-class backend. v0.13 implements a Linux development baseline and researches Windows/ConPTY/WSL; it is not a public platform release. Native Windows and additional shell backends remain unsupported. See [Linux foundations](docs/architecture/v013-linux-foundations.md), [Windows feasibility](docs/architecture/v013-windows-feasibility.md), and [deferred portability QA](docs/testing/v013-physical-qa.md). | Issue | Title | |---|---| diff --git a/dev/tapes/.gitignore b/dev/tapes/.gitignore new file mode 100644 index 00000000..ea1472ec --- /dev/null +++ b/dev/tapes/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/dev/tapes/README.md b/dev/tapes/README.md new file mode 100644 index 00000000..fd359fb1 --- /dev/null +++ b/dev/tapes/README.md @@ -0,0 +1,23 @@ +# VHS development demos + +These optional VHS tapes capture welcome, Settings v2, an ordinary command and native history selection. They are development tooling only; NMSh does not depend on VHS at runtime, and `npm test` does not invoke it. + +## Run + +From the repository root, install the normal project dependencies if needed, then run: + +```sh +npm run build +mkdir -p dev/tapes/output +vhs validate 'dev/tapes/*.tape' +vhs dev/tapes/welcome.tape +vhs dev/tapes/settings.tape +vhs dev/tapes/command.tape +vhs dev/tapes/intelligence.tape +``` + +VHS writes GIFs under `dev/tapes/output/`, which is ignored by Git. VHS is not installed automatically. Install it using the instructions in the [official VHS repository](https://github.com/charmbracelet/vhs) if you want to record these demos. + +The launch helper isolates HOME/preferences, disables update checks, opts out of the session service and uses the current `NMSH_DETERMINISTIC=1` seam. It never edits user config or attaches an existing session. Each tape exits so the helper can remove its private directory. Deterministic presentation freezes decorative motion and the completion clock only; real command durations, scheduling, font, build identity and cwd still vary. Review captures before sharing; these are not pixel goldens. + +Prefer VHS 0.12.1 or later: the previously tested host 0.12.0 binary reported success without materializing recordings. Validation is useful locally; visual-golden CI is deferred because font/host/timing differences would make it fragile. No end-user QA or optional-tool installation is required. Keep captures short and offline. diff --git a/dev/tapes/command.tape b/dev/tapes/command.tape new file mode 100644 index 00000000..3efd33a7 --- /dev/null +++ b/dev/tapes/command.tape @@ -0,0 +1,20 @@ +Require node +Require zsh + +Output dev/tapes/output/command.gif +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "echo 'Hello from NMSh'" Enter +Wait+Screen /Hello from NMSh/ +Sleep 2s +Hide +Ctrl+D +Sleep 1s diff --git a/dev/tapes/intelligence.tape b/dev/tapes/intelligence.tape new file mode 100644 index 00000000..970f3024 --- /dev/null +++ b/dev/tapes/intelligence.tape @@ -0,0 +1,22 @@ +Require node +Require zsh + +Output dev/tapes/output/intelligence.gif +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "printf demo" Enter +Sleep 1s +Type "/history" Enter +Sleep 2s +Hide +Escape +Ctrl+D +Sleep 1s diff --git a/dev/tapes/launch.mjs b/dev/tapes/launch.mjs new file mode 100644 index 00000000..51a46ed7 --- /dev/null +++ b/dev/tapes/launch.mjs @@ -0,0 +1,31 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; + +// Isolate demo preferences and zsh startup; never change the user's config. +const root = mkdtempSync(join(tmpdir(), 'nmsh-demo-')); +const config = join(root, 'config', 'nmsh'); +mkdirSync(config, {recursive: true}); +writeFileSync(join(config, 'config.json'), JSON.stringify({onboardingComplete: true, + glyphChoiceComplete: true, glyphStyle: 'safe', welcome: 'vespyr', updateChecks: false, + liveSessionStartup: 'never', composerPosition: 'bottom', transcriptPresentation: 'normal'})); +try { + const child = spawn(process.execPath, [resolve('dist/index.js'), '--new'], {stdio: 'inherit', + env: {...process.env, HOME: root, XDG_CONFIG_HOME: join(root, 'config'), + NMSH_DETERMINISTIC: '1', NMSH_SESSION_SERVICE: '0'}}); + const forward = () => child.kill('SIGTERM'); + process.on('SIGINT', forward); + process.on('SIGTERM', forward); + try { + process.exitCode = await new Promise((accept, reject) => { + child.once('error', reject); + child.once('exit', code => accept(code ?? 1)); + }); + } finally { + process.off('SIGINT', forward); + process.off('SIGTERM', forward); + } +} finally { + rmSync(root, {recursive: true, force: true}); +} diff --git a/dev/tapes/settings.tape b/dev/tapes/settings.tape new file mode 100644 index 00000000..418caba5 --- /dev/null +++ b/dev/tapes/settings.tape @@ -0,0 +1,25 @@ +Require node +Require zsh + +Output dev/tapes/output/settings.gif +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Type "/settings" Enter +Wait+Screen /Settings/ +Show +Sleep 2s +Right +Sleep 1s +Right +Sleep 1s +Hide +Escape +Ctrl+D +Sleep 1s diff --git a/dev/tapes/welcome.tape b/dev/tapes/welcome.tape new file mode 100644 index 00000000..c7a5f1e1 --- /dev/null +++ b/dev/tapes/welcome.tape @@ -0,0 +1,18 @@ +Require node +Require zsh + +Output dev/tapes/output/welcome.gif +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 60ms + +Hide +Type "node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Sleep 3s +Hide +Ctrl+D +Sleep 1s diff --git a/docs/architecture/language-identity-colors.md b/docs/architecture/language-identity-colors.md new file mode 100644 index 00000000..ee37d26b --- /dev/null +++ b/docs/architecture/language-identity-colors.md @@ -0,0 +1,15 @@ +# Language identity colors + +NMSh exposes GitHub Linguist's language colors as identity data for tool cards and project metadata. These colors identify a language only. They do not indicate success, warning, failure, focus, or any other UI state. `languageIdentity(name)` returns Chroma's existing identity category; `statusMeaning` is always undefined for it. Consumers use Chroma's capability fallback and retain text labels with NO_COLOR. The dataset does not repaint existing prompt themes. + +The generated mapping is checked in at `src/languages/linguistLanguageColors.generated.ts`. Normal rendering reads only this local TypeScript data and makes no network request. `languageIdentityColor(name)` normalizes Unicode, case, and whitespace; it recognizes canonical names and aliases published by Linguist. Unknown names return the neutral `UNKNOWN_LANGUAGE_IDENTITY_COLOR` value. + +## Provenance and license + +The source is [`github-linguist/linguist`'s `lib/linguist/languages.yml`](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml). Linguist describes the `color` field as its CSS color for a language, and documents that aliases are used for language lookup. The source repository is distributed under the [MIT License](https://github.com/github-linguist/linguist/blob/main/LICENSE), reproduced at `licenses/GITHUB-LINGUIST-MIT.txt`. The generated file records the exact Linguist commit used. + +## Updating the mapping + +Run `node scripts/update-linguist-language-colors.mjs` intentionally when updating the cached dataset. The script reads the current Linguist revision, fetches its pinned `languages.yml`, extracts only names, aliases, and valid `#RRGGBB` colors, and writes entries in stable name order. Review the generated diff and source revision with the normal code review. Build, tests, and runtime do not invoke this script or require network access. + +To add or change consumers, import `languageIdentityColor` from `src/languages/linguistLanguageColors.ts`. Keep any contrast-adjusted display color local to that rendering consumer; do not overwrite the identity color or use it as a semantic status color. diff --git a/docs/architecture/mise-project-awareness.md b/docs/architecture/mise-project-awareness.md new file mode 100644 index 00000000..c0d55139 --- /dev/null +++ b/docs/architecture/mise-project-awareness.md @@ -0,0 +1,50 @@ +# Optional mise project awareness + +Refs #253 / #154. NMSh works without mise. `/tools` → mise → **M project +awareness** is also reachable through Settings and the command palette's Tools +action. Opening it checks executable availability and standard local/ancestor +markers using filesystem metadata only. It never reads templates or invokes +mise, even after cwd changes. The ordinary tools catalog does not version-probe +mise. Existing real-zsh hooks remain authoritative. + +**I inspect** and **R refresh** open a default-No confirmation showing the two +metadata commands and explaining template execution and remote task fetching. +Only affirmative intent invokes `mise ls --current --json`, then +`mise tasks ls --json`. No `trust`, activation, config edits, installation or +environment application occurs. Refresh requires renewed consent. Esc cancels +the review or aborts the active detached metadata process group. + +The dedicated capture uses argv, ignored stdin, a three-second deadline per +command and 128 KiB combined stdout/stderr limit. stderr is discarded; failures +are generic. JSON parsing retains tool names/versions and task names only. +Unknown fields, including `env`, `run`, descriptions and source paths, never +enter UI/cache/persistence. Metadata remains in memory, bounded to 32 identities +keyed by canonical cwd, binary and ancestor marker/project stat identity. Config +changes invalidate an identity; explicit refresh reruns inspection. No typing or +rendering invocation exists. A failed inspection is cached factually. + +Task Enter inserts a quoted visible `mise run ''` into the composer. A +separate Enter submits it through ordinary real-zsh execution. Tasks may execute +project actions or install tools; returned names carry no safety endorsement. +Option-like/control-containing names are rejected. No environment values or new +settings are persisted. + +Current upstream behavior was checked against the +[ls reference](https://mise.jdx.dev/cli/ls.html), +[task-list reference](https://mise.jdx.dev/cli/tasks/ls.html), +[template execution warning](https://mise.jdx.dev/templates.html), and tagged +[`v2026.9.18` task source](https://github.com/jdx/mise/blob/v2026.9.18/src/cli/tasks/ls.rs). +The tool JSON is an object of version arrays; task JSON is an array of objects. +Task listing may fetch remote task files and evaluates resolved task directories. +The consent screen therefore does not describe metadata as side-effect-free. + +Standard marker coverage is conservative, not a full reimplementation of mise +config selection: `mise.toml`, `.mise.toml`, `.mise/config.toml`, +`.config/mise/config.toml`, `.tool-versions`. Environment-specific/custom config +may exist without a detected marker. Metadata inspection remains explicitly +available when installed. There is no automatic task argument editor, active +hook inference or persistent metadata/consent. + +Fake executable tests cover trust boundaries, bounded failures, schema/cache, +quoting and composer integration. Physical QA remains separate and only needs +mise checks when already installed/configured. diff --git a/docs/architecture/optional-tools.md b/docs/architecture/optional-tools.md new file mode 100644 index 00000000..00a4fd95 --- /dev/null +++ b/docs/architecture/optional-tools.md @@ -0,0 +1,49 @@ +# Optional shell tools + +`/tools`, Settings → Tools and the command palette open one terminal-native +browser. Discover searches the offline curated catalog by name, description or +category. Within categories missing tools precede installed tools. Installed +contains detected executables; Configure contains only real supported adapters; +Errors contains retained installation failures or missing selected providers. +No marketplace, update feed, network discovery or required external tool exists. + +Up/Down selects, Left/Right changes tabs, typing searches, Enter opens details, +Esc returns/clears/closes. Details show executable/version evidence, category, +source and install package. `I` previews installation when missing, `C` opens a +supported configuration adapter, `P` opens the existing provider chooser, and +`R` explicitly refreshes detection. Toolchain detail labels consume offline +Linguist through Chroma identity colors; status text remains separate. + +Detection uses the existing cached provider resolver and bounded version probes, +in batches of three on opening/refresh. Rendering never launches a probe or +fetches network data. Installed means an executable is present, not that a daemon, +credential, zsh hook or environment is healthy. Configured means an installed +tool is selected in NMSh settings, not that its shell hooks are active. Hook +inspection/setup is deferred and never inferred. Direct shell tools work normally. + +Conservative Recommended tools: zoxide, fzf, ripgrep, fd and jq. Other entries are +optional/specialized. First-run discovery follows glyph/prompt setup, defaults +to Skip, and offers Recommended or Choose individually. Both choices only browse; +Recommended filters Discover. No bulk installation or shell-hook modification. +Legacy completed onboarding remains complete. The additive `toolsSetupComplete` +boolean uses the existing normalizer/atomic NMSh preference writer. + +Installation recipes are fixed Homebrew package argv, offered only when brew is +available. Unsupported package managers get official-source guidance without +execution; no sudo, shell interpolation, install scripts or custom taps. Each +install shows the command and software-change scope, starts on No, and requires +a fresh explicit confirmation. Shared TaskProgress reports actual completion +and failures, with bounded captured diagnostics, detached process ownership and +timeout/disposal cleanup. Installation never configures hooks/providers; after +success availability is rechecked. No tools were installed during development. + +The shared configuration panel implements Starship module toggles only, with +supported-value previews, explicit apply, backup and atomic unknown-preserving +writes. Native fallback works with all tools missing. Tool failures are retained +only within the open browser; persistence across browser reopen, bulk selection, +hook activation detection/setup and additional package managers remain deferred. + +NO_COLOR retains text state cues, Safe glyph mode retains keyboard operation, +and progress respects reduced motion. Extremely small widths necessarily clip +descriptive/help rows; use a wider terminal to review installation/configuration. +No mouse interaction is required. diff --git a/docs/architecture/session-presets.md b/docs/architecture/session-presets.md new file mode 100644 index 00000000..409820fa --- /dev/null +++ b/docs/architecture/session-presets.md @@ -0,0 +1,85 @@ +# Session presets v1 + +Refs #153. `/presets`, the shared command-palette action, and `nmsh --preset +` describe NMSh startup configurations, independent of terminal profiles +and mise. `nmsh --presets` lists without executing. The one shared panel provides +create, list, inspect, launch and confirmed delete. `/resume` remains the place +to reattach the live sessions they create. + +## Storage and explicit creation + +`presets.json` is an additive sidecar beside the existing `config.json`, using +the shared platform/XDG config-directory resolver. Existing preferences and +transcripts are not reset or migrated. An absent sidecar means an empty list. + +```json +{ + "version": 1, + "presets": [ + { + "name": "project", + "cwd": "/work/project", + "commands": ["mise run build", "git status --short"] + } + ] +} +``` + +Names are case-sensitive, 1–64 ASCII letters/numbers/spaces/underscore/hyphen, +starting with a letter/number. cwd must be an accessible absolute directory. +Commands are supplied explicitly, at most 16 / 16 KiB total; the form uses one +shell command per line. It never captures history, transcript commands, shell +state or environment dumps. Do not put passwords/tokens or secret values in +startup commands: reference existing environment tooling instead. There are no +stored environment values, provider/layout overrides or inferred project state. + +Writes use a private 0600 staged file, atomic rename, exclusive writer lock and +an intervening-change guard. Malformed/unknown-version files, symlinks, duplicate +names and busy storage are reported and preserved. Unknown fields are ignored +on read and excluded from writes. Storage is limited to 100 presets / 256 KiB. +A crash leaving `presets.json.lock` requires checking that no writer is active +before manually removing that specific lock; NMSh does not guess lock ownership. + +## Acknowledgement and launch + +The first launch shows the exact quoted real `cd` and saved commands, wrapped +without elision and scrollable with PgUp/PgDn. Default No/Enter or Esc runs +nothing. An affirmative review records `acknowledged`, a SHA-256 digest of cwd +and ordered commands. Matching content needs no repeated confirmation; changes +require acknowledgement again. Saving a new preset never imports an +acknowledgement. A stale inspected snapshot cannot acknowledge changed storage. +This acknowledgement is separate from mise's per-inspection metadata consent. + +Launch bypasses automatic startup restoration and creates a **new** service +session from the launcher's current cwd. A UI launch detaches its current live +session, preserving it for `/resume`; it never changes that old shell's cwd or +environment. Presets require the existing live-session service: an in-process +fallback is rejected before startup, since it cannot provide resumability. +No window/tab/profile authority is added. + +The new real zsh reaches its initial prompt, then the frontend submits visible +`cd -- ''` via its ordinary command path. Each subsequent startup command +waits for the preceding real prompt. Entries, output, timing and cwd enter the +normal transcript/journal. NMSh does not `chdir()` and pretend zsh followed. +Initial cwd is verified, including equivalent symlink/trailing-slash paths. +Failed cd/commands stop the remainder factually. Ctrl+C cancels the remaining +queue. Interactive commands retain normal passthrough behavior. Slash-prefixed +startup text remains real shell input, not an NMSh frontend action. + +Preset launch postpones ordinary first-run setup to a later regular launch; +existing config defaults still apply and no onboarding preferences are reset. +Startup scheduling waits for frontend journaling/UI readiness and shell prompt +readiness. The queue is frontend-owned and is not persisted/replayed: if the +frontend exits partway through, the running command survives in its live shell, +but unsubmitted startup commands do not resume automatically. After completed +startup, detach/reattach and `/resume` use the existing session identity and +journal, with no startup replay or live-session protocol/schema change. + +V1 deliberately excludes edit/rename UI, environment snapshots, arbitrary +workflow graphs, terminal-host profiles, provider/layout overrides, installation +and multi-shell support. Recreate a preset to change it through the UI; manual +sidecar edits remain subject to validation and acknowledgement. + +Automated coverage uses isolated config/live-service sandboxes for storage, +consent, CLI/UI launches, literal paths, transcript order and resumability. +Ghostty/Terminal.app physical validation remains pending. diff --git a/docs/architecture/shell-adapter-v011-research.md b/docs/architecture/shell-adapter-v011-research.md new file mode 100644 index 00000000..5d7375b1 --- /dev/null +++ b/docs/architecture/shell-adapter-v011-research.md @@ -0,0 +1,55 @@ +# ShellAdapter boundary: research after v0.11 + +Refs #17. zsh is the only supported shell. No Bash, Fish, Nushell or PowerShell +backend, generic runtime adapter or implementation child is introduced here. + +## Existing boundaries + +`ShellSession` owns the persistent PTY, trusted zsh startup proxy, authenticated +OSC command/prompt markers and real shell signals/job control. `SessionClient` +already separates frontend consumers from in-process/socket transport; that is +a transport contract, not a multi-shell contract. `SessionService` and backlog +replay preserve lifecycle/output events. The frontend owns its editable buffer, +menus, pickers and presentation. The configured completion bridge consumes zsh +completion knowledge in an isolated hidden zpty and inserts through that editor. + +## Assumption inventory + +| Area / current code | zsh assumption | Classification / future action | +| --- | --- | --- | +| `ShellSession` spawn/startup | `/bin/zsh -i`, HOME startup proxies, ZDOTDIR, prompt suppression | Leave zsh-specific now; another shell needs startup/trust research before extracting a backend factory. | +| PTY and job control | Persistent PTY, foreground process group, Ctrl+C/Ctrl+Z, resize | Expensive to generalize across OS/terminal models; preserve PTY ownership and test actual shell signal behavior. | +| Command lifecycle / hooks | `preexec`, `precmd`, `add-zsh-hook`, OSC status/cwd markers | Candidate adapter boundary later; hook ordering and multiline/parse failure behavior require another-shell research. | +| cwd/environment | Shell cwd from prompt marker; launch environment in helpers | Leave zsh-specific capture now. Future context snapshots need explicit names, privacy and generation semantics; do not pretend helpers mirror live PATH/environment. | +| Status | `$?` captured before precmd metadata work | Future backend should emit factual status; pipelines/signals/job statuses require shell probes. | +| History | zsh history formats and privacy/submission policy | Keep provider boundaries; shell history import needs per-shell parsing/privacy rules. | +| Completion | compinit/compdef, compadd, zpty/ZLE capture | Expensive shell-specific engine; abstract structured requests/candidates only when a second backend produces them. No foreign widgets may own the NMSh editor. | +| Aliases/functions | zsh parameter tables; bounded name-only precmd snapshot | Future metadata capability, with completeness/generation. Another shell needs name/type/visibility research; do not generalize function bodies. | +| Semantic classification | lexical zsh roles and isolated `whence` | Safe to leave zsh-specific; syntax families differ materially. Unknown forms must degrade safely. | +| Prompt suppression | unset ZLE and blank prompts, p10k/fastfetch suppression | Shell/plugin-specific startup policy, never a generic prompt string replacement. | +| Restore / detach | Service owns the same real shell; frontend replays events | Transport can stay shared. Backend identity/version belongs in future session metadata before supporting multiple backends. No shell-state serialization is promised. | +| Commands and interactive apps | Buffer submitted to real zsh, passthrough forwards terminal bytes | Preserve frontend/PTY boundary; execution syntax and interactive detection require shell-specific probes. | +| Configured completion lifecycle | trusted HOME config, bounded helper, conservative invalidation | Keep helper implementation zsh-specific. A future completion capability must state context provenance and config generation rather than imply live state parity. | + +No area needs an additional generic interface **now**: current candidate, +lifecycle, provider and transport contracts already isolate useful consumers. +A tiny extraction without a second concrete behavior would add names without +reducing risk. No child issue or runtime extraction is justified for v0.11. + +## Future surface to validate + +A backend factory could expose persistent-session start/stop/resize/submit and +interrupt/suspend operations, factual output/exec/prompt events, startup trust +policy and capabilities. Capabilities may provide bounded command classification, +name metadata, history import and structured completion for a context containing +buffer/cursor/cwd/session/config generations. Completion remains optional and +failure must preserve native editing. Signal handling and raw PTY forwarding +belong beside the backend, never in command-by-command subprocess execution. + +Do not require all shells to implement all capabilities. First investigate one +additional shell in a separate future milestone with real fixtures: bootstrap +without competing prompt UI, exact lifecycle/status/cwd, incomplete/multiline +input, alias/function visibility, history privacy, command-not-found behavior, +completion replacement semantics, job control and detach/reattach. Extract only +the behavior demonstrated by that work. Linux/Windows and broader frontend/TUI +research remain outside v0.11. diff --git a/docs/architecture/supported-tool-configuration.md b/docs/architecture/supported-tool-configuration.md new file mode 100644 index 00000000..e50a5384 --- /dev/null +++ b/docs/architecture/supported-tool-configuration.md @@ -0,0 +1,30 @@ +# Supported tool configuration + +Research #83 defines the boundary; #252 implements it. A supported adapter +declares fields, reads supported values, prepares a private proposal, returns a +safe supported-value preview, and applies only after the shared confirmation. +Detection or provider selection never grants configuration write authority. +Preference: official/native CLI/API, then a structured parser, then a narrow +per-tool adapter. No arbitrary file editor or terminal appearance controls. + +The first consumer is Starship's seven existing boolean module controls. The +shared Settings destination and later Tools Configure use the same native CLI +adapter as the prompt panel. Staging does not touch the user's file. Runtime +module/value validation, a prepared-proposal identity check, an intervening-edit +recheck, exclusive backup, same-directory atomic rename and directory fsync guard +application. Symlinks, nonregular/oversized files, multiline TOML strings and +native rewrites outside the selected field are refused rather than guessed. +Unknown setting lines/comments remain unchanged; blank-line normalization by +the native CLI is allowed. Original mode is retained. Proposals are not persisted. + +Only supported field changes reach previews. Native CLI errors use generic +messages because stderr can include unrelated config or secrets. No config +contents are added to transcripts, logs, command history or session metadata. +The new shared review starts on No; cancel does not write. Other tools have no +config controls until an explicit reviewed adapter exists. This implementation +adds no public persistent schema or dependency. + +Concurrent uncooperative edits in the final recheck/rename window cannot be +locked out universally; the adapter detects edits made before application and +backs up current bytes. A directory-fsync failure after rename can report a +failure despite a valid installed change; the next read shows actual state. diff --git a/docs/architecture/terminal-app-baseline.md b/docs/architecture/terminal-app-baseline.md new file mode 100644 index 00000000..0a30783a --- /dev/null +++ b/docs/architecture/terminal-app-baseline.md @@ -0,0 +1,25 @@ +# Terminal.app baseline (unreleased v0.10) + +Terminal.app and unknown hosts use ordinary cursor/alternate-screen rendering, +owned editing, history, completion, inspector, keyboard block actions, folding, +LIVE and passthrough. Ctrl+J inserts a newline; Ctrl+W deletes a word; Ctrl+R +opens history; F1 opens the palette. Mouse hover is optional. Safe glyphs, +reduced motion and no-color remain independent user choices. + +Chroma is the only NMSh-owned color authority. Host capability evidence informs +defaults: truecolor when evidenced, 256 colors with `TERM=*-256color`, otherwise +16 colors. `NMSH_COLOR=none|16|256|truecolor` overrides these defaults, including +NO_COLOR according to existing precedence. Program SGR is untouched. Existing +presentation snapshots explicitly use truecolor; baseline tests use isolated +environment fixtures. Stored historical program colors remain program-owned. + +Apple supports profiles containing background, opacity, font and ANSI palette +settings, plus profile export/import. That establishes a feasible manual path, +not permission to mutate preferences. Automatic profile management is deferred. +A future implementation must preview a dedicated profile, export/backup state, +ask explicit confirmation and offer restoration. Baseline operation needs none +of that. See [Apple profile documentation](https://support.apple.com/guide/terminal/trml107/mac). + +Automated checks cover mode emissions, portable decoder keys, Chroma fallback and +the existing editor/layout/history/passthrough suites. GUI appearance, selection +and host shortcuts remain [physical QA](../testing/v010-physical-qa.md). diff --git a/docs/architecture/terminal-frontend-landscape-v012.md b/docs/architecture/terminal-frontend-landscape-v012.md new file mode 100644 index 00000000..324d1ac5 --- /dev/null +++ b/docs/architecture/terminal-frontend-landscape-v012.md @@ -0,0 +1,20 @@ +# Terminal frontend landscape — #179 findings (current primary sources, 2026-10-02) + +NMSh remains a terminal-agnostic TypeScript frontend over a persistent real zsh, with an owned composer, semantic transcript, optional providers, customization and resumable sessions. The following boundary conclusions are engineering inferences from the referenced designs, not a claim that one product wins. + +| Category | Ownership and useful overlap | Boundary for NMSh | +| --- | --- | --- | +| [Warp blocks](https://docs.warp.dev/terminal/blocks) | A command/output block is a navigable, copyable unit inside its terminal product. Structured navigation and editor placement are useful precedents. | Retain host-independent ScreenPlan and faithful PTY archive. Do not adopt cloud collaboration or terminal-emulator ownership. | +| [Wave widgets](https://docs.waveterm.dev/widgets), [custom widgets](https://docs.waveterm.dev/customwidgets), [customization](https://docs.waveterm.dev/customization) | Resizable terminal/file/web widgets with a single focused widget; declarative widget configuration and explicit commands. | Borrow visible focus and explicit adapters. Web/GPU workspace and terminal palette ownership are outside NMSh. | +| [Fish design](https://fishshell.com/docs/current/design.html) | Interactive shell usability, discoverability and editor feedback. | Integrate suggestions over existing zsh; changing shell grammar/state is outside scope. | +| [Nushell](https://www.nushell.sh/book/thinking_in_nu.html) | Structured pipeline values and commands. | NMSh structures command metadata, not arbitrary program output or shell pipeline values. | +| [Xonsh](https://xon.sh/) | Python-powered shell language and extensibility. | No executable presentation configuration or replacement language. | +| [tmux](https://github.com/tmux/tmux/wiki), [Zellij](https://github.com/zellij-org/zellij) | Pane/session multiplexing, keyboard navigation, plugins (Zellij). | NMSh attaches to its managed shell session; host multiplexers remain independent. Do not create pane/window management. | +| [TUIOS](https://github.com/Gaurav-Gosain/tuios) | Terminal window management, workspaces, persistent sessions and agent-oriented inbox concepts. | Explicit ownership transfer and lifecycle are useful; no agent manager or window manager in NMSh. | +| [Starship](https://starship.rs/) and Powerlevel10k | Prompt providers own their rendered identity. | Keep external provider output faithful; treatments target Native semantics or NMSh framing. | +| [Atuin](https://docs.atuin.sh/cli/) | Dedicated searchable history and optional synchronization. | Existing optional read-only provider integration; no mandatory account or cloud persistence. | +| Charm / Ink / OpenTUI / Ratatui / Textual | Terminal-native composition, keyboard focus, events, styling and async lifecycle. | Concepts inform owned UI; framework rendering must never become shell/transcript authority. See #180. | + +Across categories, visual customization and keyboard/mouse interaction only help when focus, ownership and recovery are explicit. NMSh keeps NO_COLOR, Safe glyphs, keyboard-only access and Reduced Motion first-class. This research does not assert accessibility parity between products: primary references describe mechanisms, not a comparative usability audit. + +Product boundary: NMSh is not a terminal emulator, replacement shell language, multiplexer, IDE, cloud collaboration platform or agent manager. Provider adapters are integration seams, not a new public executable plugin runtime. Persistent/resumable shell sessions remain separate from presentation records. No migration or dependency change follows from this comparison. diff --git a/docs/architecture/terminal-host.md b/docs/architecture/terminal-host.md new file mode 100644 index 00000000..ea8e5b2c --- /dev/null +++ b/docs/architecture/terminal-host.md @@ -0,0 +1,99 @@ +# Terminal host capabilities (unreleased v0.10) + +`TerminalHost` combines diagnostics/window launching with a plain capability +record. `src/host/capabilities.ts` owns conservative adapter hints; general +presentation consumes values, not terminal names. Unknown hosts and Terminal.app +have a keyboard-only baseline. No host preferences change during detection. + +Every new frontend constructs its own host, including live-session reattach. +Capabilities never enter the shell service protocol, journal or shell environment. +The old shell's inherited environment remains authoritative for its commands. + +Before renderer entry, one parallel batch requests Kitty keyboard flags and +DEC mode 2026 status. Both share an 80 ms maximum wait. Split replies are gathered +before parsing; early typing is replayed to the editor, and bracketed paste +contents are preserved. Listener cleanup also runs on failed writes. Reattached +interactive applications retain query ownership: startup skips probes there. +No probes occur per render. A new attachment does not reuse old query results. + +The renderer gates Kitty push/pop and mouse modes on capabilities. Keyboard +alternatives (Ctrl+J, Ctrl+W, Ctrl+R, F1, Ctrl+O, focus navigation) remain available. +Synchronized output wraps only one owned redraw and closes in `finally`, never +PTY streams. Suspended rendering cannot repaint over an interactive application +or pop the owned keyboard stack twice during exit. + +Protocol references: [Kitty keyboard detection](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) +and [synchronized output](https://ghostty.org/docs/help/synchronized-output). +Adapter hints are fallback evidence, not physical compatibility certification. +Physical validation remains deferred. + +Existing host configuration is exposed through a passive optional integration +adapter. Core invokes operations only from explicit keyboard/appearance panel +actions. Finding a configuration file for a different terminal no longer enables +those actions. Host-specific guidance and zsh bootstrap terminal hints live under +`src/host/`; architecture tests pin that boundary. No new preference-management +feature is introduced. + +## Additional passive profiles + +| Profile | Kitty keyboard hint | Mouse / movement / clicks | Selection with reporting | OSC 8 / truecolor | Graphics hint | Configuration adapter | +| --- | --- | --- | --- | --- | --- | --- | +| iTerm2 | Probe only | Yes | Host selection override (verify settings) | Yes | iTerm2 inline images | None | +| Kitty | Yes | Yes | Shift | Yes | Kitty | None | +| WezTerm | Probe only (configuration dependent) | Yes | Shift (configurable) | Yes | iTerm2 inline images | None | +| Unknown / nested | No | No | Native | No / explicit color evidence | None | None | + +All synchronized output remains probe-only. A graphics hint never emits image +bytes; rich previews remain research-first. WezTerm supports multiple image +protocols; the single preferred hint is deliberately conservative. Current +iTerm2 documentation also describes Kitty graphics; older installations retain +the established inline-image protocol. No graphics version inference is made. + +Explicit `TERM_PROGRAM` takes precedence over inherited outer-host variables. +Missing program evidence may use `xterm-kitty`, `KITTY_WINDOW_ID`, `WEZTERM_PANE` +or the existing resource hint. Multiplexers and `TERM=dumb` suppress profiles. +Color overrides retain their existing precedence; hyperlink overrides use the +existing `NMSH_HYPERLINKS` setting. No preferences are changed for these hosts. + +Reattach fixtures switch Ghostty → baseline → Kitty → iTerm2 → WezTerm over one +real persistent shell. Each frontend resolves fresh input modes while the shell +retains its original environment. This is protocol coverage, not GUI validation. + +Protocol references: [iTerm2 OSC 8](https://iterm2.com/documentation-escape-codes.html), +[Kitty keyboard](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), +[WezTerm keyboard configuration](https://wezterm.org/config/key-encoding.html), +[WezTerm mouse selection](https://wezterm.org/config/mouse.html), and +[WezTerm graphics features](https://wezterm.org/features.html). + +## OSC 8 presentation + +`HyperlinkPresenter` recognizes targets on bounded source lines and returns +cell copies; `TranscriptPresenter` uses them only when the current attachment's +`TerminalHost.capabilities.hyperlinks` is true. The parser stores original +program-emitted link metadata separately from SGR. NMSh-generated payloads never +enter parser cells, transcript snapshots, journal events, command history or +`/copy`. Wrapped rows close links at each boundary and reopen on continuation; +sticky truncation closes before its ellipsis. OSC scanning cannot consume +multiple adjacent sequences as one string. + +Generated targets allow only HTTP, HTTPS and local file URLs. URL parsing rejects +credentials, terminal controls and remote file authorities. File URI encoding +comes from `pathToFileURL`. Paths must be explicit whitespace-delimited tokens, +exist at recognition time and have a recorded owning command cwd. Ambiguous +paths with spaces, stack-trace locations and shell expansions remain plain. +An explicit GitHub origin in that cwd's repository enables numeric references; +there is no global repository assumption. `/issues/N` also resolves PR numbers. +Repository discovery supports local worktree gitdir/commondir pointers without +running shell commands and reads at most 64 KiB of config across 16 ancestors. + +Recognition uses a weak cache keyed by source line, parser revision and owning +cwd, so unchanged history does not repeat token recognition or filesystem work. +Each line is limited to 8192 columns/code units and 16 candidate tokens; the +repository cache retains at most 128 cwd entries. Missing paths stay plain for +that cached line; later filesystem changes do not retroactively refresh it. +Filesystem checks are synchronous and bounded in count, but mounted filesystem +latency is outside NMSh's control. Original links retain their own target and ID; +recognized text overlapping them is never given a competing generated target. +Program payloads containing controls or exceeding 4096 characters are dropped; +oversized unfinished OSC strings are drained without unbounded retention. +Physical click/selection behavior remains pending in the additive QA checklist. diff --git a/docs/architecture/tui-primitives-v012.md b/docs/architecture/tui-primitives-v012.md new file mode 100644 index 00000000..e84d851c --- /dev/null +++ b/docs/architecture/tui-primitives-v012.md @@ -0,0 +1,17 @@ +# TUI primitives — #180 findings (current primary sources, 2026-10-02) + +| System | Reusable concept | NMSh application / conflict | +| --- | --- | --- | +| [Bubble Tea](https://github.com/charmbracelet/bubbletea) | Model/update/view with messages and commands; async results return as events. | Retain explicit event transitions and cancellation. Shell output is already an authoritative stream, not a view model to regenerate. | +| [Lip Gloss](https://github.com/charmbracelet/lipgloss) | Separate styling, alignment, border and layout composition. | Existing Chroma + Surface + ScreenPlan separation fits. Measure display cells, not string length. Avoid a CSS-like theme language. | +| [Bubbles](https://github.com/charmbracelet/bubbles) | Reusable text input, list, viewport, spinner and progress components. | Focus ownership and bounded viewport are useful; existing NMSh editor, pickers and #91 progress remain authoritative. | +| [Huh](https://github.com/charmbracelet/huh) | Form grouping, validation and accessible mode. | Settings v2 should keep text changed/reset cues and keyboard focus independent of decorative color. No form-framework import. | +| [Glamour](https://github.com/charmbracelet/glamour) | Width-aware themed markdown rendering. | Appropriate for authored help/panels, never arbitrary PTY output or captured provider ANSI. | +| [OpenTUI renderer](https://github.com/anomalyco/opentui/blob/main/packages/web/src/content/docs/core-concepts/renderer.mdx) | One renderer owns scheduling and terminal lifecycle; renderables consume its context. | Unify existing activity/task animation ownership; do not import native renderer, input parser or scrollback ownership. | +| [Ink](https://github.com/vadimdemedes/ink) | React composition, layout and explicit focus hooks. | Borrow focus semantics. Repository dependency presence does not justify migrating the custom PTY/row-diff renderer. | +| [Ratatui rendering](https://ratatui.rs/concepts/rendering/) | Buffer-based immediate rendering and diffed terminal output. | Preserve stable projected rows and row diff; decorative frames can reuse cached underlying rows. No Rust rewrite or second cell renderer. | +| [Textual workers](https://textual.textualize.io/guide/workers/) | Async worker lifecycle, cancellation/exclusivity and message-driven UI updates. | Use generation/cancellation guards and replace-active effect policy; no Python runtime or reactive DOM adoption. | + +Architecture decision (inference): the missing primitive is a demand-driven shared presentation clock, because TerminalApp and TaskProgress currently create independent 100ms intervals. It belongs in the focused effects child of #78 and directly supports #180; a duplicate clock issue would manufacture work. Pure Motion and Chroma samplers remain independent of it. Effects subscribe only while active and cancel at ownership boundaries. Existing actions/forms/focus and ScreenPlan need no new abstraction for this milestone. + +Avoid framework complexity from implicit focus, framework-specific geometry, per-widget clocks, recomputing the entire transcript per decorative frame, and coupling async task state to rendering. Frameworks often assume they own the screen and input; NMSh must hand those to real fullscreen applications and faithfully retain raw shell output. Accessibility remains explicit static/text equivalents and existing keyboard controls; color/movement carry no essential meaning. No wholesale migration and no new dependencies. diff --git a/docs/architecture/v013-linux-foundations.md b/docs/architecture/v013-linux-foundations.md new file mode 100644 index 00000000..2b197a33 --- /dev/null +++ b/docs/architecture/v013-linux-foundations.md @@ -0,0 +1,123 @@ +# Linux platform foundations + +Refs #18, implementation #279, hardening #281. Frozen base #278: +`cfc9c08a5fb7b5f3bc637400a59da7a714a58ae4`. No release or physical support claim. + +## Architecture and PTY + +The current persistent real-zsh architecture runs on POSIX PTYs. The installed +[node-pty v1.1.0](https://github.com/microsoft/node-pty/tree/v1.1.0) uses its Unix +implementation for both Linux and macOS. Its shipped prebuild directories cover +macOS and Windows; Ubuntu builds the Linux native binding during installation. +CI installs zsh, Python 3 and C++/make prerequisites before `npm ci`. + +There is no new shell backend, operating-system god object or command-by-command +spawn path. ShellSession retains authenticated lifecycle markers, real zsh +state, raw output and resize/interrupt semantics. Detached helpers retain their +isolated process groups. Session service ownership remains independent of the +frontend; Unix sockets, journaling, detach/reattach and recovery stay unchanged. + +`resolveZsh` is a narrow executable-discovery boundary. It preserves system zsh +preference (`/bin/zsh`, `/usr/bin/zsh`) and accepts executable symlinks in absolute +PATH directories. Missing/non-executable zsh produces a factual requirement; +bash is never substituted. Managed bootstrap, semantic helper, configured +helper and `/zsh` handoff use this resolver. Native fallback retains its existing +PATH-based zsh invocation and conservative failure behavior. + +## Linux runtime evidence and a runner-specific difference + +The Ubuntu 24.04 runner initially blocked before NMSh startup: global zsh +completion printed an insecure-directory confirmation prompt. That prompt then +consumed command input, causing missing characters and failed completion. A +bounded raw-byte capture from an empty fixture HOME identified the prompt. +CI now audits completion security, repairs only reported system zsh completion +paths in its disposable runner, and re-audits. Product compaudit checks are +never disabled. Ordinary installations should also resolve unsafe system/user +completion permissions; NMSh does not bypass interactive startup configuration. + +Slower Linux bootstrap exposed a frontend lifecycle race: a command submitted +before initial shell readiness was incorrectly completed by the initial prompt. +The frontend now retains that queued command until its execution/completion +markers arrive, including an unacknowledged submission restored on reattach. +Deterministic protocol regressions cover both orderings. +Live fixtures await journal completion rather than echoed command text; gated +detach fixtures also wait for the service execution marker before detaching. History +latency tests run separately from concurrent PTY fixtures without relaxing their +regression budget. + +Another Linux fixture difference was pipe flushing: immediate `process.exit` +truncated noisy output. Finite/noisy fixtures now set exitCode and drain output. +PTY read boundaries also differ: after the backlog cap, the existing policy +may discard the final output chunk. Fixtures require complete output within +limits, bounded retention/factual truncation beyond them, preserved lifecycle +and successful commands after reattach. They no longer assume macOS chunk +boundaries guarantee a retained tail. These are environment/fixture fixes, +not a divergent Linux PTY implementation. +Actual canonical runtime results are recorded in the cumulative acceptance note. + +## Paths and trust + +- Linux configuration: absolute XDG_CONFIG_HOME/nmsh, otherwise ~/.config/nmsh. + Relative XDG values are ignored. Missing/relative HOME uses the OS home lookup. +- macOS retains ~/Library/Application Support/notMyShell and existing absolute + XDG override behavior. No existing user data is relocated. +- Journals remain `sessions/` inside the existing configuration directory. +- Linux sockets prefer an existing absolute, uid-owned private XDG_RUNTIME_DIR + plus `nmsh/`, only if the socket stays within a conservative 100-byte budget. + Invalid, symlink, shared or long XDG roots fall back to os.tmpdir()/nmsh-uid. +- NMSH_RUNTIME_DIR remains the explicit override. Runtime creation still rejects + symlinks, foreign ownership and group/other permissions. An excessively long + explicit/TMPDIR socket path can fall back factually to in-process operation; + use a short private runtime override when persistent attachment is required. +- Bootstrap HOME is quoted literally. Empty HOME never permits configured + completion to source cwd startup files. Tests cover Unicode, spaces, quotes, + dollar signs, symlinks, private/relative runtime roots and absent HOME. + +[XDG rules](https://specifications.freedesktop.org/basedir/latest/) inform the +Linux runtime and absolute-configuration choices. No XDG state/cache migration +is required for this milestone. + +## Optional platform features and audit classification + +| Classification | Source areas | Result | +| --- | --- | --- | +| A: portable as-is | PTY/session protocol, renderer/composer, history/transcript, POSIX process groups/signals, helper temp roots, capability resolution | Reuse existing interfaces and real runtime tests | +| B: portable correction | zsh discovery, HOME quoting, missing-HOME trust boundary, absolute config fallback, Linux runtime choice, inaccurate shell diagnostics | Narrow changes plus deterministic/real-shell regressions | +| C: optional no-op/manual fallback | osascript notifications, Terminal.app windows, brew suggestions, macOS Ghostty configuration | Linux does not require these integrations to start | +| D: unsupported | Native Windows zsh/process/security transport; arbitrary host window launchers | No support promise; manual attach commands for unsupported window launch | + +`darwin` hits in notification/host/config/provider adapters are intentional. +`osascript` is confined to macOS notification and window adapters. `/Users` +occurrences are sample fixture paths, not runtime roots. `/private/tmp` has no +core runtime dependency. `/bin/zsh` is an executable preference/shebang or +helper fallback, not a macOS-only API. Unix mode/uid/group-signal code is an +explicit POSIX foundation; it is not claimed portable to native Windows. +Terminal.app identity remains a host fixture, not a core renderer dependency. + +Linux notifications are a no-op. `notify-send` would need optional executable +and desktop-session delivery policy; parity is not necessary for a runnable CLI +baseline and no dependency was added. macOS notification tests remain intact. +Known Kitty/WezTerm profiles are reused. GNOME Terminal, Konsole, Alacritty and +unknown hosts use conservative baseline plus shared capability evidence. +First later physical target: Kitty, followed by GNOME Terminal baseline. + +## CI, diagnostics and packaging + +The single workflow has macOS/Ubuntu 24.04 × Node 22/26. Feature pushes do not +create duplicate runs; all stacked PRs receive the same gates. Matrix fail-fast +is disabled so one platform failure does not erase the other evidence. Existing +optional-tool skips remain; no full runtime coverage is replaced with compile +checks. `/status` reports OS/architecture/Node and existing host capabilities, +without environment dumps or telemetry. + +For now use a reviewed source checkout: install Node >=22, zsh and native build +tools, then `npm ci`, `npm run build`, `npm link`. Package `private: true` means +an npm registry publication is not currently available. A future release may +use an npm/global CLI package after intentional packaging review. Homebrew +Linux/deb/rpm/AUR create additional maintenance; AppImage/Flatpak/Snap are poor +first choices for this terminal frontend. No installer child or distro package +system is justified in v0.13. + +The first portability pass is small (days plus CI/physical QA), not a backend +rewrite. Physical Linux/macOS and candidate WSL checks are additive in +[the deferred QA checklist](../testing/v013-physical-qa.md). diff --git a/docs/architecture/v013-portability-plan.md b/docs/architecture/v013-portability-plan.md new file mode 100644 index 00000000..cc4c7572 --- /dev/null +++ b/docs/architecture/v013-portability-plan.md @@ -0,0 +1,42 @@ +# v0.13 portability work plan + +Frozen base: #278 at cfc9c08a5fb7b5f3bc637400a59da7a714a58ae4. +Milestone: #13. Research parents: #18 and #19. Linux child: #279. + +## Constraints + +Keep persistent real zsh, isolated semantic/completion helpers, raw PTY bytes, +capability-driven frontend and macOS behavior. Version remains 0.7.0. Every PR +stacks on its predecessor, with no merges/releases/tags. Physical QA is deferred. + +## Ordered deliverables + +1. Audit current sources and dependency implementation; document #18 findings. +2. Linux baseline (#279): resolve executable zsh consistently before allocating + bootstrap resources; select a verified private XDG runtime directory on Linux + with short-socket fallback; retain macOS locations. Tests exercise missing zsh, + spaces/Unicode/symlinks, runtime privacy, host capabilities and existing real + PTY/service/frontend lifecycles. Notifications remain optional no-op on Linux. +3. Extend the single canonical workflow to Ubuntu 24.04 and macOS, Node 22/26. + Install zsh and native build prerequisites on Linux. Keep optional-tool skips. + Typecheck benchmark scripts and retain process/temp-root leak checks. +4. Use actual Linux CI failures to fix narrowly scoped portability defects in + this new branch. No lower branch is rewritten. Full macOS suite is required. +5. Research native Windows/ConPTY and WSL using current dependency and official + documentation plus #17 findings. No additional shell backend implementation. +6. Audit cumulative platform hits, packaging and timing; fix proven leaks in a + separate hardening PR only if justified. Final acceptance/docs PR records + actual evidence and additive docs/testing/v013-physical-qa.md. + +## Verification + +Test changes before implementation where practical; run focused tests, build, +typecheck, full suite and diff check for every implementation PR. Linux runtime +proof comes from hosted Linux canonical tests, never a simulated platform flag. +Inspect disk before full suites and periodically: stop heavy work only below +1 GiB. Preserve .serena and pre-existing processes/temp directories. + +## Review focus + +HOME quoting and missing HOME; non-executable or absent zsh; unsafe/relative XDG +paths and socket length; genuine Linux job control; platform-specific fixtures. diff --git a/docs/architecture/v013-windows-feasibility.md b/docs/architecture/v013-windows-feasibility.md new file mode 100644 index 00000000..36ef709b --- /dev/null +++ b/docs/architecture/v013-windows-feasibility.md @@ -0,0 +1,83 @@ +# Windows, ConPTY and WSL feasibility + +Refs #19 and [ShellAdapter research](shell-adapter-v011-research.md). Native +Windows is not supported or promised. This is a source-based recommendation; +no Windows or WSL runtime was tested in this session. + +## Dependency versus product + +The installed node-pty **1.1.0** includes `windowsTerminal.ts`, +`windowsPtyAgent.ts`, Windows prebuilds and ConPTY bindings. Its API supports +resize and Unicode transport. That establishes a PTY transport option, not an +NMSh shell backend. The installed WindowsTerminal explicitly rejects a signal +argument to `kill`; WindowsPtyAgent closes the pseudoconsole and enumerates +console processes for cleanup. Current upstream differs from the pinned +version, so its removal of legacy winpty must not be attributed to 1.1.0. +See [node-pty v1.1.0](https://github.com/microsoft/node-pty/tree/v1.1.0). + +[ConPTY session creation](https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session) +requires pipes, process startup attributes and explicit resource ownership. +Resize is a pseudoconsole operation. Its stream uses UTF-8 terminal sequences; +this does not recreate POSIX foreground groups or suspend/resume semantics. + +## Concrete NMSh blockers + +| Boundary | Current implementation | Native Windows work required | +| --- | --- | --- | +| Shell lifecycle | zsh preexec/precmd, ZDOTDIR, add-zsh-hook, OSC cwd/status markers | A real backend supplying trustworthy lifecycle events and bootstrap suppression | +| Completion | zsh/zpty, compinit/compdef, hidden ZLE capture | Another shell-specific completion engine; preserve editor ownership | +| Semantics/history | zsh syntax, whence, parameter tables and history records | Backend-specific classification and history privacy rules | +| Signals | Ctrl+C/Ctrl+Z bytes, SIGTSTP/SIGCONT/SIGHUP, negative process-group kills | Explicit Windows interrupt, shutdown and descendant ownership semantics | +| Session transport | Unix sockets, uid/private 0700 runtime checks, exclusive locks | Named-pipe transport and ACL validation; Unix-socket API availability alone does not satisfy current security checks | +| Filesystem | POSIX paths, executable modes, HOME, colon PATH, shell quoting | Drive/UNC paths, case-insensitive environment, PATHEXT, ACLs, argument quoting and path translation | +| Helpers | Detached POSIX process groups, zpty cleanup | Separate process-tree ownership with leak evidence | +| Persistence | POSIX permissions and atomic journal/lock operations | Validate Windows locking, rename/link behavior, ACL and crash recovery | + +[Node child-process documentation](https://nodejs.org/api/child_process.html) +explains Windows environment case folding and distinct detached-process +behavior. NMSh's `process.kill(-pid)` is not a Windows process-tree primitive. +[Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects) +can manage process groups on Windows, but designing ownership alongside +ConPTY and surviving session-service detachment needs a dedicated experiment. + +CRLF handling, UTF-8 boundaries and ANSI/alternate-screen transport could reuse +parts of the renderer/parser; that is an inference, not runtime proof. Windows +Terminal capability detection must remain conservative and probe-based. A host +name never supplies missing shell hooks or POSIX job control. + +## Shell choices + +| Route | Semantics retained | Recommendation | +| --- | --- | --- | +| Native Windows + PowerShell | PTY transport, editor and much presentation; zsh lifecycle, syntax, completion and job control do not transfer | No-go now; feasible only after substantial ShellAdapter and process/transport work | +| Native Windows + POSIX-like shell | Potential zsh hooks within MSYS/Cygwin, but path translation and process/PTY boundaries differ | Research only; no clean supported zsh distribution/runtime proven | +| WSL + Linux Node + zsh | Linux PTY, hooks, completion, sockets, uid and persistent service remain inside Linux | Practical first Windows-user route, contingent on Linux baseline and later WSL validation | +| No Windows route | Existing macOS/Linux focus preserved | Correct native-Windows support policy for v0.13 | + +Native support is a multi-week backend and platform project with uncertain +integration cost, not a flag change. No implementation children are justified +by this research. ShellAdapter #17 remains future architecture work. + +## WSL boundary + +Run the **Linux** Node/npm/zsh/NMSh build inside a WSL distribution. Do not +launch Windows Node against Linux files or treat `wsl.exe zsh` as a drop-in +native backend: signals, paths and service ownership cross that boundary. +Windows Terminal is the outer host; NMSh's PTY/session service stays Linux-side. + +[Microsoft filesystem guidance](https://learn.microsoft.com/en-us/windows/wsl/filesystems) +recommends keeping Linux-tool projects in the Linux filesystem. Put runtime +sockets and journals there, not on `/mnt/c` with uncertain permission semantics. +XDG runtime can be unavailable in non-desktop WSL sessions; private temp fallback +is intentional. Reconnect must target the same distribution and uid. Distro +shutdown/reboot ends live shells; journal recovery is not shell-state recovery. +Clipboard/URL bridges are not required for this milestone. No WSL-specific code +or native Windows promise is added. + +## Go/no-go + +**No-go for native Windows in v0.13. Prefer WSL as a candidate Linux execution +route, not as validated support.** Native Windows becomes worth reconsidering +only after a second concrete shell backend and explicit process/security +transport proofs. Later WSL checks belong in the additive physical QA document; +Ubuntu CI proves Linux, not WSL or Windows Terminal. diff --git a/docs/architecture/welcome-adapters.md b/docs/architecture/welcome-adapters.md new file mode 100644 index 00000000..0ae3bb3b --- /dev/null +++ b/docs/architecture/welcome-adapters.md @@ -0,0 +1,21 @@ +# Optional external welcome adapters + +Vespyr remains the Native default. Settings → Welcome selects a curated external +fetch adapter or None. Selection is opt-in; detection never changes it. Captures +are argv-only, bounded to 2.5 seconds and 128 KiB, sanitized into an SGR-only +screen, and stored as presentation rather than shell commands. Missing/failing +adapters fall back to Vespyr with a factual notice. No managed shell state changes. +NO_COLOR strips captured styling; narrow widths hide the capture. + +The registry uses the existing provider framework, not a second integration +system. Fastfetch is the primary external choice. Neofetch remains archived/legacy +with no install recipe. [Macchina](https://github.com/Macchina-CLI/macchina) +explicitly declares maintenance mode; it is an installed-only option, not a +recommendation. [Zigfetch](https://github.com/utox39/zigfetch) is unarchived and +its upstream was active on 2026-09-20 when audited on 2026-10-01. Its upstream +documents macOS caveats. It remains an installed-only optional adapter with no +installation recommendation. These metadata claims are not compatibility tests. + +Arbitrary custom commands are deferred. No discovered executable becomes a +welcome command automatically. A future custom adapter must require explicit +argv configuration and keep the same capture limits and sanitation boundary. diff --git a/docs/design/chroma-treatments-v012.md b/docs/design/chroma-treatments-v012.md new file mode 100644 index 00000000..f679e0a5 --- /dev/null +++ b/docs/design/chroma-treatments-v012.md @@ -0,0 +1,27 @@ +# v0.12 presentation design and dependency plan + +Frozen base: PR #271 at `4a5ef78968c6e4751ac977b39170514a4ec9e282`. All changes are additive, unmerged, and unreleased; package version remains 0.7.0. Physical QA is deferred. + +## Architecture findings for #78 + +Color/preset, geometry, and motion are independent axes. Reuse Chroma color references and interpolation, Motion sampling, and the existing capability downgrade. A treatment samples semantic cells against explicit time; it owns no timer. Theme, Lavender, Aurora, and bounded custom stops are sufficient initially. Geometry is linear, center-out, or outside-in; motion is static, travel, or breathe. Intensity blends against the surface's ordinary foreground. + +Eligible roles are native prompt identity, divider, panel frame, and effect canvas. Status/error/warning, selection/focus, command source, raw PTY, external prompt and welcome captures are excluded. Historical chrome stays static, so animation never invalidates the transcript cache. Native prompt identity initially integrates into unfilled Minimal/Outline module text; filled prompt geometry retains its contrast rules. Panel framing and live separator prove other consumers. Defaults retain ordinary static presentation. + +Reduced Motion retains a static palette and suppresses transient effects. Effects Off suppresses decorative motion and transients, retaining ordinary/static presentation. Deterministic callers can supply explicit timestamps; normal deterministic sessions do not schedule decorative frames. Existing NMSH_REDUCED_MOTION and NMSH_DETERMINISTIC precedence remains. + +TerminalApp and TaskProgress currently own separate 100ms intervals; unify them through one demand-driven presentation clock with subscriptions. Pure primitives consume timestamps. Dispose subscriptions at completion, frontend stop, suspension, or terminal handoff. No subscription means no timer. Welcome's occasional blink is separate low-frequency scheduling and should use the same clock when active. + +Persist declarative presentation controls as an optional additive settings group; absent values preserve old configuration. Validate 2–8 hex stops; no executable expressions or persisted particles. Preserve provider, theme, module, and layout configuration. Advanced controls expose geometry, motion, intensity, and custom config; Simple exposes preset and accessibility controls. + +Transient v1: seeded sparkles and rain, user-triggered through /effects, replacing any active effect. Restrict overlays to owned blank gap/separator regions; never cover meaningful content or raw output. Three seconds, at most 64 particles and 10 FPS. Escape, resize, passthrough/fullscreen, suspend, detach, stop cancel. Rebuild the underlying row projection after completion; terminal modes are untouched. + +## Implementation plan + +- [x] Treatment child: src/chroma/treatment.ts pure cell sampler, validated settings, representative Native Minimal/Outline identity modules, static historical divider and Settings panel frame. Tests cover interpolation, widths, geometry, capability, semantic exclusions, configuration, and all layouts. Canonical verification; push focused PR based on #271. +- [x] Effects child: shared src/motion/PresentationClock.ts for existing activity/task consumers and effects; src/motion/effects.ts bounded seeded state; internal /effects UX. Tests cover ownership and lifecycle; canonical verification; push PR onto treatment branch. +- [x] Research #179/#180 using current primary documentation. Record product boundaries and framework concepts; only create a further primitive child if genuinely missing beyond the clock already needed by effects. +- [x] Cumulative hardening: audit timers, restore/resize/passthrough, semantic and persistence boundaries, settings, rendering cost and widths. Fix concrete issues in a separate PR when necessary. +- [x] Acceptance: additive physical QA, findings, performance and automated evidence; full cumulative verification and Node 22/26 CI. Final docs PR targets immediate predecessor; no merges/tags/releases. + +Review focus: malformed config must not reset theme; grapheme width must survive treatment; historical rows must not animate; effects cannot hide focus or shell output; idle clocks must have zero wakeups. diff --git a/docs/design/command-intelligence-continuation.md b/docs/design/command-intelligence-continuation.md new file mode 100644 index 00000000..a375d7cc --- /dev/null +++ b/docs/design/command-intelligence-continuation.md @@ -0,0 +1,191 @@ +# v0.8 continuation acceptance — integration and physical QA pending + +This additive continuation starts at PR #241, verified on GitHub as +`2da4d025ff0a352a90e7594c7dc1083d6c5e0fe5`. All ten supplied lower-stack heads +matched at session start. Those branches were left unchanged. Nothing here is +merged or released; package version remains **0.7.0**. Neither the original +stack nor this continuation has passed physical terminal QA in this session. + +The [original design](command-intelligence.md) and +[physical QA checklist](../qa/v0.8.0-physical-qa.md) still apply. This document +covers only the continuation. + +## Scope and review order + +| Order | Implementation | Issue | PR / branch | Implementation head | +| --- | --- | --- | --- | --- | +| 1 | Local command inspector | #242, research #84 | #244 `feature/v08-command-inspector` | `f63182b` | +| 2 | Interactive block actions | #243, research #85 | #245 `feature/v08-block-actions` | `83d8b07` | +| 3 | Live completion notifications | #106 | #246 `feature/v08-command-notifications` | `7919fdf` | +| 4 | Test temp lifecycle | #208 | #247 `test/208-tmpdir-hygiene` | `573ca03` | +| 5 | Inspector cursor-role hardening | #242 | #248 `fix/v08-continuation-hardening` | `8e61137` | +| 6 | Architecture and additive QA snapshot | — | #249 `docs/v08-continuation-acceptance` | `0de4805` | +| 7 | Fixture writer ownership barrier | #208 | #250 `test/208-fixture-writers` | `b1ab55e` | +| 8 | Final acceptance evidence | — | `docs/v08-continuation-final-verification` | See PR head | + +Each PR targets its immediate predecessor branch; #244 targets #241's branch. +Review/integrate in this order, after the original stack. Keep all branches +unmerged for the planned combined QA. Do not close implementation issues based +on automated tests. Research #84/#85 remain research; only child #242/#243 and +implemented #106 were added to milestone #8. #208 remains test hygiene outside +the product milestone. Project updates could not be made because the token +lacks `read:project`; active issues/PRs are the durable work record. + +Research findings: +[#84 comment](https://github.com/raiseCatError/notMyShell/issues/84#issuecomment-5924074969), +[#85 comment](https://github.com/raiseCatError/notMyShell/issues/85#issuecomment-5924075196). + +## Inspector architecture + +`CommandKnowledge` shares explicit local facts with completion enrichment. +Native candidate descriptions have priority only when their buffer, cwd, +replacement bounds and value match the inspected token. Inspection reuses +`Highlighter` and the editor's grapheme cursor, converting offsets to UTF-16 +for candidate provenance. It does not evaluate shell syntax or submit text. + +The initial table covers conventional git/npm command and subcommand names +and a small set of rg flags. Known facts include optional usage/value hints. +Other tokens show their context and **No local description available**; +unknown argument words are not guessed to be subcommands. `--` ends flag +interpretation. Quoted/path words retain command context; descriptions for quoted/dynamic +contexts and wrappers are deliberately +conservative; this is lexical context, not a complete zsh AST or alias/function +expansion. Conventional-name facts are not installed-version documentation. + +Use F1 or Ctrl+Shift+P, search **Toggle command inspector**, and press Enter. +The session-local toggle survives temporary hiding. `ScreenPlan` allocates +one or two bounded rows above Bottom, below Top and before the Flow composer +in the scrolling document. Chat changes transcript presentation only. +Execution, panel takeover, slash input and paste atoms hide the inspector. +Small terminal heights can suppress its region; widths below 32 use one line. +Plain truncated text is deterministic and works with Safe glyphs/NO_COLOR. + +Safe-source research considered existing candidates, shared authored facts, +local man/whatis and explicitly supported help adapters, in that order. Only +the first two are implemented. Bounded asynchronous extraction/caching and +platform ambiguity need separate work before man/help sources are added. +There is no new parser ecosystem, documentation browser, program invocation +or network knowledge lookup in the inspector. + +## Block action architecture + +`BlockActions` is the shared registry consumed by pointer-opened and +keyboard-opened palettes. Actions are copy command, copy output, copy both, +rerun, edit & rerun and fold/unfold. Shift+Tab traverses completed block focus; +Enter opens its actions. The global palette includes the focused/latest +block's actions. Typing clears block focus; Escape closes the palette, and +Escape at the composer clears focus. + +Passive hover adds a right-aligned `[Actions]` opener on an owning visible +command/output row only if it fits without covering text. Clicking opens the +same keyboard palette; it does not immediately run a shell command. At narrow +widths the opener disappears and keyboard access remains. Structural +`blockStartId` from the transcript presenter determines ownership, including +Chat output rows. Historical headers and sticky overlays retain their +existing interactions. + +Copy reads authoritative `CompletedCommand.command/output`, including folded +output. Copy both excludes lifecycle/presentation chrome; existing `/copy` +semantics (output plus plain lifecycle) are unchanged. Fold uses the same +record state as Ctrl+O. Rerun explicitly uses normal visible submission in +**the current shell cwd**, not the historical cwd. Edit replaces the composer +with stored command text and waits for a separate Enter. Actions apply only +to completed records while idle. Clearing a transcript invalidates its actions. + +Shift mouse reports remain discarded before hit-testing: Shift click/drag +cannot reveal, focus or dispatch controls. Raw shell output, journal content +and stored rows are unchanged. Historical inspector browsing, bookmarks/pins, +filters, annotations and extra navigation are deferred. + +## Notification architecture + +The five Config settings default to On, 60 seconds, success On, failure On, +focused Suppress. Threshold is stored as seconds (1–86400), normalized and +preserved when custom; the UI steps through presets. Settings are read at +completion time. The command palette can target the same Config rows. + +Terminal focus reporting (`CSI ? 1004 h`, `ESC [ I`, `ESC [ O`) is owned by the +renderer with its input modes. Reports are consumed before panels/editor. +Unknown focus permits delivery; definitely focused suppresses by default. +Ownership handoffs invalidate focus to unknown, disable reporting and restore +it on resume. Passthrough input remains owned by the foreground app. Exit, +external picker/wizard handoff and genuine SIGTSTP/SIGCONT suspension restore +terminal modes; Ctrl+Z still forwards shell job control rather than suspending +the frontend. External picker/wizard terminal ownership remains independent. + +Only `onShellPrompt` completing a live `running` command delivers. Replay, +restored journals, redraw, resize, sticky headers and slash commands do not. +Clearing the running state prevents repeat prompt markers from notifying twice. +Interrupted commands count as failure. The macOS backend reuses the preserved +#107 argv-only `/usr/bin/osascript` implementation, with a fixed script, +`shell: false`, ignored stdin/stdout, bounded stderr and a 10s timeout. Delivery +errors are silent and cannot prevent shell completion; other platforms no-op. + +Notification content is generic outcome/duration/exit status, with neither +command text nor output. Nothing is written to transcript, `/copy` or journal. +No sounds, icons, history, click actions, excerpts or per-command rules. +Native macOS sender identity/permissions and actual visible delivery remain +unverified: osascript exit 0 does not prove Notification Center presentation. + +## Test hygiene and verification + +Direct TerminalApp tests already had teardown; current leakage was reproduced +from intentional SIGKILL fixtures sharing host TMPDIR. `LiveSandbox` now owns +its nested TMPDIR and awaits tracked frontend exits, including external PTYs. +Completed wait-exit timers are cleared. #250 adds a test-only ownership barrier +using the suite's existing lsof tool: no process may hold the sandbox subtree +(including cwd references) when removal starts. This covers mux frontends +finishing journal writes after the outer PTY exits. A regression starts an +untracked late writer and proves it exits successfully before directory +removal. The barrier also exposed a background sleep left by the live-status +fixture; that fixture now records and kills its own background PID. Production +lifecycle is unchanged. +Normal stop/kill cleanup is tested without a sweep; a killed frontend's temp +directory is explicitly observed to remain until fixture disposal. + +`npm test` uses a short private per-run root through `scripts/test.mjs` to keep +Unix socket paths small. Root-level semantic/zsh leftovers fail the run and +are reported **before** cleanup. Only that run's root is removed; pre-existing +host artifacts are untouched. Regression fixtures prove leak detection itself. +Two repeated full suites passed **732/732**, with host artifact count **2079 +before and after**, zero additions and zero private roots remaining. The final +writer-ownership follow-up passes **733/733**. Build, typecheck and diff check +passed. Local full +PTY/socket suites required sandbox escalation; inherited NO_COLOR was unset +for existing ANSI assertion tests. Dedicated NO_COLOR tests remain present. + +CI is explicitly workflow-dispatched because these PR bases are stacked +branches. Node 22 and Node 26 passed on #244, #246, #247 and the #249 acceptance +snapshot. Historical #245 Node 22 attempts failed in existing fixtures: GNU +screen teardown raced a journal write (`ENOTEMPTY`), `/resume` arrived before +the preceding prompt, and idle Ctrl+Z input arrived before completion. These +were not block-action assertion failures. #247 awaits fixture exit and +synchronizes the two input sequences on observed completion. #248 Node 26 +passed, but Node 22 exposed the remaining GNU screen writer race after the +outer PTY had exited. #250 addresses that gap with the ownership barrier and +explicit background-job cleanup; it has its own dispatched matrix. Historical +failed runs are retained and are not described as green. The final acceptance +matrix and current required checks must pass before integration. + +Informational measurements on local Node 26.8.1 / macOS arm64: shared benchmark +completion/filter-500 p95 0.11ms; ScreenPlan p95 0.02ms or less; the existing +long editor fixture p95 9.52ms. A 2000-sample warmed microbenchmark +measured inspector p95 0.014ms and visible-row affordance p95 below 0.001ms. +These are machine-specific observations, not a formal cross-host regression +proof. Inspection performs lexical/local lookups only; action decoration adds +no transcript-wide scan on pointer motion; notification work starts only at +completion. No background watcher or dependency was added. + +## Deferred adjacent work + +#75 still combines alias/function and broad syntax/error parity; it needs a +focused acceptance slice. #153 combines preset storage, launch and environment +policy. Neither was silently expanded here. #150 depends on #10; the current +TerminalHost only exposes window launching, without the required hyperlink +capability seam. No host refactor, fifth feature or new milestone was started. +The user-excluded directions and unrelated PRs were left alone. + +Physical QA remains pending. Use the additive continuation section of the +existing checklist for both Ghostty and Terminal.app. No product decision +blocks review of this stack; notification identity/delivery may need a later +presentation decision after observations are available. diff --git a/docs/design/command-intelligence.md b/docs/design/command-intelligence.md new file mode 100644 index 00000000..c0bf36f7 --- /dev/null +++ b/docs/design/command-intelligence.md @@ -0,0 +1,86 @@ +# v0.8 command intelligence and navigation + +Development scope for [milestone #8](https://github.com/raiseCatError/notMyShell/milestone/8). This is an unmerged review stack, not a release record. Package version remains 0.7.0. Physical terminal validation is pending; use the [single QA checklist](../qa/v0.8.0-physical-qa.md). + +## Authority and dependency order + +The terminal host renders NMSh, which sends explicitly submitted input through SessionClient / nmshd / PTY to persistent real zsh. Completion, history, selection and correction operate in the frontend. Suggestions insert editor text; the user executes it separately. Raw PTY output, `/copy`, session restore and passthrough retain their existing ownership. + +The stack starts at dev `2d0e8f904896af2947d109a42df85b097b8ebc9a`, fetched and checked against GitHub before development. Merge order is #193 → #233 → #234 → #235 → #236 → #237 → #238 → #239 → #240 → acceptance documentation. Each dependent PR targets the preceding branch. Nothing has been merged, tagged or released. + +| Issue | Implemented development scope | +| --- | --- | +| #140 | Source-independent completion candidates and cancellable native capture | +| #141 | Descriptions, inline categories/groups, icons, fuzzy filtering, keyboard selection and ScreenPlan placement | +| #142 | External-source research delivered on the issue; no new external completion provider | +| #143 | Shell-approved command metadata, structured search, background journal projection and local deletion | +| #144 | Explicit optional local Atuin provider; Native default | +| #145 | Shared supplied-candidate picker boundary, Native/fzf/Television adapters and controlled host-terminal handoff | +| #147 | `/dirs`, palette action, native frecency and optional zoxide snapshot queries | +| #152 | Conservative command-not-found executable corrections that edit only | + +All eight issues remain open. #155 is a prerequisite investigation rather than added milestone scope. Its existing #193 branch was updated by merging current dev, preserving published history. Broader startup/socket/storage optimization remains open. + +## Completion + +`CompletionCandidate` carries insertion and display values, description, kind, source, optional group, UTF-16 replacement range and buffer/cwd context. `CompletionSource` supplies this model without UI dependencies; later inspector work can consume the same metadata without being implemented here. + +`NativeCompletionSource` retains the existing isolated native zsh capture helper. Tab is never forwarded into the persistent session PTY, and composer input is never submitted for execution to discover completions. Capture has a 1.5-second timeout and 1 MiB output cap. Parent contexts, including nested path prefixes, are cached for two seconds with 32 entries. Local subsequence filtering ranks exact/prefix matches first. + +CompletionService aborts superseded requests; the app checks generation, buffer, cwd and UI eligibility before accepting results. Rows clear immediately when context changes. Acceptance also checks context before Tab, covering multiple decoded keys before the next render. Arrow keys navigate, Tab inserts and Escape dismisses. + +Completion rows reuse shared actions, glyph selection and Chroma roles. Categories/groups are inline rather than separate nonselectable rows, preserving selection indices and layout geometry. ScreenPlan owns the suggestion region in Bottom, Top and Flow; narrow rows prioritize the label. Native source kinds are inferred from available values and suffixes, not a full CLI schema. + +Quoted/escaped token replacement retains the earlier whitespace-boundary limitation. Configured-zsh completion parity and fzf-tab remain parked in #52. The existing native completion functions are retained; no third-party generator runtime is added. + +## History and privacy + +`/history` searches commands; `/resume` still searches sessions. Search combines plain text with `cwd:`, `project:`, `exit:`, `before:`, `after:`, `session:` and `duration:`. Quotes support spaces in filter values. Invalid known filters match nothing. Enter/Tab restores a result, and a separate Enter executes it. + +New journal records add optional eligibility, start time and duration to the existing schema version 1. zsh preexec reports leading-space and unexported HISTORY_IGNORE eligibility through the authenticated marker, IPC, backlog and replay paths. Native indexing requires affirmative eligibility; older unknown records stay out. Transcripts remain intact independently of command-history policy. Project/cwd/exit/session/time/duration appear only when available. + +HistoryService imports the existing exported HISTFILE or `~/.zsh_history`, parses multiline/metafied zsh data in batches, and reads session journals in a worker. The worker projects eligible command metadata without returning raw output. The index derives from authoritative sources; there is no second persisted command database. Queries yield every 2,048 records and return at most 100 composer results (500 through the index API). Import hashing/indexing yields every 1,024 records. App cancellation prevents stale query rows or deletion of a stale selected result. + +Ctrl+X removes the selected record from NMSh search. Independent empty tombstone files named by SHA-256 IDs live in private `history-deletions.json.d`; concurrent frontends cannot replace each other's IDs. Earlier stack `history-deletions.json` version-1 IDs remain readable and union with tombstones. Corrupt/unreadable deletion metadata fails closed. Original journals and zsh/Atuin history are unchanged. Deletion applies to a record identity: another source or occurrence of the same command can remain. Other already-running frontends observe persisted deletions when they reload. + +Native is the persisted default. Atuin runs only when selected and reads its local list using a bounded formatted CLI call, cached until history reload. Detection/status and fallback use the shared provider gallery. No recording, deletion or sync operation is invoked. Existing user hooks retain their behavior. Atuin's formatted durations are approximate; absent metadata stays unknown. Reading Atuin does not double-record, although imported and journal representations of one command can both appear. + +No mandatory SQLite import or native binding is added. The supported Node floor includes releases before built-in SQLite availability/flag removal, and its synchronous API alone would not solve UI responsiveness. Current measured bounded queries support the derived-index approach; revisit storage/isolation if measurements warrant it. See [benchmarks](../performance-benchmarks.md). + +## Pickers and navigation + +Picker candidates contain an opaque ID, label, description and internal value. Native delegates to the existing composer search surface. fzf and Television receive flattened inert ordinal rows and return one exact known row. Unknown, transformed, oversized or multiple selections fail safely. The adapter never trusts returned text as a command. + +The app hands off the host terminal only while idle, detaches its input listener, releases raw mode and renderer ownership, then restores them in `finally`. The managed shell PTY receives no picker input. Accept, cancel and error restore input/renderer state; resize aborts selection and preserves the draft. Missing/failing providers use Native. Config offers explicit persisted Native/fzf/Television choices without installation. + +fzf default-command/options environment overrides are removed. Television receives temporary config, empty cable, isolated data/cache directories, zero query-history size, no preview and no remote channels. Adapter input is capped at 100k rows / 16 MiB, output at 64 KiB, interaction at five minutes. External tools own their appearance during handoff. Installed fzf transport was tested noninteractively; Television was absent locally and has contract tests, with real-tool QA pending. History supplies at most the newest 100 matching records; Native structured search remains the route across the full history. Bare `/history`, `/dirs` and palette actions open the configured picker; typed query forms keep Native incremental composer search. + +`/dirs` ranks approved history cwd records by frequency and seven-day recency decay, with fuzzy filtering and a snapshot cache. Its palette entry comes from the shared slash-action registry. Selection inserts a literal quoted `cd -- 'path'`; only subsequent Enter sends it through normal shell submission and transcript capture. Plain `cd` is not intercepted. Stale/missing directory paths can fail visibly in zsh; no hidden chdir changes shell state. + +Optional zoxide queries use the user's existing data location. Upstream query sorts/saves even with `--all`, so NMSh copies the bounded db.zo into a private temporary directory and queries only that copy. The original database/hooks remain unchanged. Queries are detached, time/output bounded and cached for 30 seconds; absent, incompatible or failing sources fall back to Native. A real installed zoxide fixture test verifies source-byte preservation. Directory-specific frecency ghost text is deferred; existing native `cd` argument completion remains available. + +## Corrections + +Exit 127 alone is insufficient. A simple unquoted command must have a matching command-not-found diagnostic and exactly one nearby executable in bounded cached absolute frontend PATH directories. One insertion/deletion/transposition is allowed; substitution is allowed only for longer words. Nearby alternatives suppress rather than lower confidence. Known executables returning 127, complex expressions, assignments, quoted/multiline/private input, dangerous targets and short ambiguous input are suppressed. + +Tab edits the empty composer, Escape dismisses, and Enter never accepts/executes the suggestion automatically. Typing cancels background discovery, including ABA buffer changes. The row is transient frontend presentation in ScreenPlan, so it cannot enter `/copy`, transcripts or history. Narrow width, Safe glyph and NO_COLOR paths are tested. + +Live-shell PATH mutations, live aliases/functions, history-based guesses and subcommand/flag correction are deferred. The current snapshots and native capture metadata do not establish sufficient authority for those cases. No subprocess is invoked to inspect an unknown command or discover a correction. + +## External completion research and deferred scope + +Evidence and primary links are recorded on [#142](https://github.com/raiseCatError/notMyShell/issues/142#issuecomment-5919411096); local Atuin findings are on [#144](https://github.com/raiseCatError/notMyShell/issues/144#issuecomment-5919625509). + +Carapace's JSON export includes value/display/description/tag data and is a promising future transport. Its callbacks, bridges and configurations can execute code; a universal static-only safe mode was not established. It was absent locally, so no latency number is claimed. Fig-compatible TypeScript specs are useful formats but module imports/generators are executable; licensing requires per-spec/dependency auditing. Inshellisense exposes structured completion and useful parsing/cache patterns, but its generator execution, terminal architecture and currently declared Node range do not fit direct embedding. No code was copied and no provider follow-up issue was created. + +Future external completion must default to inert audited data, with generators disabled until an explicit bounded execution model is defined. Current timeout/output limits are not a sandbox for arbitrary generators. + +All explicitly parked work remains outside this stack, including #52, inspector #84, installer #9, wider terminal compatibility and #208 TMPDIR hygiene. Unrelated PRs #181, #182, #183, #226 and #227 are untouched. No new issues were created. + +## Verification and acceptance + +Each code layer ran build, typecheck, full tests and diff checks; focused regressions cover keyboard selection, cancellation, narrow/Safe/no-color presentation, journal/IPC privacy, real-zsh hooks, large histories and terminal handoff. Final cumulative local suite: 714 passed. Benchmark script types pass. Existing regression coverage exercises sessions, startup restore, Flow/Chat, passthrough, raw output and copy; this is automated coverage, not physical QA. + +The navigation CI's first Node-22 run failed during existing GNU screen sandbox cleanup with ENOTEMPTY, cancelling Node 26. Investigation and the unchanged-head rerun are recorded on #238; both Node versions passed the rerun. A status viewport test assumption and runtime no-color correction help were corrected after recorded failures. An RTK-filtered final command returned an empty log with exit 1; raw proxy verification passed all 714 tests. The final code and acceptance heads receive workflow_dispatch because stacked targets do not automatically trigger the dev PR workflow. + +Review dependencies in order, integrate only after review and CI gates, then perform the deduplicated physical checklist. Keep milestone issues open until release, including after integration and physical QA. Version/release preparation follows physical QA under separate authorization. diff --git a/docs/design/configured-completion.md b/docs/design/configured-completion.md new file mode 100644 index 00000000..c4c33287 --- /dev/null +++ b/docs/design/configured-completion.md @@ -0,0 +1,54 @@ +# Configured completion bridge + +v0.11 extends the existing CompletionSource and CompletionService. A lazy helper +loads the same trusted home zsh startup files as the managed shell, once per +helper generation. It runs detached, with its own hidden zpty; its terminal bytes +never reach the frontend. A private widget receives BUFFER/CURSOR from private +files and invokes `_main_complete` directly, bypassing foreign Tab widgets. +Only bounded NUL-delimited compadd data is returned. NMSh quotes, renders and +inserts candidates; completion never submits a command to the managed shell. + +The helper is reused, expires after 60 seconds, and restarts on cwd changes, +explicit invalidation, cancellation, timeout or failure. Startup is bounded to +1500 ms; warm requests to 300 ms; output to 1 MiB and 4096 candidates. Unsupported +replacement contexts and failed helpers fall back to the existing native source. +Typing remains asynchronous. Cursor, cwd, buffer and service generation identify +requests. No history is written. Configuration is trusted executable code, not a +sandbox; helper isolation protects terminal ownership and managed-shell state. +Both transports also enforce deadlines inside their zsh parents, so abrupt +frontend death cannot remove the only timeout. Hidden shells are launched with +`exec` through zpty, making the recorded PID the inner process-group leader. +Cancellation kills that group explicitly before removing its private root. +Native capture uses nonblocking reads and printable per-query framing; configured +capture keeps bounded NUL records in private files. Suite-root leak checks include +both completion and native capture roots. +Config may still have external side effects. No new frontend config evaluation +or runtime dependency is introduced. + +Failures enter a five-second configured-provider cooldown. Warm cancellation +uses a one-second cooldown to prevent executable configuration reloads on each +subsequent edit; native fallback remains available. Superseding input +requests share bounded startup and serialize queries; canceled requests cannot +return candidates. A warm-query cancellation ends the helper, while a canceled +startup request leaves the shared initialization available to the newest input. +Explicit disposal ends initialization. Each completed managed-shell +command invalidates the helper. Live definitions added only to the managed shell +are not automatically replicated into the helper in this first bridge version. +Unsupported nested syntax, multiline and control-bearing input falls back; +middle-of-buffer native fallback is suppressed because the legacy helper has +no safe replacement protocol there. Configured candidate expiry is checked +before insertion. The frontend never blocks waiting for the helper. + +Protocol semantics follow the upstream [completion widget documentation](https://zsh.sourceforge.io/Doc/Release/Completion-Widgets.html) +and [zpty lifecycle documentation](https://zsh.sourceforge.io/Doc/Release/Zsh-Modules.html#The-zsh_002fzpty-Module). + +Descriptions, groups and compadd prefix/suffix data are mapped into existing +candidates. Callback-based suffix removal and arbitrary ZLE state changes are +outside v1. Native fzf-tab is unsupported. The existing optional fzf picker may +select structured candidates through its normal terminal handoff. + +Implementation plan: first deterministic bridge/parser and lifecycle fixtures; +then bounded persistent transport; then composer cursor/invalidation integration +and picker selection; finally failure, quoting, Unicode, performance and cumulative +verification. Use the current checkout and one primary implementer. Physical QA +is additive and deferred. Version stays 0.7.0; PR targets #266 and stays unmerged. diff --git a/docs/design/custom-prompt-modules-v011-research.md b/docs/design/custom-prompt-modules-v011-research.md new file mode 100644 index 00000000..8e920706 --- /dev/null +++ b/docs/design/custom-prompt-modules-v011-research.md @@ -0,0 +1,76 @@ +# Custom prompt modules: v0.11 research + +Refs #73. This is a design proposal, not implemented custom-module support. + +## Current boundary and decision + +`src/prompt/configuration.ts` persists a closed `ContextModuleId` union and drops +unknown IDs during normalization. `moduleSegments` in `src/prompt/prompt.ts` +derives built-in values from cached `PromptContext`; `renderedModules` and +`nativePromptSnapshot` already own placement, colors, geometry and historical +presentation. `src/prompt/snapshot.ts` stores rendered data, rather than a recipe +that reruns commands during historical rendering. Extend those paths when the +configuration model is settled; do not introduce a second renderer. + +The smallest useful future implementation is static modules first. Even that +needs a durable custom-ID namespace, migration behavior, validation limits and a +module editing/import UX: the current normalizer intentionally rejects unknown +IDs. Issue #73 explicitly remains research/design until its model is agreed. +v0.11 therefore leaves it research-only. No implementation child was created. +Command-backed modules also need visible failure/cache state and an explicit +execution trust boundary; supporting arbitrary command strings now would bypass +those decisions. This is deferred design work, not a technical impossibility. + +## Proposed versioned model + +A custom entry should have a stable `custom:` ID, display name, enabled state, +left/right placement and a discriminated source: literal text, a cheap known +context value, or an explicitly configured executable plus argv. Formatting +should use the current semantic/identity colors and safe glyph policy. Custom +identity must not overwrite status/error roles. Store the rendered value in the +existing prompt snapshot so resume/history neither launches commands nor changes +the original prompt. Preserve unknown future entries across schema migrations +without executing them. + +Visibility can use cached repository state, cwd/project patterns, bounded marker +file checks, cached executable presence and environment-variable **names**. Do +not show environment values in source inspection or launch commands to decide +whether another command should run. Normalize conditions once on configuration +change, and cache marker/executable results by context generation. + +## Proposed command safety contract + +Command sources require explicit user configuration and argv only. No shell +interpolation or implicit shell-string mode is proposed. Run detached from the +active PTY using the existing bounded provider process facility, after extending +its descendant-cleanup guarantees where necessary. User-selected programs can +still perform arbitrary external side effects; isolation is terminal ownership +and resource containment, not a security sandbox. + +Suggested initial limits, to be confirmed by profiling: timeout 500 ms, stdout +4 KiB, discarded/bounded stderr 4 KiB, one displayed line and 80 display columns, +two concurrent module jobs globally, minimum refresh interval five seconds and +an LRU cap of 64 context entries. Strip CSI, OSC, C0/C1 and other terminal controls +before width truncation. Re-render any future hyperlink through existing safe +primitives; never pass command ANSI through. Use cwd/config generations to +discard stale results, abort obsolete work and settle on disable/stop. + +Rendering reads a cached value or placeholder immediately. A refresh queue +coalesces repeated cwd changes and never launches per frame or per keystroke. +Cache policy must distinguish stale value, pending, failed, last success and next +refresh; failure retains a bounded stale value or hides the entry. Explicit +refresh must obey the same concurrency/timeout limits. + +## Settings and implementation gate + +Extend `/prompt` module rows with enabled state, placement, source kind, cache +age/refresh and a sanitized failure reason. Command argv/source inspection must +support redaction; configuration may contain secrets, so avoid logging argv or +stderr by default. Design the config/import review flow before adding commands. + +A future child should first settle schema/migration and static modules, then +add the asynchronous command scheduler separately. Required tests include +unknown-ID round trips, snapshot fidelity, narrow widths, NO_COLOR/safe glyphs, +control-sequence injection, timeout/output overflow, cancellation, repeated cwd +changes, bounded concurrency, disabled modules and teardown. Physical QA remains +deferred; v0.11 has no custom-module runtime checks to claim. diff --git a/docs/design/native-shell-intelligence.md b/docs/design/native-shell-intelligence.md new file mode 100644 index 00000000..33af7252 --- /dev/null +++ b/docs/design/native-shell-intelligence.md @@ -0,0 +1,42 @@ +# Native shell intelligence + +The managed zsh remains authoritative. Its existing precmd hook snapshots bounded +alias/function names into its private ZDOTDIR, before the normal prompt marker. +ShellSession reads that snapshot as optional prompt metadata; SessionService +retains it through backlog/spool and SocketSessionClient delivers it on replay. +No definitions, environment values or completion body execution enter this data. +The raw PTY data stream and journal output remain unchanged. + +Each metadata update advances the frontend semantic generation, cancels pending +classification and primes alias/function types. Complete snapshots suppress +configured aliases/functions absent from live state, resolving any unshadowed +builtin/executable instead. Truncated or filtered snapshots explicitly say +`partial`; they provide positive names without asserting that omitted names were +removed. Names use a bounded safe letter/number alphabet, including Unicode; +unusual names and names longer than 128 code points remain a limitation. +Cached live names remain available if the classifier helper dies. +Names also supplement command-position completion and the existing inspector. +The service retains its latest snapshot independently of journal acknowledgements +and includes it on reattach, including while a command is running. No synthetic +prompt or extra transcript event is needed to restore that knowledge. +This does not replicate live-only function bodies or compdef definitions into the +configured completion helper. Shell environment/PATH replication remains deferred. +Older session services without metadata retain configured helper classification. + +The lexical layer keeps existing token/presentation roles. It recognizes compound +reserved-word command boundaries, leading redirection targets, background +operators, common balanced parameter/command/arithmetic expansions, backticks, +conditional delimiters, quoting, escapes and glob arguments. Expansion scanning +is capped at 4096 graphemes and depth 16; unsupported/incomplete forms degrade to +the existing lexical path. It is not a replacement zsh parser. Complex wrappers, +heredoc bodies and every shell grammar production are outside this pragmatic layer. + +Failure presentation relabels the existing lifecycle row only when status and +matching zsh diagnostic establish command-not-found or syntax-error evidence. +Exit 127 alone is insufficient. Raw diagnostics, copy, journal and correction +suggestions stay in their current paths; no second error panel is introduced. + +Verification plan: live definitions/removal and name-only privacy fixtures, +service protocol/backlog replay, semantic stale generations, completion/inspector +integration, syntax corpus/fallbacks, diagnostic evidence and copy/journal fidelity. +Use canonical build/typecheck/full suite/diff check and additive deferred QA. diff --git a/docs/development/v0.10.0-checkpoint.md b/docs/development/v0.10.0-checkpoint.md new file mode 100644 index 00000000..fd48ef02 --- /dev/null +++ b/docs/development/v0.10.0-checkpoint.md @@ -0,0 +1,80 @@ +# v0.10.0 resource checkpoint + +Milestone #10 remains incomplete and unmerged. Nothing is released; package and +lockfile remain 0.7.0. Do not restart the milestone, merge, retarget published +PRs, or change the frozen predecessors. + +## Review order + +| PR | Branch | Implementation SHA | +| --- | --- | --- | +| #258 | docs/v09-final-acceptance | 02cda5898ef0ff1b5be8184f2e2763f457475af9 | +| #259 | feature/v010-host-capabilities | d845e3384bb80550fbf4f51bc7863de653eaf0ba | +| #260 | feature/v010-baseline-host | a31e24e7ea382d6445f27b728feca152ccb556bc | +| #261 | feature/v010-host-integration | a0b47512d6866fc53cc24776f1b6b3955e4e7849 | +| #13 continuation PR (see issue #13) | feature/v010-host-profiles | 4b8165db77d9edf7a971301d98e9e5b2cd7d8f4a | + +The top branch also contains this documentation checkpoint. Its exact published +head SHA and assigned PR number are recorded in the issue/PR checkpoint comment. +Each PR targets its immediate predecessor. #261 was verified locally, on origin, +and on GitHub as open, non-draft, unmerged and mergeable at session preflight. + +## Completed continuation + +- #13: passive iTerm2, Kitty and WezTerm profiles in the existing host boundary. + Explicit frontend program evidence overrides stale inherited host variables. + Multiplexers retain baseline behavior; configuration adapters are not added. +- Existing bounded startup probe remains authoritative for optional keyboard and + synchronized output. No additional query sequence was introduced. +- Profile fixtures cover contradictory evidence, nested/dumb hosts, explicit + color overrides, mouse modes, passthrough and synchronized-output pairing. +- Real persistent-shell fixture reattaches Ghostty → baseline → Kitty → iTerm2 + → WezTerm, preserving shell exports/environment while changing frontend modes. +- Architecture assertions and additive physical QA documented. Physical host + validation has not occurred; #13 must remain open. + +## Verification + +- Focused host tests: 11/11 passed. After fixture adjustment, host profiles plus + intelligence-hardening isolation: 7/7 passed. +- Full suite: 785/785 passed with `env -u NO_COLOR npm test -- --test-concurrency=4`. + Initial default run inherited NO_COLOR=1 and failed color snapshots. The next + run without that override passed 783/785: the new resize fixture raced async + resize propagation, and an existing native-completion test failed under load. + The fixture now waits for the shell's reported size; the completion test + passed in isolation. Bounded-concurrency full verification then passed. +- Build, typecheck and diff-check passed after the fixture adjustment. +- Benchmark-script typing passed with explicit Node types and `--ignoreConfig` + (TypeScript 7 requires it for a direct file invocation). Earlier invocations + omitted those compiler options and failed; no source changes were needed. +- No matching test/helper processes or private `/private/tmp/nt-*` roots remained + after suite completion. No unrelated processes/data were removed. +- CI Node 22/26: consult the workflow-dispatch run linked on the continuation PR; + dispatch/results are recorded separately and must not be inferred from local + Node 26.8.1 results. +- No new startup benchmark measurements. Prior 0.145 μs capability resolution + and 80–83 ms silent probe measurements are not end-to-end startup timings. + +## Resource stop + +Preflight had 6.3 GiB free. Headroom fluctuated to 3.6 GiB, recovered to 5.3 GiB, +then fell to 3,170,304,000 bytes (2.9526 GiB), below the session's 3 GiB stop +threshold. Implementation stopped under the requested resource checkpoint rule. +Do not run further full suites until healthy headroom is restored. No unrelated +user data was deleted. Existing untracked `.serena/` was left untouched. +GitHub Project access lacked `read:project`; issue activity supplies durable +progress evidence. No Project status update is claimed. + +## Remaining scope and exact next step + +Restore disk headroom; fetch origin and verify the top branch/PR SHA, status, +processes, test temp roots and versions. Review CI results for the #13 head. +Then branch #150 directly from `feature/v010-host-profiles`, not #261 or dev. + +Remaining: #150 presentation-only OSC 8; #151 research and an implementation +child only if justified; #14 deterministic compatibility harness/matrix; #15 +agent-host research and a generic child only if justified; cumulative hardening +and performance checks; one final v0.10 acceptance/docs PR; final cumulative CI. +No research findings or implementation decisions for #151/#15 were published in +this continuation. Physical QA stays deferred in `docs/testing/v010-physical-qa.md`. +Do not begin v0.11, bump versions, close human-test issues, merge, tag or release. diff --git a/docs/development/v0.9.0-checkpoint.md b/docs/development/v0.9.0-checkpoint.md new file mode 100644 index 00000000..2513aefe --- /dev/null +++ b/docs/development/v0.9.0-checkpoint.md @@ -0,0 +1,128 @@ +# v0.9 development checkpoint + +Historical checkpoint, superseded by the +[final development record](v0.9.0-final-acceptance.md). The ENOSPC interruption +below remains part of the verification history; it is not final acceptance. + +The milestone is incomplete. Nothing is merged/released and package version is +0.7.0. Local full-suite verification hit ENOSPC creating test sandboxes; disk +capacity was exhausted. The work was checkpointed rather than starting another +implementation issue. Session logs consume less than 1 MiB; unrelated files were +not removed. Restore disk capacity before another local full-suite run. + +## Base, milestone and review order + +Base: [#251](https://github.com/raiseCatError/notMyShell/pull/251), +`docs/v08-continuation-final-verification`, +`2dfbf9c12c54ada8af364bc44db87bc70f0b77c0`. Its ancestry includes #241 and all +completed continuation work, through #250. GitHub verified its final acceptance +description; the user explicitly confirmed that exact base. + +[Milestone #9](https://github.com/raiseCatError/notMyShell/milestone/9): +v0.9.0 — Tools, Integrations & Workflows. Included issues: #9, #83, #153, #154, +#176, #177, #178, plus new children #252 and #253. Only #176/#177/#178 moved from +milestone #7; #179/#180 remain in Unscheduled — Discoverability & Integrations. +All issues remain open. Project status access lacked read:project and was not +retried; branches, PRs and research comments provide durable activity. + +| Review | PR / branch | Implementation head | +|---|---|---| +| 1 | #182 `feature/176-linguist-language-colors` | `0ffa990253fd31c89db33321b3c27a4c04708b54` | +| 2 | #181 `feature/177-external-welcome-adapters` | `aa3b5edb15b7948f5a9fe6be49f81f74bb7504b1` | +| 3 | #183 `tooling/178-vhs-demo-harness` | `4434ce740d640a61375b8e3f89f1e69459210287` | +| 4 | #254 `feature/v09-supported-tool-config` | `56ba4bc0313e292301d30776b6bb18b40a2c61a1` | +| 5 | draft #255 `feature/v09-tools-browser` | `f1d6899319ebf47f91c4f173b04213cc0bf94d6d` before checkpoint docs | + +Each bases on the preceding branch; #182 bases on #251. Old #181/#182/#183 are +salvaged in place by normal merges, with no cherry-picks, force push, rebase, +closure or published-history rewrite. No lower branch, master or unrelated PR +was modified. Check #255 for the latest docs-inclusive head SHA. + +## Results and boundaries + +- #176 / #182: pinned 694-entry offline Linguist mapping, official aliases, + normalized lookup, neutral fallback, MIT license/provenance. `languageIdentity` + uses the existing Chroma identity category; statusMeaning is undefined. Tools + uses a few real language labels, not a wholesale repaint. No routine network. +- #177 / #181: existing bounded provider capture reused for installed-only + Macchina and Zigfetch. Native Vespyr remains default; None and legacy Neofetch + remain. Maintainer evidence is in [welcome adapters](../architecture/welcome-adapters.md). + Missing/failing tools fall back. Captures are sanitized presentation, not + history or managed shell mutation. Custom arbitrary welcome commands deferred. +- #178 / #183: four durable VHS tapes and isolated deterministic launch helper; + generated media ignored, no runtime/test dependency or visual-golden CI. Tape + validation passed with installed VHS 0.12.0; new media not visually verified. +- #83 research comment led to new #252, implemented in #254. One supported-field + model and shared keyboard review panel, with Starship's native module CLI as + its real consumer. No generic config-file authority. Preview excludes unknown + content; default-No apply, validation, staging, stale checks, exclusive backup, + atomic replacement and conservative preservation guards. See + [configuration architecture](../architecture/supported-tool-configuration.md). +- #9 / draft #255: searchable curated Tools browser, functional Discover, + Installed, Configure, Errors, Settings/palette entry points and optional + first-run discovery. Cached batched executable/version detection; no render + network/process calls, inferred hook state or daemon/credential checks. + Recipes use fixed Homebrew argv after per-install confirmation. Shared task + progress, bounded diagnostics, timeout/disposal process-group cleanup. No + installation occurred during development. See [optional tools](../architecture/optional-tools.md). +- #154 research is recorded on the issue; new #253 implementation remains + pending. Official mise metadata commands can evaluate executable templates, + including dry-run. Plan marker awareness plus explicit consent, bounded cached + allowlisted metadata, no env values or config writes; task selection fills a + visible quoted composer command for separate Enter. Existing zsh hooks remain + authoritative. No runtime mise integration exists yet. +- #153 remains pending. Audit found clean new-session launch seams in + SessionClient/SessionService and startupRestore. Proposed versioned sidecar + presets should bypass restoration, launch a new real session and route approved + startup commands through visible ordinary submission. No preset schema or + behavior has been added, and no environment/secret values were captured. + +The only new preference is `toolsSetupComplete`: legacy completed onboarding +normalizes to true; fresh config is false. Existing config is not reset. The +external preset IDs from #181 remain opt-in in the current normalizer. No +live-session protocol/schema or package version changes. + +Security/privacy: no automatic installations, arbitrary discovered execution, +secret-store reads, raw config previews, plaintext preset secrets, mise activation, +hook edits, release operations or terminal-host appearance authority. NO_COLOR, +Safe glyphs, keyboard navigation and reduced motion are preserved for new panels. +Extremely small widths clip text; wider review is required for full proposals. + +## Verification + +| Cumulative head | Local suite | CI Node 22/26 | +|---|---|---| +| #182 | 738/738 | [passed](https://github.com/raiseCatError/notMyShell/actions/runs/36852198597) | +| #181 | 738/740 on load-sensitive rechecks | [passed](https://github.com/raiseCatError/notMyShell/actions/runs/36852979791) | +| #183 | 740/740, two workers | [passed](https://github.com/raiseCatError/notMyShell/actions/runs/36853421937) | +| #254 | 742/743 final; tmux teardown timeout. Earlier 743/743 before loading guard | [passed](https://github.com/raiseCatError/notMyShell/actions/runs/36854182889) | +| draft #255 code | 726/748; 22 failures during disk exhaustion | [dispatched](https://github.com/raiseCatError/notMyShell/actions/runs/36855614986) | + +Build, typecheck and diff checks passed at every implementation checkpoint. +Latest focused tools/config/settings/progress suite: 20/20. Config-focused suite +18/18 and a real installed-Starship staging/backup/apply against an isolated temp +file passed. Initial #182 inherited NO_COLOR invalidated old color assertions; +unset it for subsequent suites. #181 unbounded/four-worker runs also hit existing +native-completion, interactive/benchmark/lsof timing failures; do not describe +those runs as green. No physical QA performed. See final PR status for latest CI. + +## Exact continuation + +1. Restore adequate free disk space. Current code/docs are pushed to draft #255. +2. Fetch/check out its latest `feature/v09-tools-browser` head; preserve .serena/ + as unrelated local state. Inspect CI, rerun build/typecheck/full suite/diff + check (NO_COLOR unset; two workers avoids observed host saturation). +3. Resolve any reproducible Tools failures in #255 with normal commits. Review + first-run, narrow install review and task cancellation/timeout behavior. +4. Implement #253 from the resulting stable top, then #153 from its completed + top. Read the recorded research/acceptance criteria first. No lower-stack edits. +5. Extend the [one additive QA checklist](../qa/v0.9.0-physical-qa.md), then create + the final docs/acceptance PR once safe buildable scope is actually complete. +6. Keep all work unmerged/unreleased and issues open until the authorized release. + +Deferred deliberately: additional install managers, bulk install/multiselect, +active hook detection/setup, persisted errors, arbitrary welcome commands, +additional config consumers, broad prompt repaint, automatic mise metadata, +custom prompt modules #73, terminal-host/multi-shell work, #52, graphics/OSC8 and +research #179/#180. Eventual user QA should exercise the final top cumulatively; +no optional tools need installing just for QA. diff --git a/docs/development/v0.9.0-final-acceptance.md b/docs/development/v0.9.0-final-acceptance.md new file mode 100644 index 00000000..5faa9e58 --- /dev/null +++ b/docs/development/v0.9.0-final-acceptance.md @@ -0,0 +1,206 @@ +# v0.9 final development acceptance + +Milestone #9, **v0.9.0 — Tools, Integrations & Workflows**, is development-complete +as an **unmerged review stack** above final v0.8 acceptance. Physical terminal QA +is pending. Nothing is merged, tagged, released or described as shipped. Current +released/package version remains **0.7.0**. No release changelog preparation or +version bump is included. Implementation issues #253 and #153 remain open. + +This record supersedes the [historical checkpoint](v0.9.0-checkpoint.md), without +erasing its ENOSPC interruption or lower-stack verification history. Work resumed +at #255 `9f9ce08fad9fb475608b079d662686af93389b84`; completed lower work was not +reimplemented, rebased, force-pushed, merged or retargeted. + +## Review order and integration boundary + +Every new PR targets its immediate predecessor. Review cumulatively in this order: + +| Order | PR / branch | Implementation head | +| --- | --- | --- | +| 1 | [#251](https://github.com/raiseCatError/notMyShell/pull/251) `docs/v08-continuation-final-verification` | `2dfbf9c12c54ada8af364bc44db87bc70f0b77c0` | +| 2 | [#182](https://github.com/raiseCatError/notMyShell/pull/182) `feature/176-linguist-language-colors` | `0ffa990253fd31c89db33321b3c27a4c04708b54` | +| 3 | [#181](https://github.com/raiseCatError/notMyShell/pull/181) `feature/177-external-welcome-adapters` | `aa3b5edb15b7948f5a9fe6be49f81f74bb7504b1` | +| 4 | [#183](https://github.com/raiseCatError/notMyShell/pull/183) `tooling/178-vhs-demo-harness` | `4434ce740d640a61375b8e3f89f1e69459210287` | +| 5 | [#254](https://github.com/raiseCatError/notMyShell/pull/254) `feature/v09-supported-tool-config` | `56ba4bc0313e292301d30776b6bb18b40a2c61a1` | +| 6 | [#255](https://github.com/raiseCatError/notMyShell/pull/255) `feature/v09-tools-browser` | `9f9ce08fad9fb475608b079d662686af93389b84` | +| 7 | [#256](https://github.com/raiseCatError/notMyShell/pull/256), issue #253, `feature/v09-mise-awareness` | `78b9c41e7c363f5889f0695ceb0b20f883e2db86` | +| 8 | [#257](https://github.com/raiseCatError/notMyShell/pull/257), issue #153, `feature/v09-session-presets` | `0b0b2d00d807d100febd05857a4a19bcfdf0abba` | +| 9 | Final acceptance PR, `docs/v09-final-acceptance` | This branch; final SHA and CI run recorded in its PR | + +GitHub remains the durable source of truth. One best-effort Project read failed +because the token lacks `read:project`; it was not retried. Focused branches/PRs +record implementation activity. No Project transition, merge or human test is +claimed. Unrelated local `.serena/` state was preserved and excluded from commits. + +## Delivered scope and architecture + +- **#176 / #182:** pinned offline 694-entry Linguist language identity mapping, + official aliases, normalized lookup, neutral fallback and provenance. Existing + Chroma identity semantics stay separate from status meaning. +- **#177 / #181:** installed-only bounded Macchina/Zigfetch welcome adapters; + Vespyr/None/default fallback remain. Captured presentation is sanitized and + stays separate from history and managed-shell mutation. +- **#178 / #183:** four deterministic VHS demo tapes and isolated development + launch tooling. Generated media is ignored; no runtime/CI dependency or claim + of newly verified physical video visuals. +- **#83 / #252 / #254:** shared supported-field configuration review with Starship + as the consumer. Explicit default-No apply, validation, stale guards, staging, + exclusive backup and conservative atomic preservation. No arbitrary config + authority or unbounded raw previews. +- **#9 / #255:** curated offline `/tools` browser, Discover/Installed/Configure/ + Errors, search, details, Settings/palette and optional first-run discovery. + Cached executable detection, fixed Homebrew argv recipes after individual + consent, bounded progress/diagnostics and process-group cleanup. No installation + occurred during development; no hook/daemon/auth-state inference. +- **#154 / #253 / #256:** optional + [mise project awareness](../architecture/mise-project-awareness.md). Passive + marker/executable facts do not invoke mise. Explicit default-No consent precedes + argv-only bounded metadata; refresh requires renewed consent. Only tool + names/versions and task names survive schema parsing; unknown/env/run fields + and raw failures are discarded. In-memory cache separates cwd/project identity. + A selected task fills a quoted visible composer command; separate Enter runs + real zsh. No trust, activation, config/env mutation or environment persistence. +- **#153 / #257:** host-independent + [session presets v1](../architecture/session-presets.md). One shared + `/presets`/palette panel creates, lists, inspects, launches and deletes; CLI + supports `--presets` and `--preset `. Launch creates a new service-backed + real-zsh session; UI launch leaves the old one detached. Visible literal `cd` + and explicit startup commands use ordinary transcript/journal submission, one + per prompt. Failed startup stops the remainder. First/changed content requires + separate startup acknowledgement. Reattach uses the existing identity and never + replays startup; automatic startup restore is bypassed only for preset launch. + +The persistent real shell, raw PTY presentation, NMSh editor, detached semantic +helper, FOLLOW/DETACHED viewport and interactive passthrough model remain. No +terminal-host dependency, shell replacement, dependency package or live-session +protocol change was added by the continuation. + +## Persistent schema and consent boundaries + +Earlier #255 adds only `toolsSetupComplete` to preferences: legacy completed +onboarding normalizes to true, fresh config to false; existing config is not +reset. Existing welcome adapter IDs remain opt-in. Mise adds no preference, +persistent cache, trust marker or environment values. + +Presets add **`presets.json`, version 1**, in the existing platform/XDG config +directory beside `config.json`: `name`, absolute `cwd`, explicit ordered +`commands`, optional `acknowledged` digest. No passwords/tokens/secret fields, +environment dumps or inferred commands are captured. Users must keep secrets out +of their explicit command text and use existing tooling references instead. +An absent file is compatible with existing config. Malformed/unknown-version +storage is preserved and reported; writes use private atomic staging, exclusive +locking, intervening-change and symlink guards. No migration resets preferences. + +Three independent consent boundaries remain: + +1. Each optional installation/configuration change has its existing explicit review. +2. Each mise metadata inspection/refresh permits config template execution and + possible remote-task fetching **only after consent**. It never auto-trusts. + Selection only edits input; task execution is the user's separate Enter. +3. Preset startup review shows the exact cd/commands before first execution and + after their content changes. The digest binds cwd and command order. It does + not confer mise metadata consent or mark project actions inherently safe. + +## Verification and resource history + +At resumption macOS/Node 26.8.1 had approximately **11 GiB free**, versus 3–4 GiB +at the previous checkpoint. `node_modules`/`dist` were absent after cleanup. +Dependencies were restored from the lockfile. An initial network-restricted +install stalled; a subsequent build found dependencies absent despite an install +success report. A sequential explicit repository install resolved it. No source +failure was attributed to the resulting `tsc: command not found` setup error. +Only the session's stalled installer was stopped. No NMSh service was terminated +or unrelated user files deleted. Preflight found no stale runner/helper/service. + +| Head | Local canonical suite | Node 22 / Node 26 | +| --- | --- | --- | +| #255 verified checkpoint | **748/748**; focused Tools/config/settings/progress **21/21** | [checkpoint CI passed](https://github.com/raiseCatError/notMyShell/actions/runs/36856000116) | +| #256 mise | **759/759**; final fake-mise focused **11/11** | [both passed](https://github.com/raiseCatError/notMyShell/actions/runs/36861543414) | +| #257 presets | **772/772** (earlier 770/770 before two tests) | [both passed](https://github.com/raiseCatError/notMyShell/actions/runs/36863813425) | +| Final acceptance | Same cumulative implementation; exact final canonical result recorded in PR | Final docs-head dispatch/result recorded in PR | + +Local suites unset inherited `NO_COLOR` and normally use two workers, matching the +checkpoint's host-load guidance. The runner owns/removes a private short TMPDIR +and fails on semantic/zsh directory leaks. Final checks include disk, tree, +process and private-temp hygiene. Automated PTY/live-service fixtures are **not +physical terminal validation**. + +The earlier **726/748 during ENOSPC** did not reproduce after disk recovery. +#255 moved out of draft because its interrupted local verification passed; its +SHA remained unchanged. The checkpoint's earlier lower-stack load/teardown +failures remain historical facts, not silently reclassified as passes. + +Mise focused tests caught/fixed a confirmation-state bug (arrow selection must +retain the review) and corrected an overstrict NO_COLOR assertion to allow shared +ANSI reset chrome. Preset tests corrected a private helper method name and a +first-launch fixture that matched review text before actual shell launch; probing +the empty service during bootstrap triggered its existing empty-service shutdown. +Waiting for the live composer resolved that fixture error without changing +service behavior. The first final docs-head CI +[run](https://github.com/raiseCatError/notMyShell/actions/runs/36866230699) passed +Node 22 but Node 26 had one preset test failure (764 pass / 1 fail / 7 skipped): +the test typed after service-side idle but before the frontend processed the +final startup prompt, while startup still blocked input. A normal acceptance +commit now waits for the frontend's completed startup journal before typing. +No product/service behavior or lower PR history was changed; the failed run +remains recorded rather than silently rerun. Benchmark typing initially omitted `--types node` despite the +project's explicit type convention; using it passed. A dynamic-import type +annotation was corrected during implementation. No unexplained test rerun is +represented as green. + +The corrected docs-head CI [run](https://github.com/raiseCatError/notMyShell/actions/runs/36870396278) +passed Node 22/26. A subsequent local run was 771/772: the existing 100k +suggestion benchmark measured p95 73.2 ms against its unchanged 60 ms ceiling. +Host load was 45.36, with Spotlight/media indexing using substantial CPU; no +user/system process was stopped. Focused isolated verification and lower local +suite concurrency are used to assess contention without relaxing assertions. +The first isolated benchmark recheck also missed at p95 67.1 ms while load +remained 17.16; preset storage tests passed. These failures are retained. +Final-head results remain recorded in the PR. CI's optional installed-tool +fixtures can skip: #256 was 752 pass / 7 skipped on both versions; #257 was +765 pass / 7 skipped on both. These are successful jobs, not claims every test +executed in CI. + +Final acceptance also enforces the preset store's existing 256 KiB read limit +before atomic writes, and counts startup command bounds in UTF-8 bytes. An +oversized create must preserve readable existing storage. This small safety +correction and its regression test stay on the final acceptance PR; no lower +branch/history was rewritten and no schema or feature expansion was added. + +## Exact deferred scope and limitations + +No feature expansion follows #153. Deferred: #73, #78, #150, #151, #179, #180; +terminal-host abstraction, multi-shell support, additional installers/managers or +configuration consumers, bulk installs/multiselect, active-hook detection/setup, +persistent tool error history, arbitrary welcome commands, broad prompt repaint, +#52 completion parity, graphics/OSC8 and a workflow engine. + +Mise marker detection covers conservative standard ancestor names, not arbitrary +environment/custom configuration. Metadata is optional, explicitly inspected and +in-memory only. Existing zsh hooks are authoritative; returned tasks are not +certified safe. No task-argument editor or secret-aware command generation exists. + +Presets require the live-session service and have no edit/rename UI, environment +snapshots, provider/layout overrides or host-profile authority. Recreate to change +one via the UI. A crash-left writer lock requires manual ownership inspection +before removing that specific lock. Startup queues are frontend-owned: a running +command survives detachment, but unsubmitted remainder is not persisted/replayed. +Preset launch postpones first-run setup to a later ordinary launch without changing +stored setup choices. Very small widths clip chrome; use a useful review width. + +## Physical QA and exact next steps + +The [one additive v0.9 checklist](../qa/v0.9.0-physical-qa.md) retains earlier +Tools/config/welcome checks and adds only mise consent/task paths and preset +creation/inspection/new-session/transcript/continuity/error paths. Mise checks +apply only if already installed/configured; no optional installation is required +for QA. Exercise new panels in Ghostty, with basic preset launch in Terminal.app +where practical, plus narrow/NO_COLOR/Safe and affected Bottom/Top/Flow paths. +Physical QA remains **pending**. + +Review the full stack in order, then test the final acceptance head cumulatively +with the existing v0.8 checklist and this additive v0.9 checklist. Report failures +against the relevant issue and add focused fixes without rewriting history. +Keep issues open while physical validation is pending. Merge/release requires a +separate authorization; only after successful review/QA should release changelog, +version bump, tags or release preparation begin. diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index 2e46da46..05f05f41 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -39,3 +39,20 @@ Full transcript wrapping and snapshot serialization exceed the editor-frame targ Structured completion filtering (500 candidates) measured p50 0.06ms / p95 0.13ms. A direct native-source probe measured cold Git parent-context capture at 226.64ms and command capture at 84.10ms. These cold operations are asynchronous and exceed the 50ms investigation target; warm parent-context results are cached for two seconds (32 contexts maximum) and locally filtered. Results are invalidated by buffer/cwd generation before rendering. Capture execution remains bounded to 1.5 seconds and 1MiB output. Structured history queries with combined cwd/exit/duration/text filters and no matches (full scan) measured p50/p95 1.39/4.25ms at 10k and 11.38/11.70ms at 100k. Queries yield every 2,048 entries and stop after at most 100 results in the composer. Journal JSON loading/projection runs in a worker to keep whole-transcript parsing off the editor thread; zsh import yields between batches. Index setup and sorting are distinct from these warm-query measurements. + +### Directory ranking (v0.8 navigation) + +Same macOS arm64 / Node 26.8.1 host, 20 samples after 3 warmups. Native aggregation yields every 2,048 history records, caches by the immutable history snapshot, and decays visit contributions over seven days. + +| Workload | p50 | p95 | +| --- | --- | --- | +| Rank 10k records | 0.65 ms | 1.61 ms | +| Rank 100k records | 6.56 ms | 9.26 ms | +| Cached fuzzy query from 10k history | 0.04 ms | 0.05 ms | +| Cached fuzzy query from 100k history | 0.02 ms | 0.03 ms | + +These fixtures contain 80 unique directories. They measure native work, not private zoxide database latency or external picker rendering. zoxide queries use a bounded temporary database copy because upstream query sorts and saves its database. + +### History index import (v0.8 hardening) + +Hashing imported source identities and populating the native index: 5 samples after 1 warmup on the same host. 10k records: p50 8.16 ms, p95 19.02 ms; 100k: p50 107.08 ms, p95 121.41 ms. These are total wall times, with a yield every 1,024 records so the work is spread across event-loop turns. File I/O, source parsing and final sorting are separate. Warm bounded queries remain as measured above; an import is background work, not a keystroke query. diff --git a/docs/qa/v0.8.0-physical-qa.md b/docs/qa/v0.8.0-physical-qa.md new file mode 100644 index 00000000..e9c28e6e --- /dev/null +++ b/docs/qa/v0.8.0-physical-qa.md @@ -0,0 +1,88 @@ +# v0.8 physical terminal QA — pending + +This checklist has not been performed. Automated tests do not establish physical terminal behavior. Test the cumulative acceptance branch after review, or current dev after the stack is integrated. Keep package version at 0.7.0 until release preparation is separately authorized. + +Use Ghostty and Terminal.app for one focused pass. Exercise Bottom, Top and Flow, with both Normal and Chat presentation where relevant. Repeat a narrow-width sample with Safe glyphs and NO_COLOR; reduced motion should retain the same static behavior. Optional tools are only tested if already installed; do not install tools just for this checklist. + +## Completion + +- [ ] Type a normal command such as `git st`; move with arrows and insert with Tab. Enter must still be a separate execution step. +- [ ] Try options/descriptions and nested file paths such as `ls src/ap`. Plain candidates, inline categories and long descriptions remain readable. +- [ ] Type/delete rapidly, change command context, and press Tab while typing. Old-buffer candidates must not flash or insert. Escape dismisses. +- [ ] Check hundreds of candidates and a narrow terminal in Bottom, Top and Flow. Rows stay within NMSh's composer region without displacing or corrupting raw output. + +## History + +- [ ] `/history` and Ctrl+R search commands; `/resume` still browses sessions. Combine plain text with useful cwd/project/exit/date/session/duration filters; metadata absent from imports stays unknown. +- [ ] Arrow/Enter and Tab restore a selected command into the composer without execution. Check a multiline history command restores its original text. +- [ ] Run a synthetic public command, find its record, and use Ctrl+X. That selected record disappears and stays deleted after a reload/restart. Other sources/occurrences of the same command may remain; original shell history and transcripts are intact. +- [ ] Run a command beginning with a space, for example ` echo NMSH_QA_PRIVATE_08`. Its transcript may remain, but that command must not become an NMSh history result. If checking an unexported HISTORY_IGNORE pattern, verify the exact ignored command is excluded and restore your original pattern afterward. +- [ ] Search an existing large imported history while continuing to type. Results remain responsive; no internal storage inspection is required. +- [ ] If Atuin is installed, choose it explicitly in Config, inspect truthful status, search metadata and return to Native. NMSh selection must not execute a command or invoke sync. + +## Pickers + +- [ ] Native remains the default. Bare `/history` and the palette history action open the configured selection surface. +- [ ] If fzf is installed, select it in Config. Accept restores one supplied command into the composer; cancel preserves the draft. Return to Native afterward. +- [ ] If Television is installed, perform the same check. No user channel, preview command or remote channel should run through this adapter. +- [ ] During an external picker, cancel and resize. After each exit, typing, paste, arrows and normal command submission work; no raw-mode or keyboard-mode leak occurs. Missing/incompatible tools fall back to Native with a truthful notice. + +## Navigation + +- [ ] Run ordinary `cd` commands in zsh, then use `/dirs` or its palette action. Native ranks recorded directories and filters fuzzily. +- [ ] Selecting a directory inserts a quoted `cd -- …` command only. Review it and press Enter; cwd, transcript and session state then reflect the real shell command. +- [ ] Check a path with spaces or an apostrophe. Ordinary `cd ..` and shell-defined `cd` behavior remain normal. +- [ ] If zoxide is installed/configured, choose it in Config and verify ranked selection. Existing shell hooks continue to work; return to Native. No inspection of the database is required. + +## Corrections + +- [ ] Try a simple typo of an installed executable, for example `gti status` or `grpe --version`. When one candidate is unambiguous, a correction appears as NMSh UI. Tab edits the buffer; a separate Enter executes. Escape dismisses. +- [ ] Try an unrelated nonexistent name, an ambiguous typo and a compound/quoted command. No low-confidence suggestion should appear. An installed command returning 127 should not be mistaken for a command-name typo. +- [ ] Verify `/copy` contains shell output only, without correction chrome. Typing a fresh draft must remove any old correction. + +## Regression + +- [ ] Ghostty and Terminal.app: Flow/Chat and Bottom/Top resize, scrolling, command presentation and ordinary long-running commands remain usable. +- [ ] Enter and leave a fullscreen TUI and an inline interactive CLI; normal editor behavior returns. Raw PTY output remains faithful, and `/copy` remains plain. +- [ ] Detach during a command and reattach; startup restore and `/resume` retain the same live session and shell state. Ignored/private history remains excluded after replay. +- [ ] Existing prompt providers and ghost suggestions still work. No completion keystroke reaches the managed PTY as zsh editor UI. + +Record pass/fail observations against the relevant open issue and include terminal host/layout and a short reproduction for failures. Review and merge the stack in dependency order only after the automated/review gates; do not close issues or prepare a release merely because this checklist exists. + + +## Continuation — inspector, block actions and notifications (pending) + +These checks are additive to the original stack above. Use the cumulative +`docs/v08-continuation-final-verification` branch after review. No check here has been +claimed as physically performed. Repeat relevant paths in Ghostty and +Terminal.app, Bottom/Top/Flow, Normal/Chat, and a narrow Safe glyph/NO_COLOR +sample. See [continuation architecture](../design/command-intelligence-continuation.md). + +### Inspector + +- [ ] Open F1 / Ctrl+Shift+P → Toggle command inspector. Type `git status`, `rg --hidden pattern` and `npm run test`; move the cursor over command/subcommand/flag/argument. Known facts appear; unknown/dynamic contexts remain truthful and never execute. +- [ ] Bottom places it above the composer; Top below; Flow moves it with the composer. Chat does not relocate either. Scroll Flow off screen and back; resize and type again. +- [ ] Check narrow width and short height fallback, multiline/Unicode cursor movement, Safe glyphs and NO_COLOR. Text stays bounded and deterministic. +- [ ] Toggle off/on. Open a panel, run a command and use a paste atom: it hides while those surfaces own input and restores afterward when applicable. + +### Block actions + +- [ ] Run `printf 'one\ntwo\n'` and a longer repetitive command such as `yes sample | head -40`. Shift+Tab reaches its block; Enter opens actions. Arrow/Enter can reach every action without a mouse. Escape closes, a second Escape clears block focus, and typing returns to the composer. +- [ ] Passive hover shows `[Actions]` only where it fits; clicking opens the same palette. Narrow widths preserve keyboard access without covering command/output text. +- [ ] Shift click and Shift drag perform native terminal selection without opening, focusing or dispatching controls, both before and after a normal hover. Check selection across output and block boundaries. +- [ ] Copy command/output/both into an ordinary text editor. Compare with the original command and full output, including hidden lines. There is no Actions label, colored chrome or scraped screen text; `/copy` retains its existing output/lifecycle behavior. +- [ ] Edit & rerun fills the composer and waits. Change it before Enter. Rerun executes only after explicit action selection and shows the submitted command. Try after `cd`: it uses the current cwd. +- [ ] Fold/unfold matches Ctrl+O. In Chat, hover actual command and output blocks for two different commands: the owning record supplies each action, including while scrolled and resized. + +### Notifications + +- [ ] In Config search “Command notifications”; check all five defaults and keyboard editing/reset. Temporarily choose 5s and focused Notify. `sleep 6` should post one generic completion notification; `sleep 1` should not. +- [ ] With failure On, `sh -c 'sleep 6; exit 7'` posts one failure. Success Off suppresses only successes; failure Off suppresses only failures; master Off suppresses both. Restore defaults afterward. +- [ ] Focused Suppress blocks delivery while focused; switch away during `sleep 6` and verify delivery. For a host that does not report focus, unknown focus is allowed. Note the sender macOS attributes it to and any permission required; backend success alone is insufficient evidence. +- [ ] Enter/leave a fullscreen TUI, cancel/resize an external picker if already installed, and from another terminal send SIGTSTP then SIGCONT to the NMSh frontend PID if practical. No focus escape text reaches the composer/output; typing and focus suppression recover. Ctrl+Z on a real job still suspends that job. +- [ ] Redraw, resize, scroll sticky headers and `/resume` completed history: no repeats. Reattach a still-running command and observe one live completion; replayed completions must not notify. +- [ ] Notification text contains neither commands nor output. Verify `/copy` and restored transcript show no notification content. + +Test TMPDIR hygiene has no physical QA requirement. Record host-specific +failures against #242, #243 or #106; keep those issues open until integration +and the required physical checks actually pass. diff --git a/docs/qa/v0.9.0-physical-qa.md b/docs/qa/v0.9.0-physical-qa.md new file mode 100644 index 00000000..7af69bba --- /dev/null +++ b/docs/qa/v0.9.0-physical-qa.md @@ -0,0 +1,112 @@ +# v0.9 additive physical QA + +Pending. No physical validation or release readiness is claimed. Run these once +on the eventual final cumulative v0.8 + v0.9 top, alongside the existing +[v0.8 checklist](v0.8.0-physical-qa.md); do not repeat lower-stack checks here. +Use the final cumulative acceptance head described in +[the development record](../development/v0.9.0-final-acceptance.md). +Use Ghostty and Terminal.app where available. Never install optional tools just +to satisfy QA. Mouse is optional; perform the paths with keyboard only. + +## Tools browser + +- [ ] `/tools`, Settings → Tools and palette open the same browser. +- [ ] Type `fzf`, use Up/Down, Enter details, Esc back/clear/close, Left/Right tabs. +- [ ] Installed/Missing evidence matches the executables already available; + Configured in NMSh does not claim active zsh hooks or daemon availability. +- [ ] Recommended identifies only zoxide/fzf/ripgrep/fd/jq; other tools remain optional. +- [ ] Errors has a calm empty state and shows factual retained failures. +- [ ] Narrow widths around 40 columns retain navigation and useful details; + use a wider terminal for full installation/configuration review. +- [ ] NO_COLOR and Safe glyph mode retain readable state/focus; reduced motion + holds indeterminate progress still. + +## Install consent + +- [ ] On a missing tool, `I` shows exact curated command/scope and defaults to No. +- [ ] Enter on No or Esc cancels without changing software, hooks or settings. +- [ ] Only if desired, confirm one harmless optional tool using available Homebrew; + observe truthful progress and post-install executable detection. +- [ ] If an actual install fails, Errors retains a factual failure and navigation + remains usable. Do not deliberately disrupt an install or system config. +- [ ] Without a supported package manager, official-source guidance replaces execution. +- [ ] Fresh first-run Recommended/Choose individually only browse; Skip completes + discovery without installation. Existing completed onboarding is not reset. + +## Supported configuration + +Only if Starship is already installed/configured; otherwise verify truthful +unavailable state without installing it for QA. Use a disposable config via the +existing STARSHIP_CONFIG/NMSh config-path choice if applying a change. + +- [ ] Tools → Configure opens the same supported Starship module model as Settings. +- [ ] Select a boolean field, inspect its supported-value preview, cancel; file unchanged. +- [ ] Explicitly approve a change: module value changes, unknown settings/comments + remain, existing config has an exclusive backup, and prompt remains usable. +- [ ] Unsupported/multiline/symlink config fails usefully without leaking contents. +- [ ] Esc during loading does not let a late result reopen the panel. + +## Welcome and identity + +- [ ] Native Vespyr and None work without any external executable. +- [ ] Only if already installed, select an external welcome and inspect sanitized + capture, narrow fallback, NO_COLOR and subsequent native fallback on failure. +- [ ] Missing external choices show truthful unavailable state; no random command runs. +- [ ] Tool language labels identify language while status remains explicit text. + +## mise project awareness + +Only if mise is already installed/configured. Otherwise check Missing and skip +metadata/task checks; do not install mise for QA. Use an existing project you +intend to inspect. Metadata consent permits config template execution and may +fetch remote tasks; do not treat it as a sandbox or a safety endorsement. + +- [ ] `/tools` → mise → `M` shows truthful Installed/Missing and marker state. + A project marker alone shows "not evaluated" / "metadata not yet inspected"; + merely opening/changing directory does not invoke metadata or grant trust. +- [ ] `I` shows the exact two commands and defaults to No. Enter on No or Esc + cancels without evaluation, hook edits, software installation or env changes. +- [ ] Explicitly approve inspection; tools/tasks or a generic factual failure + appear. No raw environment, startup script or stderr dump is shown. +- [ ] Close/reopen in the same cwd uses cached metadata; another project does + not inherit that project's metadata. `R` requests fresh consent before rerun. +- [ ] If tasks exist, Up/Down and Enter insert a quoted visible `mise run` command + into the composer without executing it. Review possible project actions or + installations; press Enter separately only if you intend to run the task. +- [ ] Cancel inspection/review and return to normal input. Narrow/NO_COLOR/Safe + views retain the new panel's state and keyboard operation. + +## Session presets + +Use a disposable preset named `QA` and a directory you control. Commands should +be harmless and explicit, for example `printf '%s\n' 'preset startup'` and +`pwd`. Never place secrets in a preset. No terminal-profile changes are needed. + +- [ ] `/presets` or its palette action lists presets. `N` opens one shared form: + name, absolute cwd, commands. Tab changes fields; Ctrl+J adds a command line; + Enter creates. Duplicate/invalid names report an error and preserve data. +- [ ] Enter inspects the stored cwd/commands without executing. `nmsh --presets` + also lists without creating a live shell or running commands. +- [ ] `L` / `nmsh --preset QA` shows quoted real cd and startup commands on first + launch. PgUp/PgDn review long content. Default No/Enter or Esc runs nothing. + An already acknowledged unchanged preset needs no repeated review; if you + intentionally edit its stored commands/cwd, launch requires review again. +- [ ] Approve: a NEW live real-zsh session starts. The UI's previous live session + remains detached, with its original cwd/state available in `/resume`. +- [ ] Visible real `cd -- ''` runs, then the explicit startup commands run + in order and appear as ordinary transcript entries. `pwd` reports the + requested directory, including a path with spaces/apostrophes if practical. +- [ ] Close/detach the new frontend, then reattach using `/resume` or + `nmsh --attach ` from `nmsh --sessions`. Same live identity/state and + transcript return; startup commands do not run again. +- [ ] A failed startup command or Ctrl+C stops remaining startup commands and + leaves usable real zsh. Unsubmitted startup commands are not replayed after + frontend exit during startup. +- [ ] `D` delete defaults to No; confirm removes the preset without ending its + live sessions. Missing preset and a disposable cwd removed after creation + report factual errors before startup; existing config is not reset. +- [ ] In Ghostty, exercise the new form/review with Bottom, Top and Flow, narrow + width, NO_COLOR and Safe glyphs. In Terminal.app, verify basic create/inspect, + acknowledged CLI launch, visible cd/startup and reattach if practical. + +VHS is development tooling only and has no end-user QA requirement. diff --git a/docs/superpowers/plans/2026-10-02-adversarial-hardening.md b/docs/superpowers/plans/2026-10-02-adversarial-hardening.md new file mode 100644 index 00000000..9d3a9b02 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-adversarial-hardening.md @@ -0,0 +1,28 @@ +# Adversarial hardening implementation plan + +**Goal:** Resolve the adversarial blockers in issue #284 above #286. +**Architecture:** Preserve persistent zsh and detached helpers. Keep startup ownership until readiness; explicitly reject bounded queue overflow. Negotiate startup safety through an additive v2 welcome capability. Track only child keyboard stack entries on each physical screen. Serialize abandoned preset lock recovery. +**Constraints:** No merge, history rewrite, release, tag, dependency additions or version bump. Package/lockfile remain 0.7.0. Physical QA deferred. Preserve .serena and worktrees. Stop disk-heavy work below 1 GiB. + +- [x] Startup: reproduce early less/vim against SIGINT-ignoring startup; prevent pre-ready passthrough; preserve recovery before/after notice and post-ready passthrough. +- [x] Queue/tail: pin exact 64 KiB UTF-8 boundary, overflow rejection, cumulative writes, eventual readiness and abort; propagate rejection to composer; cap sanitized tail at 2048 UTF-8 bytes. +- [x] Protocol: optional startupSafety capability; reject create/attach before sending either request when missing, retain list/kill administration; real old/new service and frontend exchanges. +- [x] Kitty: observe actual forwarded bytes and restored modes, track independent main/alternate child pushes, pop each on its own screen before returning to NMSh; preserve pre-existing host stacks and normal leave. +- [x] Presets: atomic recovery directory serializes recovery; re-read owner inside guard; never recover guard blindly. Deterministic competing recoverers with writer paused in mutation; retain dead/live/malformed/reused PID cases. +- [x] Clipboard: stdin failure rejects even with zero exit; isolated process group killed only on failure/timeout; assert descendant cleanup and successful owner survival. +- [x] Test quality: held submitted command survives reattach exactly once; isolate local mise marker mutation; wait on less journal completion and composer ownership. +- [x] Verification: focused red/green tests, complete macOS suite with TERM=xterm-256color/COLORTERM=truecolor, build/typecheck/benchmark typing/diff check, process/temp leak checks, repeated tmux test. Push implementation and open PR above #286, await all four CI gates. +- [x] Final acceptance: document disposition, evidence and pending physical QA on separate branch/PR above implementation; keep both open/unmerged. + +Review focus: asynchronous service rejection; readiness prompt must not complete queued command; each screen has independent stack; competing recovery cannot displace live owner; legacy service must not receive create/attach from new frontend. + +Evidence: 918 local tests passed with TERM=xterm-256color, COLORTERM=truecolor, NO_COLOR/FORCE_COLOR cleared. Ten real-tmux ownership repetitions passed, including runs alongside the suite. Startup ownership, missing capability, queue overflow, stale recovery and Kitty regressions were observed failing on unfixed code. Independent review found delayed rejection overwriting a newer draft; a real socket regression reproduced it and now passes. Rejection source metadata prevents raw writes from clearing a submitted command; newer drafts survive and rejected input remains recoverable. Build, source/benchmark typing and diff checks passed; final refreshed verification and CI recorded in acceptance docs. + +Ruling: an abandoned recovery guard fails closed with both paths in the error. Automatic read-then-remove recovery of the guard would recreate the original ownership race. End all NMSh writers before manual guard removal. +Ruling: protocol remains v2. Unknown startup semantics refuse create/attach before either request; administrative list/kill remain possible. Fresh-session creation falls back in-process with a factual notice. + +Integration evidence: implementation PR #287 remains open/unmerged at 39923fcf58c0fb52e07511104b45ac552899158d. All four CI jobs passed (run 37045796065); macOS Node 22 required a failed-job rerun after an unchanged native-completion deadline miss. Final acceptance records that residual risk. Final docs branch is docs/v013-adversarial-acceptance, above the implementation commit. Physical QA remains deferred; issue #284 remains open. + +Resumed continuation: fetched origin and verified #287 2a4ca48 / #289 fd16ac9 with 4.77 GiB available. Deterministic real-service reattach probe demonstrated raw=false/listening=false at visible restored-mode handoff. Fix a8937c677268735cc5291d13d12eba11eebaabc3 enables raw mode/listener before renderer entry/handoff. Fresh 918-test canonical suite, exact Node22.23.2 ten repetitions, build/typecheck/benchmark typing/diff and process/temp lifecycle checks pass. Fresh docs branch docs/v013-adversarial-final-acceptance is based directly on this implementation, preserving #289 and #288 history. Final exact-head CI evidence is recorded in acceptance and PR descriptions. + +Final implementation CI run 37067891722: all four macOS/Ubuntu Node22/26 jobs passed on the first attempt at a8937c677268735cc5291d13d12eba11eebaabc3. Final docs remains an open PR above that exact head; no merge is authorized. diff --git a/docs/testing/v010-acceptance.md b/docs/testing/v010-acceptance.md new file mode 100644 index 00000000..7fb20563 --- /dev/null +++ b/docs/testing/v010-acceptance.md @@ -0,0 +1,117 @@ +# v0.10 development acceptance (unmerged) + +Milestone #10, **Terminal Hosts & Compatibility**, is development-complete as an +unmerged review stack, subject to the exact-head CI gate recorded on the final +PR. This is not a shipped release. Package, lockfile and current release stay +**0.7.0**. Physical terminal and actual-tool QA remains pending; relevant issues +stay open. No v0.11 work, merges, tags or releases are included. + +## Review order and scope + +Frozen foundation: #258 → #259 capabilities → #260 baseline → #261 integration +→ #262 profiles (`ae66506ab4aecf509414a75841943160ffc044fc`). The continuation is +[#263 OSC 8](https://github.com/raiseCatError/notMyShell/pull/263) → +[#264 compatibility](https://github.com/raiseCatError/notMyShell/pull/264) → +[#265 hardening](https://github.com/raiseCatError/notMyShell/pull/265) → this acceptance/docs PR. Every PR targets its immediate +predecessor. The lower foundation was neither reimplemented nor modified. + +- **Capabilities and baseline:** frontend-owned plain values, conservative unknown + host, keyboard-only Terminal.app, explicit color/hyperlink override precedence, + one shared bounded keyboard/synchronized-output query batch. No capability state + is injected into the persistent shell or journal. +- **Ghostty integration:** optional keyboard/configuration/appearance operations + remain isolated under host adapters and explicit user actions. Core presentation + consumes capabilities rather than host names. Startup fastfetch suppression, + NMSh-owned editor and detached semantic helpers remain intact. +- **iTerm2, Kitty, WezTerm:** passive profiles provide documented hints; keyboard + and synchronized output depend on probe evidence where applicable. No graphics + bytes or new host preference writes are introduced. +- **Attach/reattach:** each frontend refreshes capabilities. The creating shell's + cwd/environment/state persists. Foreground interactive programs retain query and + input ownership; host context changes do not mutate shell environment. +- **OSC 8 (#150):** URL recognition, existing explicit paths from owning command cwd, + repository-gated numeric GitHub references and preserved original program links. + Generated decoration uses copied cells. Allowed generated schemes are http, + https and local file; controls/credentials/remote file authorities are rejected. + Wrapped rows and sticky truncation close links; capability-off output is plain. +- **Preview (#151):** [research decision](https://github.com/raiseCatError/notMyShell/issues/151#issuecomment-5941207557) + is research-only. Kitty placement IDs, iTerm inline images, Sixel, symbols and + optional Chafa were evaluated. Current text renderer lacks owned image lifecycle + across scroll/resize/close/clear/passthrough/reattach. No implementation child, + graphics subsystem, decoder dependency or network fetching was added. Preferred + future v1 is one explicitly requested local-image panel with strict bounds and + native → Unicode/text → metadata fallback. +- **Compatibility (#14):** repeatable finite/noisy/streaming/canonical/raw/alternate- + screen/agent/nested-PTY fixtures; full stored ownership, folding, LIVE, resize, + signals, keyboard/mouse/paste handoff and restoration. Optional tmux/GNU screen + coverage remains skippable in CI. Local tmux 3.7c passed actual-client mouse-on + detach/reattach and pane resize. Physical selection is not certified. +- **Embedded hosts (#15):** [research findings](https://github.com/raiseCatError/notMyShell/issues/15#issuecomment-5941320913) + recommend a generic manual pass for Supacode/similar hosts. Worktree cwd, opaque + environment ownership, app shortcut interception, nested agent UI and process + persistence remain separate layers. No reproduced vendor-specific gap warranted + a defensive child issue or dedicated adapter. +- **Hardening:** ordered, bounded, independent main/alternate keyboard stacks; + incomplete-only mode carry, reset isolation, counted pops and correct default + flags; synchronous probe listener cleanup. A preset test now waits for command + completion before `/resume`; owned-process timeout diagnostics improve handoff. + +Detailed records: [host architecture](../architecture/terminal-host.md), +[compatibility matrix](v010-compatibility.md), [cumulative audit and performance](v010-hardening.md), +and [additive physical QA](v010-physical-qa.md). + +## Automated evidence and retries + +The final cumulative suite contains **810 tests**. Canonical build, typecheck, +full suite and diff check are required. Benchmark-script typing uses TypeScript +6's explicit `--ignoreConfig` when source files are specified on the command line. +Node 22 and 26 are checked through workflow_dispatch because normal PR CI filters +do not trigger for these feature-branch bases. Exact run/head evidence belongs +on the PRs; a green predecessor is not a substitute for final-head CI. + +Local test environment had inherited `NO_COLOR=1`; the first run failed color +snapshots. Later full runs removed that override. Default concurrency twice hit +the baseline sandbox teardown timeout, and one bounded-concurrency run did too. +Passing bounded-concurrency retries are reported explicitly. Another run exposed +a preset-test race: PID output arrived before completion, so `/resume` was rejected. +The test now waits for completion. Another four-worker run failed the existing native-completion fixture and suggestion-ranking latency ceiling (p95 66.9 ms against 60 ms); assertions were kept intact and the unchanged head was rerun serially. None of these runs is physical validation. + +Final checks also cover process leaks, private temp roots, version consistency and +clean tracked status. Existing unrelated untracked `.serena/` was preserved. +Resource preflight started around 5.3 GiB free; headroom was checked periodically, +and no unrelated user data was deleted. See the final PR/handoff for final disk. +Project access was attempted once; the token lacked `read:project`, so no board +transition is claimed. Issues/PRs/comments remain the durable work record. + +## Performance and limitations + +URL-heavy benchmark, local Node 26.8.1 / macOS arm64: 1000 lines first recognition +p50 **5.40 ms**, p95 **9.10 ms**; cached access p50 **0.06 ms**, p95 **0.11 ms**. +This excludes terminal writes, wrapping and filesystem checks. No extra startup +query was added. Prior checkpoint values (not freshly remeasured) were about +80–83 ms for the probe batch and 0.145 μs/call for capability resolution. + +Recognition is bounded to 8192 columns/code units and 16 candidates per source +line; weak revision/cwd caches avoid rescanning unchanged history. Repository +cache is bounded to 128 cwd entries. Existing wrapping still traverses history. +Synchronous path checks have bounded count, but mounted filesystem latency is +unbounded externally. Missing-file/repository cache results are not retroactively +refreshed for unchanged rows. Ambiguous paths (including spaces/shell expansion), +stack-trace locations, arbitrary hashtags and uncertain repositories remain plain. +Program link metadata is retained only for bounded, control-free OSC payloads. + +Actual Supacode/agent accounts, all GUI hosts and real zoxide/Atuin configurations +were not newly evaluated. tmux key/selection behavior remains configuration and +host dependent; Ctrl+J is the multiline fallback. Cross-host shell environment +staleness is intentional persistent-shell behavior, documented separately from +fresh frontend capabilities. Graphics previews remain deferred by research. + +## Human next steps + +Review the stack in order without merging under this authorization. Run only the +additive checklist on available terminal hosts; installing every host is not +required. Validate URL/file/GitHub/program links, narrow wrapping/selection, +compatibility categories, tmux mouse and detach/reattach, fresh attachment modes, +and nested keyboard restoration. Optional embedded-host checks remain pending. +Record actual pass/fail evidence before closing human-validation issues. Any +later merge, version bump or release requires separate authorization. diff --git a/docs/testing/v010-compatibility.md b/docs/testing/v010-compatibility.md new file mode 100644 index 00000000..112e1530 --- /dev/null +++ b/docs/testing/v010-compatibility.md @@ -0,0 +1,72 @@ +# v0.10 compatibility coverage (unreleased) + +The deterministic fixture suite runs through real nested PTYs and persistent zsh. +It is automated protocol coverage, not GUI or physical host certification. + +Run all checks with `npm run build`, `npm run typecheck`, `npm test` and +`git diff --check`. For a focused pass: + +```sh +env -u NO_COLOR node --import=tsx --test tests/compatibilityHarness.test.ts tests/muxInterop.test.ts +``` + +Existing color snapshots require truecolor; remove an inherited `NO_COLOR` for +that run. Dedicated no-color tests still set their own environment. On this local +machine default parallelism twice timed out in Terminal.app sandbox teardown; +a full `npm test -- --test-concurrency=4` passed. This is recorded as a teardown +concurrency concern, not a physical host failure or a hidden passing retry. + +## Category matrix + +| Category | Automated coverage | Optional real tools | Physical QA | +| --- | --- | --- | --- | +| Ordinary finite CLI | stdout/stderr, exit 7, lifecycle, journal ownership, persistent `$?` | Tool-specific behavior not evaluated by new fixtures | Pending git/gh/ls/eza/brew/npm/pnpm as available | +| Noisy finite | 1000 bounded lines, completion, folding, narrow resize, full copy/journal retained | Existing suite exercises command output | Pending representative real builds | +| Streaming | 650 ms cadence, LIVE, resize, Ctrl+C, no passthrough/folding | ping/tail/dev servers not evaluated by new fixtures | Pending | +| Canonical inline input | newline input, termios before/after, return | Real prompts not evaluated | Pending | +| Raw inline interactive | bracketed paste, keyboard protocol, mouse reports, resize, exit restore | Existing inline trust-picker fixture | Pending | +| Fullscreen TUI | alternate screen, full size, resize, protocol ownership, exit restore | Existing `less` and GNU screen tests where installed | Pending vim/nano/fzf/lazygit/btop | +| Agent-style UI | raw UI, paste, mouse, resize, Ctrl+C, clean composer return | No paid account required; actual agent tools not evaluated | Pending available tools | +| Nested PTY | node-pty inner agent, input/output forwarding and resize | tmux/GNU screen skip automatically if absent | Pending selection and key modifiers | +| Shell augmentation | Existing provider/adapter unit tests only | zoxide/Atuin real behavior not evaluated in this pass | Pending | + +The fixture file is `tests/fixtures/compatibility.mjs`: finite, noisy, streaming, +canonical, inline, fullscreen, agent and nested modes. Interactive modes clean up +keyboard/paste/mouse/cursor requests before exit. The nested mode uses the +existing node-pty dependency; no new packages, accounts or network are needed. +All fixtures and LiveSandbox resources have explicit teardown. + +## Host matrix + +| Host | Automated evidence | Optional local real tool | Current physical QA | +| --- | --- | --- | --- | +| Ghostty | capability, probe, renderer, frontend profile tests | Not a GUI launch | Pending | +| Terminal.app | baseline persistent editor/paste/resize fixture | Not a GUI launch | Pending | +| iTerm2 | passive profile and reattach fixture | Not evaluated | Pending | +| Kitty | passive profile and reattach fixture | Not evaluated | Pending | +| WezTerm | passive profile and reattach fixture | Not evaluated | Pending | +| tmux | conservative capability nesting, synthetic Shift mouse guards | tmux 3.7c passed local PTY tests | Physical terminal selection/key forwarding pending | +| Supacode / other embedded hosts | generic baseline, nested PTY and agent fixtures | Not evaluated | Pending; no support certification | + +## tmux ownership and limits + +Existing `muxInterop` and `liveHardening` coverage exercises NMSh inside tmux, +tmux inside NMSh, full size, resizing, shell job suspension, bracketed paste, +mouse-mode replay, pane/server closure and NMSh live-session reattach. The new +mouse-enabled client test detaches and reattaches an actual tmux client: NMSh's +frontend survives in the pane and the same live session remains attached. A +fullscreen fixture subsequently receives the pane size (minus tmux's status row) +and exits back to the composer. + +Both tmux and GNU screen remain optional. CI skips unavailable tools; deterministic +nested PTY coverage always runs. Mouse `on` was configured only on a private test +server. Tests never change user tmux configuration. Synthetic Shift reports test +NMSh guards; they cannot certify the GUI's native selection gesture. tmux owns +outer mouse handling, while the foreground TUI owns reports inside passthrough. +Use Ctrl+J for multiline if extended Shift+Enter forwarding is unavailable; +verify terminal/tmux selection modifiers physically. No multiplexer features or +automatic tmux escape tunneling are added to NMSh. + +See [multiplexer architecture](../architecture/multiplexer-interop.md) for older +observations and environment persistence limits. This new matrix does not extend +those historical observations into fresh physical passes. diff --git a/docs/testing/v010-hardening.md b/docs/testing/v010-hardening.md new file mode 100644 index 00000000..bbf518d9 --- /dev/null +++ b/docs/testing/v010-hardening.md @@ -0,0 +1,43 @@ +# v0.10 cumulative audit (unreleased) + +This audit reviews the cumulative stack above frozen #262. Automated evidence is +listed separately from physical host validation, which remains pending. + +| Boundary | Review and evidence | +| --- | --- | +| Host names and lifetime | General renderer consumes capability values. Profiles/integration stay under `src/host`; existing architecture tests pin imports. Each frontend resolves fresh capabilities; no host values enter session protocol or shell environment. | +| Attachment/reattach | Existing hostProfiles/live session tests preserve the creating shell and resolve fresh frontend modes. Interactive programs retain query ownership. | +| Probe cleanup and early input | 80 ms shared batch, paste exclusion, early-input replay and failure cleanup remain. Added regression for synchronous buffered input finishing before listener cleanup is assigned. | +| Keyboard stacks | Audit found complete pushes retained as carry, stale partial modes after reset, lost push/pop ordering and collapsed stack state. Fixed ordered bounded main/alternate stacks, counted pops, zero default flags and incomplete-only carry. Four new hardening tests cover this and probe cleanup. | +| Mouse and bracketed paste | Existing renderer pairing, synthetic Shift guards, inline/fullscreen and reattach tests; new compatibility fixtures cover raw reports/paste/resize and exit. Physical native selection remains pending. | +| Synchronized output | Owned redraw opens/closes in `finally`; raw passthrough does not use the renderer. Existing write-failure/suspend tests remain authoritative. | +| OSC and hyperlinks | Adjacent OSC scanner no longer greedily swallows close/SGR sequences. Payload controls rejected; unfinished OSC retention bounded; original links stay distinct from generated ones. Narrow wrapping and sticky truncation close links. | +| Stored/raw fidelity | Generated links use cell clones; parser/journal/history/copy do not receive generated bytes. Original program link metadata survives archive restore. Existing raw PTY/copy tests and new snapshot invariance tests cover the boundary. | +| Unknown baseline and overrides | Baseline remains keyboard-only, graphics/hyperlinks off without explicit evidence/override. Color precedence is unchanged. Existing hostColor/baseline/profile tests cover this. | +| Narrow layouts | Source-line recognition occurs before wrapping, so wrapped fragments retain the full target. Existing ScreenPlan and narrow layout checks plus hyperlink fixtures remain. | +| Lifecycle/teardown | Test runner owns its private root; fixtures terminate child resources; LiveSandbox waits for services and open references. Added timeout process identities. A preset test issued `/resume` after its PID output but before prompt completion; it now waits for the lifecycle row. Repeated local baseline teardown timeouts are recorded; a passing retry is not evidence that the concurrency concern is resolved. | +| Preview resources | No graphics backend was added after #151 research. No image bytes/IDs/cache or network-fetch path exists in NMSh presentation. | + +## Performance review + +`npm run bench -- hyperlinks` adds two bounded, informational cases with 1000 +URL-heavy source lines. Local Node 26.8.1 / macOS arm64 results (20 samples, +3 warmups): cold recognition p50 5.40 ms, p95 9.10 ms; cached access p50 0.06 ms, +p95 0.11 ms. Cached mean included an outlier (max 2.91 ms); do not interpret the +median as an end-to-end redraw guarantee. These cases exclude filesystem checks, +terminal writes and transcript wrapping. File recognition has a strict candidate +count but synchronous mounted-filesystem latency remains a known limitation. + +Recognition is cached by source/revision/cwd. It does not introduce a whole-history +recognition scan on redraw, although the existing transcript wrapping still +walks history. Weak source caches disappear when parser lines are released; +repository cwd cache is capped at 128, candidates at 16 and source length at 8192. + +No extra startup query, renderer mouse-motion policy or graphics allocation was +added. The existing startup batch remains bounded by 80 ms; prior checkpoint +measurements were about 80–83 ms and capability resolution about 0.145 μs/call. +Those prior measurements were not freshly remeasured here. Current tests cover +query timing/cleanup; no claim of physical startup latency is made. + +See [coverage matrix](v010-compatibility.md) and [additive physical +QA](v010-physical-qa.md). Package and lockfile remain at released version 0.7.0. diff --git a/docs/testing/v010-physical-qa.md b/docs/testing/v010-physical-qa.md new file mode 100644 index 00000000..67fdb495 --- /dev/null +++ b/docs/testing/v010-physical-qa.md @@ -0,0 +1,88 @@ +# Additive v0.10 physical QA (pending) + +This unmerged development stack has not shipped. Package/release version is +0.7.0. Automated protocol fixtures are separate from physical terminal validation. +Use the existing v0.8/v0.9 checklists for their features; the checks below add host +coverage. Do not install every host solely for this checklist. + +- [ ] Terminal.app: startup; Ctrl+J multiline; Ctrl+W word deletion; completion; + Ctrl+R history; inspector; F1/palette and keyboard block controls; resize; + INLINE/FOLDED/LIVE; long-running command; TUI passthrough and exit; `/copy`. +- [ ] Terminal.app: Safe glyphs, narrow widths, 256/16 colors and NO_COLOR; + restore a session previously displayed in an enhanced host. +- [ ] Ghostty: Shift+Enter, Option+Backspace, hover/click; Shift selection; + synchronized redraw if reported; links; detach and attach from another host. +- [ ] iTerm2 / Kitty / WezTerm where available: startup; `/settings` Status host + capabilities; input; resize; passthrough and exit; links. Graphics only if an + implementation is added (currently none). +- [ ] tmux: nested session, resize, alternate screen, mouse ownership, Shift + selection, passthrough, suspend/resume and detach/reattach. +- [ ] Available agent-oriented terminal: persistent shell, worktree cwd, resize, + agent CLI ownership and exit restoration. Accounts/tools remain optional. + +Every row remains pending until an actual human result is recorded. Host +configuration is not applied automatically. + +- [ ] iTerm2: mouse hover/click and native selection override with current host + settings; Ctrl+J fallback; Kitty keyboard only if startup probe confirms it. +- [ ] Kitty: enhanced keyboard push/pop across TUI exit and detach; Shift + selection with mouse reporting; no automatic remote-control configuration. +- [ ] WezTerm: keyboard encoding with `enable_kitty_keyboard` disabled/enabled; + fallback Ctrl+J; configurable Shift selection; synchronized output only when + the shared startup query reports support. +- [ ] Reattach Ghostty → Terminal.app → Kitty → iTerm2 → WezTerm where available: + new frontend capabilities, same shell cwd/exports, no stale input modes. + +## OSC 8 additions (#150) + +All checks below remain pending physical host validation. Test on available +hosts; installing every terminal is unnecessary. Repeat capable checks after +reattaching from a capable host to a baseline host and back. + +- Print `https://example.com/path?x=1` and `http://example.com`; visible text must + match, with clickable targets only when the attachment supports hyperlinks. +- From a command cwd containing `file.txt`, print `file.txt`, `./file.txt` and a + missing path. Existing paths open the correct local file; missing paths stay + plain. Change cwd and inspect the old command to verify its original context. +- In a GitHub repository with an explicit origin, print `#123`, `#tag` and + `#123abc`. Only the numeric reference links. Outside a known repository all + stay plain. GitHub resolves PR numbers through the issue URL. +- Print a program-owned OSC 8 label followed by an unlinked URL. Resize narrowly + and expand/fold output. Program target and generated target remain distinct; + no link extends into neighboring rows or the editor. +- Compare `/copy`, exported transcript/journal and command history before/after + enabling `NMSH_HYPERLINKS`; no generated OSC 8 appears in stored/copy text. +- Ghostty, iTerm2, Kitty and WezTerm: check host click modifiers and selection. + Terminal.app, unknown hosts and tmux baseline: text remains plain unless the + explicit `NMSH_HYPERLINKS=1` override is used; no escape garbage appears. + +## Compatibility additions (#14) + +- Run the deterministic fixture modes in `tests/fixtures/compatibility.mjs` from + an available host. Finite/noisy commands complete with intact history; streaming + stays LIVE through resize and Ctrl+C; interactive modes receive keys/paste and + return cleanly. Automated PTY passage does not certify their visual appearance. +- With tmux mouse enabled, try native Shift selection, Ctrl+J multiline fallback, + keyboard disclosure navigation, interactive mouse ownership and bracketed paste. + Detach/reconnect the tmux client: the same NMSh remains in the pane. Close the + pane and reattach the NMSh live session from another available host: capabilities + refresh while shell cwd/environment/state persist. +- In available Ghostty/Terminal.app/iTerm2/Kitty/WezTerm installations, try ordinary + CLI, noisy builds, streaming tools, vim/less/fzf, and available agent CLIs. Check + resize, Ctrl+C, Ctrl+Z/fg, clean exit and restored keyboard/mouse/paste modes. +- zoxide and Atuin require a real configured installation to validate integration; + no account/tool-specific behavior is claimed by the deterministic fixture suite. + +See [coverage matrix](v010-compatibility.md) for automated vs optional vs pending +status. All new physical entries remain pending. + +## Hardening and embedded-host additions + +- Reattach while a nested interactive UI owns enhanced keyboard modes; exit its + inner UI, then its outer UI. Each must receive the expected keys, and NMSh's + editor must regain its own modes without a stuck keyboard stack. +- If Supacode or a similar host is already available, use two worktrees with + distinct cwd/environment, run streaming and interactive fixtures, resize, hide + and close panes, then detach/reattach NMSh. Check app-level shortcut interception, + shell persistence, mode restoration and copy/journal fidelity. This remains an + unperformed interoperability pass; no dedicated vendor adapter is claimed. diff --git a/docs/testing/v011-acceptance.md b/docs/testing/v011-acceptance.md new file mode 100644 index 00000000..b2f9de17 --- /dev/null +++ b/docs/testing/v011-acceptance.md @@ -0,0 +1,213 @@ +# v0.11 cumulative development acceptance + +v0.11 is an unmerged development stack above frozen #266, not a release. Package +and lockfile remain 0.7.0. Physical terminal QA is explicitly deferred. + +Milestone [#11](https://github.com/raiseCatError/notMyShell/milestone/11) contains +only #52, #75, #73 and #17. All remain open. The frozen base is +`d3e77f703fcd8af71fffc0feba1275e590984cbe` (`docs/v010-final-acceptance`). + +## Runtime results + +[#267](https://github.com/raiseCatError/notMyShell/pull/267) extends the existing +CompletionSource pipeline with a lazy persistent configured-zsh helper. A hidden +zpty loads trusted HOME startup files once per generation. Its own widget invokes +completion knowledge and captures bounded compadd records; foreign ZLE/fzf-tab UI +never owns the NMSh editor. Existing candidates retain values, displays, +descriptions, groups, prefix/suffix data, replacement range and context provenance. +NMSh renders/inserts; Tab never submits a command. Explicit unquoted home paths +retain expansion, while quoted/escaped tildes remain literal. + +Startup/query deadlines are 1500/300 ms, output/candidates 1 MiB/4096. Buffer, +cursor, cwd and frontend generations reject stale results. Helper expiry is +60 seconds, with invalidation after managed prompts/cwd changes; no stat tree runs +per keystroke. Cancellation, expiry, timeout and failures restart the helper; +failures use a five-second cooldown and native fallback. Warm cancellation uses +a one-second cooldown so rapid edits cannot repeatedly reload executable config. +Middle-buffer legacy +fallback is suppressed when no safe replacement protocol exists. Shell-side +deadlines and explicit inner process-group cleanup survive frontend death. +No history is recorded by helpers. Configuration is executable trusted user code +and can cause external effects; isolation does not sandbox it. Config is never +sourced inside frontend JavaScript. See [completion design](../design/configured-completion.md). + +Native fzf-tab support remains **unsupported**. The optional existing fzf picker +can select among NMSh structured candidates, with native menu fallback when fzf is +missing. This is useful interoperability, not fzf-tab widget parity. + +[#268](https://github.com/raiseCatError/notMyShell/pull/268) captures name-only +alias/function metadata at real managed-shell prompt boundaries. Complete/partial +snapshots distinguish absence from truncation. Semantic generations cancel stale +classification; live names feed highlighting, completion and inspector without +executing definitions. Latest metadata survives journal acknowledgement and +reattachment. Complete snapshots reveal builtin/executable commands underneath +removed configured aliases; partial snapshots retain conservative positive knowledge. +Live positive names survive classifier failure. No bodies/environment values are +transported. See [native intelligence design](../design/native-shell-intelligence.md). + +The bounded lexical layer improves reserved words, assignments, redirects, +parameter/command/arithmetic expansions, quotes/escapes/globbing, pipelines, +background operators and common compound boundaries. It remains a pragmatic +lexical layer, not a complete zsh parser. Failure presentation relabels the existing +lifecycle row only with matching diagnostic/status evidence. Exit 127 alone does +not claim command-not-found. Raw PTY diagnostics, journals, `/copy` and correction +remain authoritative and unchanged. + +## Research decisions + +[#73](https://github.com/raiseCatError/notMyShell/issues/73) stays research-only. +The closed module-ID schema and migration/editing UX require an agreed model +before custom modules are persisted. No implementation child was created. A future +static-first model should reuse current placement/rendering/snapshot paths. +Command-backed sources would require explicit argv configuration, detached bounded +execution, sanitized output, asynchronous caching, generation cancellation and +bounded concurrency. Proposed limits are research values, not runtime promises. +No shell-string mode or custom command runtime was added. +See [prompt-module research](../design/custom-prompt-modules-v011-research.md). + +[#17](https://github.com/raiseCatError/notMyShell/issues/17) inventories zsh +assumptions across startup, PTY, hooks, cwd/environment/status, signals, history, +completion, metadata, semantics, prompt suppression and restore. Current provider, +lifecycle and transport contracts already isolate consumers; no small generic +extraction improves current behavior enough to justify a child. A future backend +surface must emerge from another shell's demonstrated behavior. No multi-shell +support or generic runtime adapter was added. +See [ShellAdapter research](../architecture/shell-adapter-v011-research.md). + +## Hardening and verification + +Hardening disposes hung/dead semantic helpers, bounds pending classification, +prevents uncached-result redraw loops, preserves home-path completion semantics +and keeps native helper descendants in their owned group. Automated fixtures +cover abrupt frontend death, timeouts, cancellation, real large capture, live names, +partial snapshots, acknowledged reattach, malformed records, stale results and +existing picker/passthrough/copy/journal/presentation behavior. + +A final job-control teardown identified an early completion helper orphan whose +argv matched the configured helper. Native capture also recorded its inner PID +only after an initialization command. Cancellation could kill either parent before +inner PID registration. Both zpty +parents now record PID from their own table before querying, retain a table-based +cleanup fallback, and receive at most 100 ms cancellation grace before forced +termination. The configured trap is installed before spawning. A delayed inner-shell +fixture deterministically covers pre-initialization cancellation; this failure was +fixed as a lifecycle defect rather than rerun as an unexplained flake. +Review also reproduced a TERM-ignoring descendant surviving its parent's close; +the close path now kills the remaining owned group, with a separate regression. + +The updated #268 Node 26 dispatch passed all assertions (828 passed, eight optional +tests skipped) but failed the suite-root check with one managed-shell ZDOTDIR. +Its Node 22 job passed. The root was removed by the runner after reporting, so its +exact origin is unknown; no unexplained retry was used to erase this failure. +Investigation separately confirmed that a transient removal error discarded the +owned path permanently and that synchronous PTY spawn failure leaked the bootstrap +root. Hardening now signals before removal, uses bounded retries, retains the path +for exit-event retry on failure, and cleans a failed spawn. A red/green fault-injection +regression and an isolated spawn-failure fixture verify those fixes. These defects +are confirmed; attributing the historical CI artifact to them would be an inference. + +Both initial updated-hardening and acceptance CI runs then exposed a test-fixture +race in the redraw regression: its real shell startup prompt could legitimately +schedule a second render while the test counted only classifier callbacks. The +fixture now disconnects shell events and kills its owned resources before making +the unchanged one-request assertion. This is a test isolation fix, not a relaxed +product assertion. A further red/green config-load counter verifies ten rapid +queries after warm cancellation use native fallback without reloading config. +The premature [#270](https://github.com/raiseCatError/notMyShell/pull/270) acceptance +PR was superseded unmerged to preserve published history while keeping runtime +and test fixes in #269. Only this replacement is the active final acceptance PR. + +A real nested-fixture readiness race was reproduced: terminal exit text can be +observed before the frontend's paste-restoration sequence. The harness now awaits +frontend ownership before making the unchanged restoration assertions. Earlier CI +failures and resource/host-load timing misses were not treated as unexplained green +reruns. Native helper cleanup failures were fixed in code. Under heavy host load, +cold helpers can miss their budget and gracefully fall back; timings below are +informational, not physical terminal certification or guaranteed budgets. + +Final published-code profiling on Node 26.8.1, darwin arm64, 8 GiB memory: + +| Work | Median ms | p95 ms | +| --- | ---: | ---: | +| Live name snapshot, 4096 (20) | 0.82 | 1.42 | +| Filter 500 candidates (20) | 0.04 | 0.11 | +| Parse 4096 configured records (20) | 3.03 | 5.97 | +| Cold configured startup/query (5) | 319.92 | 590.60 | +| Warm configured query (20) | 9.61 | 11.18 | +| Filesystem completion (20) | 10.43 | 11.08 | +| Queued cancellation (20) | 0.01 | 0.04 | +| In-flight cancellation (5) | 11.25 | 11.94 | +| Large-query return (5; **4 complete, 1 fallback**) | 258.03 | 315.85 | +| Native fallback (5) | 76.52 | 80.49 | +| Edit/layout/highlight fixture (20) | 8.73 | 11.20 | + +In-flight cancellation includes a controlled 10 ms fixture delay; each cancellation +sample prepares an independent warm generation outside its timed section. In this +final run four large queries completed with 4096 candidates and one hit its budget. +The table mixes both outcomes and is **not a successful-capture-only p95**. An earlier +published-code run hit the budget on all five queries (303.49/306.88 ms median/p95); +those were fallback timings, not capture throughput. A previous prepared run +completed all five samples with 4096 records, zero fallback and 198.65/210.38 ms +median/p95. Preparation runs outside timed queries. The real large fixture with +a 2000 ms test budget verifies the 4096 cap independently. Production keeps its +300 ms budget and asynchronous fallback rather than extending it to force parity +under load. Earlier samples reached ~1075 ms cold p95. Benchmarks remain +informational and reproducible through `npm run bench`; no physical validation or +guaranteed latency is implied. + +## Review order and automated evidence + +Each new PR targets its immediate predecessor; none is merged. + +| Order | PR / branch | Published head | Node 22/26 dispatch | +| --- | --- | --- | --- | +| Frozen base | [#266](https://github.com/raiseCatError/notMyShell/pull/266), `docs/v010-final-acceptance` | `d3e77f703fcd8af71fffc0feba1275e590984cbe` | [36932297398](https://github.com/raiseCatError/notMyShell/actions/runs/36932297398) | +| Configured completion | [#267](https://github.com/raiseCatError/notMyShell/pull/267), `feature/v011-configured-completion` | `0df8cd6772d18793ea0f0744c2fdc7ec70a61692` | [36944128030](https://github.com/raiseCatError/notMyShell/actions/runs/36944128030) | +| Native intelligence | [#268](https://github.com/raiseCatError/notMyShell/pull/268), `feature/v011-shell-intelligence-parity` | `f63f2f3cba3229ad2c8c106a1ca900bd7d8d5e54` | [36948391400](https://github.com/raiseCatError/notMyShell/actions/runs/36948391400) | +| Hardening | [#269](https://github.com/raiseCatError/notMyShell/pull/269), `feature/v011-shell-hardening` | `37244be9be7c79a27c6549d7e5f7a2d411eb54e6` | [36950450150](https://github.com/raiseCatError/notMyShell/actions/runs/36950450150) | +| Final acceptance | This docs PR, `docs/v011-cumulative-acceptance` | Exact head in PR verification comment | Exact-head dispatch in PR verification comment | + +Local runtime verification passed **845/845** serial tests (193.48 seconds), +`npm run build`, `npm run typecheck`, `git diff --check` and benchmark-script typing. +The initial #52 focused/full checkpoints passed 823 tests; #75 passed 836 before +the readiness-only fixture update. Later cumulative checks include every change. +Managed-shell cleanup added two further tests after the 842-test checkpoint, and +the config-reload regression added one. The updated hardening and final +acceptance heads receive full-suite runs and Node 22/26 dispatches; +its PR verification comment records the exact SHA and outcomes, including any +failure, rather than embedding a self-referential commit hash here. + +The test runner checks semantic, managed-shell, configured-completion and native +capture roots before deleting its private suite root. Post-hardening process +inspection found no test/completion/semantic helpers or suite roots. Private logs +remain ordinary files. Versions are still 0.7.0. Existing unrelated untracked +`.serena/` was preserved; tracked changes are committed through this PR. + +Preflight verified free space, fetched origin, #266's open/unmerged branch/SHA, +worktrees/status, stale helpers/private roots and package/lockfile versions. +After the user revoked the old preferred margin, the only disk checkpoint gate +was less than 1 GiB. Checks continued around full suites/builds; recent available +space was 4,120,000 KiB. No ENOSPC or unrelated-data cleanup occurred. Final free +space and temp/process checks are recorded in the acceptance PR comment. +Project access failed once for missing `read:project`; no board update is claimed. +Issue comments and the unmerged PRs are the durable work record. + +## Limits and deferred work + +Live-only compdefs/function bodies and managed-shell PATH/environment changes are +not replicated into completion helpers. Completion prefix/suffix callbacks and +arbitrary ZLE state mutation are outside v1. Complex/nested/multiline replacement +contexts use conservative fallback; middle-buffer native insertion is intentionally +unavailable. Explicit `~/` is supported; named-directory/user tilde expansion is +not claimed as completion parity. Name snapshots are capped at 4096 names/64 KiB/128 code points and a +safe Unicode transport alphabet; partial snapshots cannot prove every removal. +Shell failure labels cover safely matched simple diagnostics. Heredocs, wrappers +and complete shell grammar remain outside semantic parsing. + +Custom prompt modules and multi-shell runtime support remain future design work. +Linux, Windows/ConPTY, Chroma effects and frontend/TUI landscape research were not +started. [Additive physical QA](v011-physical-qa.md) contains only v0.11 checks for +configured completion, alias/function/syntax/failure behavior and relevant host +ownership restoration. No physical validation has been claimed. Next: review the +stack in order, perform the deferred physical checklist, then decide integration +separately. No merge or release is authorized by this acceptance record. diff --git a/docs/testing/v011-physical-qa.md b/docs/testing/v011-physical-qa.md new file mode 100644 index 00000000..3b5aa929 --- /dev/null +++ b/docs/testing/v011-physical-qa.md @@ -0,0 +1,40 @@ +# v0.11 additive physical QA (deferred) + +Only v0.11 checks belong here. Existing milestone checklists remain separate. +Automated PTY fixtures do not certify these physical terminal behaviors. + +## Configured completion (#52) + +In Ghostty and Terminal.app where available: + +- Try a real configured command completion, options with descriptions, custom + compdef functions and aliases. Check groups and insertion without command execution. +- Complete paths containing spaces and Unicode, including an unfinished quote; + complete in the middle of a word with trailing arguments. Check the caret. + Check `~/` home files/directories and quoted or escaped literal tilde paths. +- Type/delete/move the cursor rapidly and change cwd. Old candidates must disappear. +- Change completion configuration, then retry after the 60-second helper expiry + or after a submitted command. Confirm refreshed knowledge and usable fallback. +- Dismiss with Escape; try repeated Tab. Test narrow width, Safe glyph and NO_COLOR. +- If fzf is already installed and selected as the picker, select/cancel/resize the + candidate picker. Confirm composer and terminal modes return. Missing fzf must + leave the native menu usable. This is not native fzf-tab UI support. +- Detach/reattach and run an interactive application: completion must never take + the active application's input or render foreign plugin UI into NMSh. + +No physical QA has been performed for v0.11. + +## Native shell intelligence (#75) + +- Define an alias and function in the managed shell. Type their names and open + the inspector; check alias/function highlighting and name completion. Remove + them and check freshness after the next prompt. No function should run merely + because its name is typed or inspected. +- Try assignments, leading redirects, pipelines/background operators, if/then, + quoted paths, parameter/command/arithmetic expansions and globbing. Incomplete + syntax must leave the composer usable. +- Run a missing simple command, then a function that returns 127 internally. + Only the matching zsh diagnostic should produce the command-not-found label. + Try a parse error. Real diagnostics must remain visible, with one lifecycle row. +- Check correction acceptance still edits only; compare `/copy` and stored journal + output with the actual diagnostics. Detach/reattach and verify live names refresh. diff --git a/docs/testing/v012-acceptance.md b/docs/testing/v012-acceptance.md new file mode 100644 index 00000000..9d7efd6e --- /dev/null +++ b/docs/testing/v012-acceptance.md @@ -0,0 +1,92 @@ +# v0.12 cumulative development acceptance + +Development stack is unmerged and unreleased. Physical QA is intentionally deferred. The released package and lockfile remain **0.7.0**. No merges, tags, releases, history rewrites or lower-layer branch edits occurred. + +## Resource and frozen-base verification + +Initial free disk: 4,240,000 KiB (~4.04 GiB), above the user-authorized 1 GiB hard stop. No dependency restoration or generated media was required. Checks before/after suites remained above the threshold; latest pre-acceptance reading was 5,864,376 KiB (~5.59 GiB). No ENOSPC event occurred and no unrelated data was removed. + +GitHub and fetched origin both verified PR #271 (`docs/v011-cumulative-acceptance`) at `4a5ef78968c6e4751ac977b39170514a4ec9e282`, open, non-draft, unmerged, mergeable, based on `feature/v011-shell-hardening`. Existing worktrees were inspected and left unchanged. `.serena/` is preserved as untracked user data. + +## Milestone, findings and exact review order + +[Milestone 12](https://github.com/raiseCatError/notMyShell/milestone/12): #78, #179, #180, #272, #274, #276. All remain open. #79 remains a compatibility reference and was not added. Project status updates were unavailable because the token lacks `read:project`; issue/PR activity and child `needs-human-test` labels provide tracking. + +| Order | PR / branch | Exact implementation SHA | Base | +| --- | --- | --- | --- | +| Frozen | [#271](https://github.com/raiseCatError/notMyShell/pull/271) `docs/v011-cumulative-acceptance` | `4a5ef78968c6e4751ac977b39170514a4ec9e282` | v0.11 hardening | +| 1 | [#273](https://github.com/raiseCatError/notMyShell/pull/273) `feature/v012-chroma-treatments` | `71472b70b9e01b0a3822da09173ef3c8f9dd8d29` | #271 | +| 2 | [#275](https://github.com/raiseCatError/notMyShell/pull/275) `feature/v012-transient-effects` | `0815d909752552d16f5eb5480c2af2b563d900c9` | #273 | +| 3 | [#277](https://github.com/raiseCatError/notMyShell/pull/277) `feature/v012-presentation-hardening` | `45620a1f0a05e181b599b8ccbea4b8053b16735d` | #275 | +| 4 | Final acceptance `docs/v012-cumulative-acceptance` | See final PR head/commit metadata | #277 | + +Review in this order without merging. No implementation layer depends on deferred physical QA to start later work in this milestone. + +Research comments: [#78 architecture](https://github.com/raiseCatError/notMyShell/issues/78#issuecomment-5944765870), [#179 landscape](https://github.com/raiseCatError/notMyShell/issues/179#issuecomment-5944836863), [#180 frameworks](https://github.com/raiseCatError/notMyShell/issues/180#issuecomment-5944837102). Implementation children: [#272 treatments](https://github.com/raiseCatError/notMyShell/issues/272), [#274 effects/clock](https://github.com/raiseCatError/notMyShell/issues/274), [#276 hardening](https://github.com/raiseCatError/notMyShell/issues/276). + +## Treatment architecture + +`src/chroma/treatment.ts` samples owned plain graphemes/cells against explicit time and display-column position. It reuses Chroma color references/interpolation, Motion's waveform and existing capability/colorEscape logic. No sampler owns timers. `sampleTreatment` yields a cell foreground; `treatmentText` produces a styled projection without changing content or display width. + +Independent axes: + +- Preset: Off (default), Lavender (#A67CF3 and nearby purples), restrained Aurora, Chroma Theme, Custom. +- Geometry: Linear left-to-right, Center outward, Outside inward. +- Motion: Static, Travel (six-second cycle), Breathe (four-second cycle). Intensity blends against the ordinary surface foreground. + +Eligibility roles are Native identity, divider, panel frame and effect. Status, focus, raw output and provider roles reject treatment. Representative integrations: Native Minimal/Outline identity modules (project/cwd/toolchain, excluding custom foreground overrides), static historical divider rules under Normal/Chat, Settings framing and the live composer rule across Bottom/Top/Flow. Filled prompts, selected rows, error/warning/success semantics and arbitrary provider-rendered content retain their established styling. No automatic animation of every surface. + +Static treatments remain available with Reduced Motion or Effects Off. Reduced Motion also honors NMSH_REDUCED_MOTION and NMSH_DETERMINISTIC, freezes decorative phases and suppresses transients. Effects Off independently suppresses decorative movement/transients. Factual durations continue. Deterministic tests supply explicit timestamps/seed; no wall-clock randomness. Interactive deterministic sessions stay static. + +Color downgrade remains truecolor → ANSI256 → ANSI16 → none through existing host capability detection. Explicit NMSH_COLOR overrides retain precedence. NO_COLOR preserves text and meaning. Safe glyphs and Unicode display widths remain supported. + +## Scheduling, effects and restoration + +`PresentationClock` is the single demand-driven frame owner shared by TerminalApp activity, #91 TaskProgress and welcome blink subscriptions. Motion and treatments consume time. Decorative frames run at 10 FPS maximum; welcome uses sparse deadlines; reduced task progress updates factual time at 1Hz. No subscriber means no scheduled timer. Shell/task timeout timers are resource deadlines, not animation clocks. + +`/effects sparkles|rain [top|bottom]` is internal frontend behavior; `/effects stop` and Escape cancel. Triggers are user initiated, deterministic-seeded and replace the active effect. No event-triggered celebration is installed. At most 64 particles, three seconds, 512 columns × four rows of effect canvas; one active effect with no persisted particles. Placement selects the first/last eligible gap or decorative rule from ScreenPlan. No eligible region means cancellation. Full canvas/panel-side effects are deferred. + +Ordinary shell execution, resize, passthrough/fullscreen, suspend, detach/stop and terminal write errors cancel effects. Underlying projected rows are retained and repainted from current state when the effect ends; raw content, editor source, journal, history, resume and copy data never receive overlay escapes. Terminal modes remain owned by existing handoff/renderer machinery. Animation ticks reuse cached rows: activity updates its own region, task progress updates its panel while geometry is stable, and decorations update only their regions. Geometry/state changes use the ordinary full render path. + +## Settings and compatibility + +Settings v2 exposes preset, Reduced Motion and Effects Off in Simple; geometry, motion and intensity are Advanced. Existing row order, changed markers and reset behavior are retained. `presentation` is an additive normalized object; missing values preserve the previous ordinary presentation. No schema version or destructive reset is required. Custom gradients use 2–8 validated `#RRGGBB` stops, entered through config JSON; the GUI selects an already valid Custom preset. Malformed/oversized lists safely disable Custom. Only declarative settings persist. + +Config save preserves unknown object fields along the bounded normalized schema, including presentation fields, and flattens the legacy prompt wrapper to prevent stale provider/theme values shadowing current settings. No executable expressions or public plugin runtime was added. + +## Research decisions + +[Frontend landscape](../architecture/terminal-frontend-landscape-v012.md) compares Warp/Wave blocks and editor/widget ownership, Fish/Nushell/Xonsh shell semantics, tmux/Zellij/TUIOS multiplexing, prompt/history tools and modern terminal UI systems. NMSh remains a host-independent frontend over a real persistent shell, with owned composer, semantic transcript, optional providers, customization and resumption. It is not a terminal emulator, replacement shell language, multiplexer, IDE, cloud collaboration platform or agent manager. + +[TUI primitive findings](../architecture/tui-primitives-v012.md) cover Bubble Tea, Lip Gloss, Bubbles, Huh, Glamour, OpenTUI, Ink, Ratatui and Textual. Borrow explicit lifecycle/messages, focus semantics, measured layouts, composition and diffing. Do not migrate frameworks. The only justified new primitive is the shared presentation clock already implemented in #274; no duplicate primitive child was manufactured. + +## Performance and hardening + +Run `node --import=tsx scripts/presentation-benchmarks.ts`. Local Node 26.8.1/macOS arm64 instrumented samples: + +| Workload | Median / p95 ms | +| --- | --- | +| Static 40-cell prompt sampling | 0.029 / 0.067 | +| Animated 40-cell prompt sampling | 0.028 / 0.050 | +| Animated 120-cell divider | 0.061 / 0.123 | +| Cached effect + cell instrumentation, six layouts | 0.031–0.038 / 0.046–0.147 | +| Resize plan/effects at 20, 80, 320 columns | 0.030 / 0.043 | +| Fresh static 1,000-command transcript projection | 307.887 / 448.875 | + +Cached effects changed one row, at most 46 particle cells in this fixture. Renderer work was 105 writes (100 samples plus five warmups), ~108 KiB total. Five timer wakeups in 550ms; zero subscribers and no scheduled timer afterward. Aggregate CPU per 100 instrumented effect frames was ~4.7–12ms. These are local computation measurements, not physical host or end-user CPU claims. A fresh transcript projection remains expensive; animation regressions assert no `wrapped()` scans for decorative, activity or stable task-panel frames. + +Dedicated hardening fixed interrupt priority, motion settings propagation, welcome reconciliation, terminal error cleanup, absent-canvas cancellation, cached activity/panel frames, unknown config fields and legacy-wrapper migration. A focused independent safety review and recheck found no remaining important defect in the reviewed fixes. Lower published branches were untouched. + +## Automated evidence and remaining limits + +Treatment: 851/851 full tests. Effects: 856/856. Hardening: 858/858, plus focused lifecycle/deterministic/session tests. Build, typecheck and diff-check passed. Benchmark typing passed with `npx tsc --ignoreConfig --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --strict --skipLibCheck scripts/benchmarks.ts scripts/presentation-benchmarks.ts` (TypeScript 7 requires explicit command-line configuration and Node types). + +[Node 22/26 treatment CI](https://github.com/raiseCatError/notMyShell/actions/runs/36958741727) and [effects CI](https://github.com/raiseCatError/notMyShell/actions/runs/36959202103) passed. [Hardening CI](https://github.com/raiseCatError/notMyShell/actions/runs/36960106299) also passed on Node 22/26. Final acceptance CI is recorded in final PR evidence after completion. + +Failures were recorded and diagnosed: initial inherited NO_COLOR/override conflicts; Settings row ordering regression; legacy-wrapper shadowing; a canonical stty fixture completion/echo race; missing import and test-fixture API errors during focused development. The final fixture waits for persisted baseline completion. One local acceptance run stalled in the sticky-header worker with a live zsh child. The owned runner was terminated; the same file passed 13/13 in isolation. A cumulative rerun uses a 60-second per-test timeout. The stall was not reproduced and no product cause is asserted. No failure was silently retried without explanation. + +Final cumulative run: 858/858 with zero failures, cancellations or skipped tests. Build/typecheck/diff-check and benchmark typing passed. After completion, no NMSh helper processes, private test roots or semantic/zsh/capture/completion temp roots remained. The private-root runner reports lifecycle leftovers before cleanup. Presentation cleanup tests finish with zero clock subscribers and no scheduled timer. Existing unrelated replay temp directories and `.serena/` were retained. No dependency restore or large media generation occurred. + +Limits: Native modules and panel/history treatment are static; animated treatment currently targets the live rule. Filled prompt styles retain their prior rendering. Custom stops require config editing. Effects use only gaps/rules, fixed seed and two placements; no full-canvas, automatic triggers or effect palette editor. Fresh large-transcript projection cost remains a baseline limitation. No accessibility or physical animation-quality claim is made. + +Physical QA: [v0.12-only checklist](v012-physical-qa.md), Ghostty primary and Terminal.app fallback. No new host installation is required. Research parents and milestone remain open. v0.13, multi-shell/platform work and a large public plugin API are deferred. diff --git a/docs/testing/v012-physical-qa.md b/docs/testing/v012-physical-qa.md new file mode 100644 index 00000000..27781044 --- /dev/null +++ b/docs/testing/v012-physical-qa.md @@ -0,0 +1,39 @@ +# v0.12 additive physical QA + +Status: deferred, not performed. This checklist adds only v0.12 checks to prior milestone QA. No v0.12 release or merge is authorized. Package version is 0.7.0. + +Use Ghostty as the primary host and Terminal.app as fallback. Another already available capable host is optional; installing a new terminal is unnecessary. Build the final cumulative branch with `npm run build`, then launch `npm run nmsh` from a normal shell, outside a managed NMSh session. + +## Treatments + +- [ ] In `/settings`, select Lavender, Aurora and Theme. Use `/prompt` to choose Native Minimal/Outline; identity modules get the treatment, while Git/error states keep their semantic colors. Filled prompt styles retain their existing contrast rules. +- [ ] Check live separator, historical divider and Settings frame. Saved command source and `/copy` text stay unchanged. +- [ ] In Advanced settings, try Linear, Center outward and Outside inward geometry, Static/Travel/Breathe motion and intensity levels. Only the live rule animates; Native modules, history and panels remain static. +- [ ] Reduced Motion holds decorative presentation still and suppresses `/effects` previews; task progress still reports factual elapsed time. Effects Off suppresses decorative movement/transients while static treatment remains. +- [ ] Repeat with `NO_COLOR=1 npm run nmsh`, and with `NMSH_COLOR=256 npm run nmsh`. Explicit NMSH_COLOR has its existing precedence over NO_COLOR. Safe glyph mode remains legible and usable without Nerd Fonts. +- [ ] With NMSh stopped, optionally set `presentation.customStops` to 2–8 `#RRGGBB` colors and preset `custom` in the existing config JSON. Invalid or oversized custom lists normalize to Off; existing prompt/theme settings remain intact. The GUI does not edit color stops. +- [ ] Starship/Powerlevel10k prompts and captured external welcome output retain their own colors. Focus, selection, warnings and errors remain clear. + +## Layout matrix + +- [ ] Bottom + Normal +- [ ] Bottom + Chat +- [ ] Top + Normal +- [ ] Top + Chat +- [ ] Flow + Normal +- [ ] Flow + Chat + +For each combination, try a narrow terminal, resize, multiline input, keyboard selection, scroll back and return to FOLLOW. Treatment changes must preserve geometry and input behavior. + +## Transient effects + +- [ ] Run `/effects sparkles top`, `/effects sparkles bottom`, `/effects rain top` and `/effects rain bottom`. Only available NMSh-owned gaps or rules are used; these are relative to eligible chrome, not arbitrary terminal edges. +- [ ] Let the effect finish (three seconds). The current underlying screen returns without an archived effect row or altered shell output. +- [ ] Cancel with Escape and `/effects stop`; resize mid-effect. Trigger repeatedly: the new effect replaces the old one without accumulating activity. +- [ ] Immediately submit `sleep 10` after a preview, then press Ctrl+C. The command receives the interrupt. +- [ ] Start an existing fullscreen application (`less` or `vim` where available). Effects stop and the app owns the terminal. Exit normally; NMSh restores its current projection. +- [ ] With a service-backed session, detach/reattach and suspend/resume. Effects do not resume across ownership loss; allowed welcome motion resumes normally. +- [ ] With a long-running existing optional install task, Reduced Motion/Effects Off freezes task decoration while measured time and completion/error remain factual. Do not install a tool solely to perform this optional check. +- [ ] Close the terminal during a preview. No helper, task or presentation timer should remain because of the frontend. + +Do not use physical QA results to merge this stack without separate merge authorization. Record host, layout, settings, exact actions and observed PASS/FAIL; automated tests do not establish animation quality, contrast or physical host behavior. diff --git a/docs/testing/v013-adversarial-acceptance.md b/docs/testing/v013-adversarial-acceptance.md new file mode 100644 index 00000000..0ee8a7d1 --- /dev/null +++ b/docs/testing/v013-adversarial-acceptance.md @@ -0,0 +1,121 @@ +# v0.13 adversarial hardening acceptance + +This continues issue [#284](https://github.com/raiseCatError/notMyShell/issues/284) after the independent adversarial review and the disk-limited checkpoint. Implementation #287 and the fresh final acceptance PR remain **open and unmerged**. Physical QA is **deferred**, not passed. Package and lockfile remain **0.7.0**. No merge command, tag, release, force push or history rewrite was performed. The earlier fast-forward of implementation through the original docs snapshot caused GitHub to mark #288 merged automatically; that violated the requested unmerged state. #289 is preserved at its published checkpoint, and this fresh docs branch is directly above the final implementation without merging or rewriting either prior branch. + +## Verified starting point and review order + +Origin was fetched before work. These published heads were verified and left untouched: + +| PR | Branch | Head | +| --- | --- | --- | +| #283 | `docs/v013-cumulative-acceptance` | `e325bb30628b120837c28dd10d569e85ec7b0a12` | +| #285 | `fix/v013-post-qa-hardening` | `ae45bbeca692950fd71f7f6d3dd1db867739fda9` | +| #286 | `docs/v013-post-qa-acceptance` | `4f90c13421c874b6aee79e122ce0eef8bd78d5f3` | + +Final review order: +[#278](https://github.com/raiseCatError/notMyShell/pull/278) → +[#280](https://github.com/raiseCatError/notMyShell/pull/280) → +[#282](https://github.com/raiseCatError/notMyShell/pull/282) → +[#283](https://github.com/raiseCatError/notMyShell/pull/283) → +[#285](https://github.com/raiseCatError/notMyShell/pull/285) → +[#286](https://github.com/raiseCatError/notMyShell/pull/286) → +[#287](https://github.com/raiseCatError/notMyShell/pull/287) → the final acceptance PR (`docs/v013-adversarial-final-acceptance`). + +Implementation: `fix/v013-adversarial-hardening`, head `a8937c677268735cc5291d13d12eba11eebaabc3`, based directly on #286. Final docs: `docs/v013-adversarial-final-acceptance`, based directly on that implementation head. Issue #284 stays open; this unmerged work is not Done or Needs Human Test after integration. + +## Disposition + +| Review finding | Result and regression evidence | +| --- | --- | +| Early passthrough loses startup ownership | Fixed. Early less/vim against SIGINT-ignoring startup retain the notice and Ctrl+C recovery, with session/process cleanup. Startup recovery precedes raw passthrough routing, including reattach; heuristic passthrough starts at preexec after readiness. Normal post-ready passthrough remains covered by real PTY/tmux fixtures. | +| Oversized/cumulative startup input silently disappears | Fixed. The queue counts UTF-8 bytes and rejects an entire write that would exceed 65,536 bytes, retaining already accepted input. Exact boundary, boundary+1, cumulative writes, eventual readiness and abort are covered. Rejection identifies composer submission versus raw write; frontend awaitingExec/running state recovers, the rejected command stays in history, an empty composer restores it, and newer drafts survive asynchronous replies. Rejected raw input is restored or retained as a visible interaction. | +| Kitty state survives on main screen | Fixed. Renderer observes bytes actually forwarded to the physical terminal, including restored app modes. It tracks outstanding child pushes independently per screen, pops main entries on main, then alternate entries on alternate, and restores NMSh there. No global flattening. Byte tests seed host stacks on both screens and cover child screen switches, abnormal return, suspended exit and final exit to the outer shell. Real tmux mode reconciliation remains covered. | +| Competing stale preset recovery breaks exclusion | Fixed. Lock acquisition still atomically hard-links a private PID file. Recoverers atomically claim `presets.json.lock.recovery` using mkdir, then re-read ownership inside that exclusive guard before removing a dead owner's lock. A recoverer that observed stale ownership before a competitor acquired the lock now sees the live owner and refuses it. The deterministic A/B/C interleaving pauses A after stale inspection and B inside mutation; C cannot acquire while B writes. Ordinary concurrency, dead recovery, live/reused PID refusal and malformed/out-of-range ownership remain covered. | +| Mixed-version service bypasses startup safety | Fixed through capability negotiation without a protocol bump; see below. Tests use frozen real #283 service/shell/protocol/frontend implementations, not only decoder assertions. | +| Clipboard EPIPE/descendant leak | Fixed. Stream errors reject even when backend exit is zero; success requires successful stdin finish and zero exit. Failed/timed-out backend process groups are killed, including ordinary descendants. Successful selection owners survive. Tests observe descendant PIDs and explicitly clean their own successful owner. Backend selection is unchanged. | +| Startup tail claims 2 KiB but counts characters | Fixed. Sanitized tail is at most 2,048 UTF-8 bytes, raw retention at most 16,384 UTF-8 bytes; suffix trimming never splits a code point. Multibyte regression added. | +| Weak test assertions | Strengthened: explicit queue-rejection assertions, submitted held command survives reattach and executes exactly once, local mise file fact itself changes on edit, descendant-PID clipboard assertions, real mixed-version exchange, inherited main/alternate keyboard state. Notification/effects foreground fixtures now establish initial shell readiness. | +| Ubuntu BACK-FROM-LESS flake | The original failure was not conclusively proven harmless. The test previously sent q and immediately submitted the next command. It now waits for the less completion journal and a returned composer before submitting. Ten consecutive real-tmux runs passed locally, including runs alongside the full suite; there are no sleep increases or retries. | + +## Protocol compatibility + +The existing v2 decoder validates known fields, ignores additive fields, and contains unknown live message types. The new `welcome.startupSafety = 1` advertises pre-ready isolation, startup state reporting and explicit bounded rejection. New frontends check it **before sending create or attach**. Optional input `submission` metadata and `input-rejected` replies preserve frontend recovery semantics. + +- **New frontend / new service:** startup state is reported, accepted input is held until readiness, overflow is explicitly rejected and composer state recovers. +- **New frontend / old service:** create/attach are refused before ownership or input transfer, even if the old session happens to be ready. Administrative listing/termination remain available. New-session connection may safely fall back in-process with a factual notice; attach has no fallback. End old live sessions intentionally and let the service exit before reconnecting to start the updated service; no existing session is automatically terminated for upgrade. +- **Old frontend / new service:** additive fields are ignored and normal commands remain compatible. The new shell holds input through startup, including detach/reattach. Legacy frontends cannot display the new startup notice or rejection UI; update the frontend to obtain that presentation. + +The legacy-service test establishes a real blocked read, verifies refusal leaves its detached session untouched, and then demonstrates that a legacy client can answer that same read. The reverse-version test submits before readiness, reattaches and observes exactly one execution after the gate opens. + +## Automated verification + +Local environment: macOS, Node **26.8.1**, `TERM=xterm-256color`, `COLORTERM=truecolor`, with `NO_COLOR` and `FORCE_COLOR` cleared. + +- Fresh full canonical suite at final implementation `a8937c677268735cc5291d13d12eba11eebaabc3`: **918 passed, zero failed** (910 runtime tests plus 8 ranking tests). +- Build, source typecheck, benchmark-script typing and `git diff --check`: passed. +- Ten consecutive real-tmux less ownership runs: passed. The additional nested-tmux startup/reattach/completion barriers also passed ten consecutive runs, including runs alongside the full suite. +- Owned temporary-root lifecycle check: no leaked semantic/zsh/completion/capture directories; test-owned roots removed. LiveSandbox also checks sessions, services and open process references before deletion. +- Final helper/test process scan: zero matches. +- Regression proof: unfixed #286 TerminalApp loses the startup notice for early less; unfixed #286 SocketSessionClient accepts the real legacy service. Queue rejection, stale interleaving, clipboard EPIPE and inherited Kitty stack regressions were also observed failing before their fixes. +- Exact macOS CI Node **22.23.2**: ten consecutive real reattach regressions passed, including concurrent full-suite load. The temporary official runtime/archive were removed after verification. +- Independent focused review found an asynchronous rejection that erased a newer draft. A real socket test reproduced it; the fix preserves both inputs and the follow-up review found no remaining material concern. + +Final implementation CI at **`a8937c677268735cc5291d13d12eba11eebaabc3`**: [run 37067891722](https://github.com/raiseCatError/notMyShell/actions/runs/37067891722), all four jobs passed on the first attempt after the reattach fix. + +| Platform | Node 22 | Node 26 | +| --- | --- | --- | +| macOS | Passed | Passed | +| Ubuntu 24.04 | Passed | Passed | + +The latest canonical suite and these CI results apply to the exact final implementation head above. Final docs CI is tracked on its own PR. + +An initial run inherited `NO_COLOR=1` and failed presentation expectations; two foreground fixtures also lacked initial readiness. The environment/fixtures were corrected and the final canonical run above passed. No physical validation is inferred from these automated runs. + +The first implementation CI attempt passed macOS Node 26 and both Ubuntu jobs. macOS Node 22 failed the unchanged `native fuzzy filtering preserves nested path capture context and cached insertion ranges` test: its candidate was absent after about 1.62 s, consistent with the capture helper's existing 1.5 s deadline under load. The exact cause is not proven. Twenty unchanged local repetitions passed (Node 26); only the failed CI job was rerun at the same implementation SHA. This is a recorded residual timing risk, not a silently discarded failure; no unrelated completion code, sleep or timeout was changed. + +Original implementation CI at `39923fc`: [run 37045796065](https://github.com/raiseCatError/notMyShell/actions/runs/37045796065). + +| Platform | Node 22 | Node 26 | +| --- | --- | --- | +| macOS | Passed (attempt 2) | Passed | +| Ubuntu 24.04 | Passed | Passed | + +## Resumed checkpoint and reattach root cause + +Resume fetched origin and verified #287 `2a4ca48e2bbe17426b37d61357224344abacc3ef` and #289 `fd16ac9c651515a9469a1b1dab407900f4b2db44`, both open. Disk recovered from 0.71 GiB to **4.77 GiB**. Existing history, worktrees and `.serena/` were preserved. + +Final-docs CI at `38284c8` exposed the nested-tmux marker race on Ubuntu Node 22. The marker could match NMSh's displayed command before tmux started, followed by unchecked kill-server. The checkpointed fix observes real pane dimensions, the reattached 110×36 client, successful shutdown and a completion journal. Ten repetitions passed; the fresh full suite now passes this corrected test too. The historical intermediate fixed-height assertion and disk stop are not final-head failures. + +Implementation CI [run 37061934895](https://github.com/raiseCatError/notMyShell/actions/runs/37061934895) at `2a4ca48` then exposed a real frontend handoff ordering gap on macOS Node 22. The picker selected No after Down, but Enter did not produce PICKED-1. Echoed `^[[B` is consistent with host canonical mode: CR can become LF, which the fixture ignores. + +The complete path was traced: service attach binds the controller, reports shell readiness/modes and sends replay completion; TerminalApp restores passthrough only after that replay and outside startupPending; onInput forwards raw data through SocketSessionClient.write and service input to the already-ready shell's PTY. Startup capability negotiation, queue isolation and the socket write path do not filter these keys. The gap was in TerminalApp.run: renderer handoff visibly restored the app's modes **before** host stdin raw mode and the data listener were installed. + +A deterministic real-service regression records stdin state inside the child frontend at the exact restored-mode write. Unfixed code reported `{raw:false,listening:false}` locally, including on Node 26, so the gap is not Node-22-specific. The fix installs raw mode and the input listener before entering the renderer and announcing passthrough. The test then sends Down+CR as one byte stream and checks PICKED-1 and a subsequent NMSh command. No sleep, retry or timeout was added. Ten repetitions passed on the exact macOS Node 22.23.2 runtime; all five interactive tests and the fresh 918-test suite passed. + +This final acceptance branch starts directly at the new implementation head. #289's published history remains intact; it is superseded by the fresh final docs PR. #288's accidental merged status is retained without undoing it. + +## Remaining limits and physical handoff + +- Recovery guards intentionally fail closed if a recoverer crashes while holding one. The error names both paths. Stop all NMSh preset writers and inspect them before manual removal. A reused live PID also requires inspection; it is never presumed dead. +- Kitty cleanup preserves inherited entries still present. A child that over-pops, resets terminal state, or causes the terminal's bounded stack to evict inherited entries can destroy state itself. This change does not reconstruct unknown entries that the child already destroyed. [Kitty specifies independent bounded screen stacks and oldest-entry eviction](https://sw.kovidgoyal.net/kitty/keyboard-protocol/). +- Clipboard failure cleanup covers descendants remaining in the isolated process group. A backend that deliberately creates a separate session escapes group cleanup. Real Wayland/X11 clipboard ownership remains untested. +- The composer is visible during the initial notice delay. Pre-ready submitted commands remain held; the notice stops Enter and offers abort, not an answer to hidden startup prompts. +- The original Ubuntu less failure is dispositioned by an ownership barrier and repeated regression evidence, not a claim that every timing failure is harmless. + +**Physical QA remains pending.** In Ghostty/Kitty/Terminal.app, try blocked `.zshrc` startup with early less/vim and ignored SIGINT; verify visible notice and Ctrl+C exit, no surviving session, then normal less/vim after readiness. Check raw keyboard/paste/mouse/resize and selection after an app dies on both main and alternate screens. On Linux desktops, test real wl-copy/xclip/xsel success and bounded failure. Follow the broader [v0.13 physical QA checklist](v013-physical-qa.md). Never close #284 based on CI alone. + +For the early-startup check, build the reviewed stack, then use a disposable HOME/config/runtime in the physical terminal (the command below is a handoff, not a test performed here): + +```sh +npm run build +qa_root="$(mktemp -d /tmp/nmsh-physical-XXXXXX)" +mkdir -p "$qa_root/home" "$qa_root/config/nmsh" "$qa_root/runtime" +chmod 700 "$qa_root/runtime" +printf '%s\n' '{"onboardingComplete":true,"glyphChoiceComplete":true,"updateChecks":false}' > "$qa_root/config/nmsh/config.json" +printf '%s\n' 'if [[ -t 0 ]]; then trap "" INT; read -k1 "?STARTUP-BLOCKED> "; fi' > "$qa_root/home/.zshrc" +env HOME="$qa_root/home" XDG_CONFIG_HOME="$qa_root/config" NMSH_RUNTIME_DIR="$qa_root/runtime" node dist/index.js +``` + +Immediately submit `less`, then repeat with `vim` using a fresh launch. The notice must appear after 1.5 s; typed q must not answer the hidden read; Ctrl+C must exit with startup-aborted feedback and end the session. Confirm no live session remains before removing the disposable root. Use a clean disposable `.zshrc` for post-ready less/vim and the broader physical tests. Do not use a recursively managed NMSh terminal for this check. + +Final acceptance disk snapshot: **4.01 GiB free**; continue only while free disk is at least 1 GiB. `.serena/` and existing worktrees were preserved. diff --git a/docs/testing/v013-cumulative-acceptance.md b/docs/testing/v013-cumulative-acceptance.md new file mode 100644 index 00000000..de60220d --- /dev/null +++ b/docs/testing/v013-cumulative-acceptance.md @@ -0,0 +1,162 @@ +# v0.13 cumulative development acceptance + +v0.13 is an unmerged development stack, not a Linux/Windows public release. +Physical QA is intentionally deferred. Package and lockfile remain **0.7.0**. +Independent-QA fixes are recorded in [post-QA acceptance](v013-post-qa-acceptance.md). +No lower published branch was rewritten; no merge, tag or release occurred. + +## Review stack and scope + +Frozen base [#278](https://github.com/raiseCatError/notMyShell/pull/278), +`docs/v012-cumulative-acceptance` at +`cfc9c08a5fb7b5f3bc637400a59da7a714a58ae4`, was verified against GitHub and fetched +origin before work and rechecked unchanged. It remains open, non-draft and unmerged. + +| Order | PR / branch | Published implementation SHA | +| --- | --- | --- | +| 1 | [#280](https://github.com/raiseCatError/notMyShell/pull/280), `feature/v013-linux-baseline` | `78010a140b675684a75640bdd0a2b5abb985e7a6` | +| 2 | [#282](https://github.com/raiseCatError/notMyShell/pull/282), `feature/v013-portability-hardening` | `3b0fc8ac9aba880bb0dd0fdc95e9d87e8ad73a9b` | +| 3 | Final acceptance/docs, `docs/v013-cumulative-acceptance` | This document's branch head | + +Each targets its immediate predecessor. Review #278 → #280 → #282 → final docs. +[Milestone #13](https://github.com/raiseCatError/notMyShell/milestone/13) tracks +research #18/#19 and implementation children #279/#281. Issues remain open with +this unmerged stack. Project field updates were unavailable because the token +lacks read:project; issue and PR activity records progress instead. + +Custom prompts #73, rich previews #151, layout redesign #79, additional shell +backends and v0.14 work are outside scope. + +## Platform findings + +[Linux foundations](../architecture/v013-linux-foundations.md) records actual +source findings, PTY dependency, shell discovery, paths, adapters and packaging. +The real persistent-zsh/session architecture is reused on Linux; bash is never +substituted. Ubuntu native binding installation and canonical PTY fixtures +provide runtime evidence, rather than compile-only platform guards. + +Narrow boundaries added/extended: executable zsh discovery, Linux secure runtime +path selection, absolute configuration defaults and factual existing Status +diagnostics. Existing TerminalHost capabilities and notification services remain +the boundaries for optional features. There is no generic OS adapter. + +Linux sockets prefer a private uid-owned XDG_RUNTIME_DIR/nmsh when its socket +path fits; invalid roots fall back to os.tmpdir()/nmsh-uid. Configuration uses +absolute XDG_CONFIG_HOME or ~/.config/nmsh. Journals stay under existing config. +macOS storage is unchanged. Unicode/spaces/symlinks/quoted HOME and absent HOME +have regressions. Optional Linux notifications are no-op; macOS keeps its bounded +privacy-preserving osascript backend. Terminal.app/Ghostty macOS window adapters +remain isolated; unsupported launchers show manual fallback. + +Linux revealed insecure global Ubuntu completion permissions before isolated +bootstrap; CI audits/repairs only system completion paths and keeps compaudit +enabled. Slow startup exposed a genuine initial-prompt command lifecycle race, +fixed above #278 for live submission and unacknowledged reattach replay. Pipe flushing and journal-based fixture checkpoints were also +corrected; detach fixtures require the service preexec marker, rather than +interpreting a submitted journal entry as proof that bootstrap has finished. +PTY chunk boundaries differ: beyond the cap, output including the tail can be +dropped by the existing retention policy. Tests require bounded retention and +a factual marker, complete output within limits, lifecycle and later execution. No divergent Linux PTY transport implementation was necessary. + +[Windows/ConPTY/WSL feasibility](../architecture/v013-windows-feasibility.md) +recommends **no-go for native Windows in v0.13**. node-pty 1.1.0 supplies ConPTY +transport, but zsh lifecycle/completion, signals/process ownership, named-pipe +ACL transport, executable/path/quoting and persistence semantics remain blockers. +PowerShell is not a drop-in backend. WSL with Linux Node/zsh is a candidate Linux +route, not native Windows or physically validated WSL support. No Windows child +or shell implementation was justified. + +Packaging: reviewed source checkout with Node >=22, zsh and native build tools, +then npm ci/build/link. private:true currently prevents registry publication. +No distro-specific installer child, deb/rpm/AUR or GUI packaging was added. + +## Automated evidence + +Local macOS arm64 / Node 26.8.1: **868 tests passed**, zero failure/skip; focused +lifecycle/status/semantic checks **12 passed**, replay/detach **9 passed**, retention/backlog **10 passed** +and tmux interoperability **8 passed**. Build, typecheck, benchmark-script +typing and git diff --check passed. Local inherited NO_COLOR was removed for +truecolor-pinned snapshots; dedicated NO_COLOR/baseline fixtures still ran. +Sandbox-denied process-inspection runs were invalid and excluded from acceptance. + +The final cumulative workflow runs macos-latest/ubuntu-24.04 × Node 22/26, +including native install, build/typecheck, benchmark typing, bounded timing, +canonical tests, diff and process leak checks. Feature push duplication is +disabled; no OS or Node lane was reduced. Per-file 120-second timeout bounds +compound lifecycle fixtures while their internal waits stay unchanged. History +latency tests run separately from competing PTY fixtures with the same budget. + +Implementation [CI run 37033963287](https://github.com/raiseCatError/notMyShell/actions/runs/37033963287) +at `3b0fc8ac9aba880bb0dd0fdc95e9d87e8ad73a9b` passed all four lanes: + +| Runner | Node | Total | Passed | Skipped | Failed | +| --- | --- | ---: | ---: | ---: | ---: | +| macOS | 22 | 868 | 860 | 8 | 0 | +| macOS | 26 | 868 | 860 | 8 | 0 | +| Ubuntu 24.04 | 22 | 868 | 864 | 4 | 0 | +| Ubuntu 24.04 | 26 | 868 | 864 | 4 | 0 | + +Build, typecheck, benchmark typing, diff and process-leak gates passed in each +lane. The acceptance branch reruns these same canonical lanes and adds existing +10k/100k transcript presentation timings. Its live CI result is on the final PR; +this table pins the completed implementation evidence rather than predicting +a future run. + +Earlier #280 Linux CI remains red at its frozen published head; runner security +and runtime fixes live in #282. Acceptance uses the later cumulative head, +without rewriting lower branches. Optional-tool skips must not be mistaken for +physical QA or exhaustive host coverage. + +## Performance and cleanup + +Three-sample timing probes measure initial PTY prompt, persistent-service prompt +and service socket removal. Existing benchmarks measure cold/warm configured +completion, composer planning and 10k/100k transcript wrapping/presentation. Values are informational; cross-OS equality +is not a requirement. History latency retains its regression budget. + +An idle local macOS sample measured PTY p50 23.15 ms, service 130.18 ms and +cleanup 11.89 ms. Under full-suite competition these rose to 74.10/706.21/10.11 +ms, illustrating why unlike runner workloads should not be compared equally. +The final local presentation samples measured 10k/100k wrapping p50 +12.38/178.52 ms, configured completion cold/warm 223.62/10.33 ms, and +composer planning ≤0.01 ms. Hosted timing/counts are recorded with CI evidence +below. + +Hosted implementation p50 timings (ms): + +| Runner / Node | PTY ready | Service ready | Cleanup | Completion cold / warm | +| --- | ---: | ---: | ---: | --- | +| macOS / 22 | 54.93 | 240.20 | 11.08 | 262.22 / 29.56 | +| macOS / 26 | 35.44 | 250.56 | 11.88 | 236.39 / 22.77 | +| Ubuntu / 22 | 477.91 | 650.99 | 10.23 | 643.49 / 7.04 | +| Ubuntu / 26 | 483.37 | 654.60 | 9.88 | 645.58 / 7.02 | + +Ubuntu global completion initialization contributes startup cost; warm queries +and cleanup stay bounded. Composer planning p50 was ≤0.04 ms in all lanes. +Readiness/cleanup probes enforce 10s/5s bounds, while history retains its 60ms +p95 budget. No comparison claims identical host load or cross-OS equality. + +Local final process inspection found no NMSh semantic/completion/session/test +processes. Private suite roots enforce/report helper-directory leaks before +removing only their own artifacts. Initial pre-existing name-replay temp data +and .serena were preserved. Empty screen roots from interrupted verification +and the identified owned interrupted bootstrap fixture were cleaned precisely. +No unrelated user data was removed. + +Disk started around 2.42 GiB free. Checks before/after full suites remained above +the user's 1 GiB hard stop; final available space is recorded in the handoff. +The threshold was not replaced with a larger comfort margin. + +## Remaining limits and later QA + +Use [v0.13 additive physical QA](v013-physical-qa.md) for install/startup/zsh, +ordinary/streaming/fullscreen commands, resize, persistence/reconnect, configured +completion, /tools, prompts, effects and baseline hosts. Start with Kitty then +GNOME Terminal; regress macOS Ghostty/Terminal.app. WSL checks apply only to the +Linux candidate route. Nothing here claims human validation. + +Linux CI covers Ubuntu x64, not every distro/architecture/terminal host. Trusted +user startup code can still block or fail; insecure completion configuration +requires user repair. Native Windows is unsupported. Very long explicit runtime +socket paths can fall back factually to in-process operation. Registry publishing, +distro packages and desktop notification parity remain unimplemented. diff --git a/docs/testing/v013-physical-qa.md b/docs/testing/v013-physical-qa.md new file mode 100644 index 00000000..7cb283e2 --- /dev/null +++ b/docs/testing/v013-physical-qa.md @@ -0,0 +1,54 @@ +# v0.13 additive physical QA (deferred) + +These checks cover portability only. No result below is marked passed. +Use the final cumulative v0.13 branch after review; package version is 0.7.0. +Automated Ubuntu PTY fixtures do not validate a physical Linux terminal or WSL. + +## Linux + +Start with Kitty, then GNOME Terminal baseline; optionally compare WezTerm, +Konsole and Alacritty. Record distro, architecture, Node/zsh versions, host, +commit, actual result and failures. Do not promise every host. + +- Install Node >=22, zsh and native build tools. From the reviewed checkout run + `npm ci`, `npm run build`, `npm link`; verify `nmsh --version` names this build. + Missing zsh must report that zsh is required; it must not select bash. +- Launch `nmsh --new`. Verify composer, context, keyboard, prompt and `/tools`. + `/status` should report factual platform, architecture, Node and capabilities. +- Submit a command immediately during slow trusted shell startup. Its initial + readiness prompt must not create an empty completion or duplicate record. +- Execute `printf 'λ 世界\n'; pwd`; output remains raw and completion is factual. + Submit two commands setting then printing a variable; persistent zsh state + survives. Test spaces/Unicode/symlink working directories and home paths. +- Run a streaming command (`for i in {1..20}; do print $i; sleep .3; done`). + Resize repeatedly, inspect history then return to FOLLOW. Ctrl+C restores + editing without corrupting transcript/history; `/copy` stays plain text. +- Run an installed fullscreen TUI such as `vim -u NONE`. Keys, paste, resize and + exit return terminal ownership to NMSh. Test Ctrl+Z/`fg` with an ordinary job. +- Detach with the session action, use `nmsh --sessions`, then + `nmsh --attach `. Same shell/cwd/variables and backlog survive. Reboot or + distro shutdown ends live shells; recovery must not claim shell survival. +- Test a trusted zsh `compdef` and filesystem completion, including Unicode, + quoted spaces and directory symlinks. Partial composer input is never run. +- Try `NO_COLOR=1 nmsh --new`; effects/presentation off/on and Kitty-capable + keyboard/protocol smoke. Baseline hosts retain conservative capabilities. +- With XDG runtime available verify private socket storage; without it verify + fallback and reconnect. Config/journals remain in documented paths. Linux + notifications are no-op; missing optional integrations cannot block startup. +- Confirm `/zsh` restores terminal modes and launches ordinary zsh. Recursive + NMSh from its managed shell is still rejected. + +## macOS regression + +On Ghostty and Terminal.app check startup, completion, persistent sessions, +resize/fullscreen passthrough, existing notifications, configuration paths and +transient effects. HOME quoting must preserve user startup configuration. + +## WSL candidate route (unvalidated) + +Only test the Linux build inside a WSL distribution, using Linux Node/npm/zsh +and the Linux filesystem. This is not native Windows support. In Windows +Terminal verify startup, Unicode, completion, ordinary/streaming/fullscreen +commands, resize, detach/reattach within the same distro/uid, and truthful +recovery after distro shutdown. Keep sockets/journals off `/mnt/c` for baseline +QA. Native Windows has no runnable supported baseline in v0.13. diff --git a/docs/testing/v013-post-qa-acceptance.md b/docs/testing/v013-post-qa-acceptance.md new file mode 100644 index 00000000..b6670fc6 --- /dev/null +++ b/docs/testing/v013-post-qa-acceptance.md @@ -0,0 +1,120 @@ +# v0.13 post-QA acceptance + +This updates the [cumulative v0.13 acceptance](v013-cumulative-acceptance.md) after +three independent QA passes. v0.13 remains an unmerged development stack. +**Physical QA is still deferred and nothing here claims human validation.** +Package and lockfile remain **0.7.0**; no merge, tag, release or force push occurred. + +## Review stack + +[#278](https://github.com/raiseCatError/notMyShell/pull/278) → +[#280](https://github.com/raiseCatError/notMyShell/pull/280) → +[#282](https://github.com/raiseCatError/notMyShell/pull/282) → +[#283](https://github.com/raiseCatError/notMyShell/pull/283) (`e325bb3`, untouched) → +[#285](https://github.com/raiseCatError/notMyShell/pull/285), `fix/v013-post-qa-hardening` +(`ae45bbeca692950fd71f7f6d3dd1db867739fda9`) → this PR. Implementation issue: +[#284](https://github.com/raiseCatError/notMyShell/issues/284), milestone 13, left open. + +QA originally froze #282 at `4f74efe`; #282 later advanced to `3b0fc8a` with startup +lifecycle fixes. Each finding below was re-checked against the final #283 source. + +## Disposition of the confirmed findings + +| Finding | Re-check on final source | Result | +| --- | --- | --- | +| M1 settings persistence | Reproduced: malformed/unreadable config replaced by a save; stale frontends overwrote each other | Fixed | +| N2 abnormal foreground app mode leak | Reproduced with the real frontend (SIGKILL fixture) and real tmux 3.7c | Fixed | +| M2 stale preset lock | Reproduced: a dead owner's lock blocked create/delete/acknowledge | Fixed | +| N1 interactive zsh startup prompt | Reproduced: `read -k1` in `.zshrc`; the v0.13 early-submit fix did not address it | Fixed (abort supported; explicit prompt input not offered) | +| M3 Linux clipboard | pbcopy only | Implemented | +| L1 monotonic scheduling | Reproduced: a backward wall clock stalled frames | Fixed | +| L3 welcome test path | Depended on the checkout name | Fixed | +| L4 notification slash | Any leading `/` suppressed the notification | Fixed | +| L5 mise cache identity | `mise.local.toml` / `.mise.local.toml` ignored | Fixed | + +**M1.** An existing config that cannot be read or parsed is never replaced; the error +names the path and is shown in the transcript or panel. With a base state, only +changed settings are written over a fresh read, absent keys are filled in, and +unknown fields survive. Writes remain atomic. A narrow read-to-rename window +between two simultaneous writers remains; there is no locking subsystem. + +**N2.** When passthrough ownership returns, or NMSh exits while an app still owns +the terminal, the renderer re-asserts the alternate screen, resets mouse modes +?1000/?1002/?1003/?1005/?1006/?1015 that NMSh does not want, restores keypad and +cursor-key modes, pops the whole keyboard-protocol stack and re-applies NMSh's own +modes. Modes NMSh requires stay enabled. Baseline hosts receive these resets +(idempotent) but never an enabling sequence; the baseline-host test was narrowed +accordingly. Coverage: + +- a byte-stream terminal model fed the renderer output plus a fixture that sets every mode and dies without cleanup, compared with a clean run; +- a real-frontend fixture (nested node-pty) killed with SIGKILL, with a precondition that the leak was on the terminal; +- a bounded real-tmux 3.7c test comparing pane mode flags before and after the kill. It was observed failing without the fix. It is distinct from the nested node-pty fixtures. + +Not covered: the main-screen keyboard stack, and terminals other than tmux. + +**M2.** The preset lock follows the transcript-store ownership protocol. Dead owners +are recovered; live, reused-PID or unreadable owners are refused with the lock path +and left in place. A PID reused by an unrelated live process therefore fails safe +and needs manual removal. Writers wait up to one second. + +**N1.** The shell holds input until its first prompt (64 KiB bound), so type-ahead +cannot answer a startup prompt; the early-submit flow from final v0.13 is preserved. +After 1.5 s (`NMSH_STARTUP_NOTICE_MS`) the frontend shows an explicit state with +the last 2 KiB of sanitized startup output (no escape sequences or controls), +holds Enter and aborts on Ctrl+C. A reattach to a still-starting session shows the +same state. The protocol gained an additive `startup` message and an optional +`startup` field on `attached`; the version number is unchanged. + +Limitations: the composer is visible for the first 1.5 s; sending text to the +startup prompt is not offered; an abort ends the session; commands submitted +before the delay are queued by the shell rather than rejected; startup output is +shown only while the shell has not reached its first prompt. + +**M3.** Linux uses `wl-copy` under Wayland, then `xclip -selection clipboard` or +`xsel --clipboard --input` under X11, resolved from PATH, argv-executed, payload on +stdin, 3 s timeout, 1 MiB cap. No backend gives a "Clipboard unavailable" message; +failures never affect command execution. pbcopy is unchanged. Backend selection is +tested with fake executables, not a desktop. Not tested: real Wayland/X11 +clipboards. OSC 52 is deferred because TerminalHost has no explicit capability for it. + +**Low findings.** L1: scheduling uses `performance.now()`, callbacks still receive +wall-clock time. L3: the welcome test asserts the actual cwd basename. L4: only +recognized NMSh slash commands are skipped, so `/usr/bin/make` notifies. L5: the +two local mise files are now markers and identity inputs; there is no filesystem +watching. + +## Unresolved items + +- Different `XDG_RUNTIME_DIR` visibility across launches, and a capability-probe reply split across chunks: assessed, no evidence of a bounded fix, left as documented. +- Detach/reattach during blocked startup is covered by a live test; held composer text is not carried to the new frontend. +- Downgraded earlier concerns (completion helper restarts, PID reuse, long socket paths, notification coverage, relative XDG_CONFIG_HOME) were not reopened. + +## Automated evidence + +Local macOS arm64: **897 tests, 897 passed**, zero failure/skip (up from 868). +Build, typecheck, benchmark-script typing and `git diff --check` passed. No NMSh +processes or owned temp roots were left behind. During development, parallel +full-suite runs showed `lsof` timeouts and one listing race in new fixtures; the +fixtures now wait for the session listing. + +CI [run 37039105588](https://github.com/raiseCatError/notMyShell/actions/runs/37039105588) +at `ae45bbe`: + +| Runner | Node | Total | Passed | Skipped | Failed | +| --- | --- | ---: | ---: | ---: | ---: | +| macOS | 22 | 897 | 888 | 9 | 0 | +| macOS | 26 | 897 | 888 | 9 | 0 | +| Ubuntu 24.04 | 22 | 897 | 893 | 4 | 0 | +| Ubuntu 24.04 | 26 | 897 | 893 | 4 | 0 | + +The first Ubuntu Node 22 attempt failed in the existing tmux interoperability test +(`BACK-FROM-LESS`: a key sent to `less` did not arrive). It passed on a rerun of the +same commit and could not be reproduced locally; it is recorded as a suspected +timing flake, not diagnosed. The macOS skip count rose from 8 to 9: the new real-tmux test skips on macOS runners +because tmux is not installed there. It ran on the Ubuntu lanes and locally on macOS with tmux 3.7c. + +## Physical QA + +Still pending: use [v0.13 additive physical QA](v013-physical-qa.md). Additionally +check, on real terminals: a killed fullscreen/mouse app, a blocked `.zshrc` prompt, +and clipboard copy under Wayland and X11. diff --git a/licenses/GITHUB-LINGUIST-MIT.txt b/licenses/GITHUB-LINGUIST-MIT.txt new file mode 100644 index 00000000..acc8e6f2 --- /dev/null +++ b/licenses/GITHUB-LINGUIST-MIT.txt @@ -0,0 +1,22 @@ +Copyright (c) 2017 GitHub, Inc. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/package.json b/package.json index b655b012..bd3be78e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "postinstall": "node scripts/ensure-node-pty-helper.mjs", "predev": "node scripts/ensure-node-pty-helper.mjs", "nmsh": "node dist/index.js", - "test": "node --import=tsx --test tests/**/*.test.ts", + "test": "node scripts/test.mjs", "bench": "node --import=tsx scripts/benchmarks.ts", "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json && node scripts/write-build-info.mjs", diff --git a/scripts/benchmarks.ts b/scripts/benchmarks.ts index f75e39bd..78ef0026 100644 --- a/scripts/benchmarks.ts +++ b/scripts/benchmarks.ts @@ -1,19 +1,27 @@ import {arch, platform, totalmem} from 'node:os'; import {performance} from 'node:perf_hooks'; -import {AnsiOutputParser} from '../src/output/AnsiOutputParser.js'; +import {HyperlinkPresenter} from '../src/output/Hyperlinks.js'; +import {AnsiOutputParser, type StyledLine} from '../src/output/AnsiOutputParser.js'; import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {CommandEditor} from '../src/input/CommandEditor.js'; import {layoutInput} from '../src/input/inputLayout.js'; import {Highlighter} from '../src/input/Highlighter.js'; +import {rankDirectories, DirectoryService} from '../src/shell/DirectoryService.js'; import {HistoryIndex} from '../src/shell/HistoryIndex.js'; import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js'; import {planScreen} from '../src/app/screenPlan.js'; import {encodeMessage, FrameDecoder} from '../src/session/SessionProtocol.js'; -import {parseZshHistory} from '../src/shell/HistoryService.js'; +import {parseZshHistory, indexImportedHistory} from '../src/shell/HistoryService.js'; import {NativeSuggestions} from '../src/suggestions/NativeSuggestions.js'; +import {ConfiguredCompletionSource, parseConfiguredCompletions} from '../src/shell/ConfiguredCompletion.js'; +import {NativeCompletionSource, ShellCompletionSource} from '../src/shell/CompletionService.js'; +import {parseShellKnowledge} from '../src/shell/ShellKnowledge.js'; +import {mkdtempSync, writeFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import type {CommandEntry, SuggestionContext} from '../src/suggestions/types.js'; -type Benchmark = {name: string; run: () => unknown; samples?: number; warmup?: number; units?: number; unitName?: string}; +type Benchmark = {name: string; run: () => unknown; prepare?: () => unknown; samples?: number; warmup?: number; units?: number; unitName?: string}; const args = new Set(process.argv.slice(2)); const memory = args.has('--memory'); @@ -29,6 +37,7 @@ const highlighter = new Highlighter(); const semanticCache = new Map(); for (const name of ['git', 'npm', 'rg', 'zsh', 'print']) semanticCache.set(name, 'executable'); const editorCharacters = Array.from(editorText); +const shellNamesFixture = Array.from({length: 4096}, (_, index) => `alias n${index}\n`).join('') + 'complete\n'; const suggestionCounts = [10_000, 100_000]; const historyIndexes = new Map(); const histories = new Map(); @@ -56,9 +65,18 @@ function transcript(count: number): OutputBuffer { return output; } +let hyperlinkLines: StyledLine[] = []; +const cachedLinks = new HyperlinkPresenter(); + function setup(): void { + if (selected.length === 0 || selected.some(name => name.includes('hyperlinks'))) { + const parser = new AnsiOutputParser(); + for (let index = 0; index < 1000; index++) parser.write(`link ${index} https://example.com/path/${index} http://example.org/test?q=${index}\n`); + hyperlinkLines = parser.allLines(); + for (const line of hyperlinkLines) cachedLinks.line(line); + } const requested = selected.length === 0 ? ['suggestions', 'transcript'] : selected; - if (selected.length === 0 || selected.some(name => name.includes('history'))) { + if (selected.length === 0 || selected.some(name => name.includes('history') || name.includes('navigation'))) { for (const count of suggestionCounts) { const entries = generatedHistory(count); histories.set(count, entries); @@ -89,11 +107,91 @@ function setup(): void { } const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, index) => `--option-${index} -- description ${index}`).join('\n'), {buffer: 'tool ', cwd: '/work'}); +const configuredWire = Array.from({length: 4096}, (_, i) => [`value${i}`, `value${i}`, 'description', 'group', '', '', 'argument'].join('\0') + '\0').join(''); +let completionHome: string | undefined; +let configuredSource: ConfiguredCompletionSource | undefined; +let configuredBigResults: number | undefined; +let configuredBigHits = 0; +let configuredBigMisses = 0; +function configuredFixture(): {home: string; source: ConfiguredCompletionSource} { + if (!completionHome) { + completionHome = mkdtempSync(join(tmpdir(), 'nmsh-completion-bench-')); + writeFileSync(join(completionHome, '.zshrc'), `autoload -Uz compinit\ncompinit -D\n_bench() { compadd -- alpha alpine; }\ncompdef _bench bench\n_bench_cancel() { sleep 0.1; compadd -- value; }\ncompdef _bench_cancel benchcancel\n_bench_big() { compadd -- value{1..4096}; }\ncompdef _bench_big benchbig\n`); + writeFileSync(join(completionHome, 'alpha.txt'), ''); + configuredSource = new ConfiguredCompletionSource({env: {...process.env, HOME: completionHome}}); + } + return {home: completionHome, source: configuredSource!}; +} +const directoryServices = new Map(suggestionCounts.map(count => [count, new DirectoryService()])); const benchmarks: Benchmark[] = [ + {name: 'shell/name-snapshot-4096', run: () => parseShellKnowledge(shellNamesFixture), units: 4096, unitName: 'names'}, + {name: 'hyperlinks/recognize-1000', run: () => { + const presenter = new HyperlinkPresenter(); + for (const line of hyperlinkLines) presenter.line(line); + }, units: 1000, unitName: 'lines'}, + {name: 'hyperlinks/cached-1000', run: () => { + for (const line of hyperlinkLines) cachedLinks.line(line); + }, units: 1000, unitName: 'lines'}, + ...suggestionCounts.map(count => ({name: `history/index-import-${count}`, run: () => indexImportedHistory(new HistoryIndex(), histories.get(count)!, 'zsh'), samples: 5, warmup: 1, units: count, unitName: 'entries'})), + ...suggestionCounts.map(count => ({name: `navigation/rank-${count}`, run: () => rankDirectories(historyIndexes.get(count)!.all()), units: count, unitName: 'entries'})), + ...suggestionCounts.map(count => ({name: `navigation/cached-query-${count}`, run: () => directoryServices.get(count)!.query(historyIndexes.get(count)!.all(), 'pr7', 'native')})), ...suggestionCounts.map(count => ({name: `history/structured-query-${count}`, run: () => historyIndexes.get(count)!.search('cwd:/work/project-7 exit:failure duration:>1s nonexistent'), units: count, unitName: 'entries'})), {name: 'completion/filter-500', run: () => filterCompletions(completionFixture, 'op4'), units: 500, unitName: 'candidates'}, + {name: 'completion/configured-parse-4096', run: () => parseConfiguredCompletions(configuredWire, {buffer: 'bench v', cwd: '/'}), units: 4096, unitName: 'candidates'}, + {name: 'completion/configured-cold', samples: 5, warmup: 0, run: async () => { + const {home, source} = configuredFixture(); source.dispose(); + const values = await source.query({buffer: 'bench al', cwd: home}, new AbortController().signal); + if (!values.length) throw new Error('Configured cold benchmark returned no candidates'); + return values; + }}, + {name: 'completion/configured-warm', run: async () => { + const {home, source} = configuredFixture(); + const values = await source.query({buffer: 'bench al', cwd: home}, new AbortController().signal); + if (!values.length) throw new Error('Configured warm benchmark returned no candidates'); + return values; + }}, + {name: 'completion/configured-files', run: async () => { + const {home, source} = configuredFixture(); + const values = await source.query({buffer: 'cat al', cwd: home}, new AbortController().signal); + if (!values.length) throw new Error('Configured file benchmark returned no candidates'); + return values; + }}, + {name: 'completion/configured-cancel', run: async () => { + const {home, source} = configuredFixture(); const controller = new AbortController(); + const result = source.query({buffer: 'bench al', cwd: home}, controller.signal); controller.abort(); return result; + }}, + {name: 'completion/configured-cancel-inflight', samples: 5, warmup: 0, prepare: async () => { + const {home} = configuredFixture(); + // Production cancellation intentionally backs off config reloads. Prepare + // an independent warm generation rather than timing a cooldown cache miss. + configuredSource?.dispose(); + configuredSource = new ConfiguredCompletionSource({env: {...process.env, HOME: home}}); + if (!(await configuredSource.query({buffer: 'bench al', cwd: home}, new AbortController().signal)).length) throw new Error('Cancellation preparation failed'); + }, run: async () => { + const {home, source} = configuredFixture(); const controller = new AbortController(); + const result = source.query({buffer: 'benchcancel v', cwd: home}, controller.signal); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(); return result; + }}, + {name: 'completion/configured-large-query', samples: 5, warmup: 0, prepare: async () => { + const {home} = configuredFixture(); + configuredSource?.dispose(); + configuredSource = new ConfiguredCompletionSource({env: {...process.env, HOME: home}}); + if (!(await configuredSource.query({buffer: 'bench al', cwd: home}, new AbortController().signal)).length) throw new Error('Large-query preparation failed'); + }, run: async () => { + const {home, source} = configuredFixture(); + configuredBigResults = (await source.query({buffer: 'benchbig v', cwd: home}, new AbortController().signal)).length; + if (configuredBigResults === 4096) configuredBigHits++; else configuredBigMisses++; + }}, + {name: 'completion/native-fallback', samples: 5, run: async () => { + const {home} = configuredFixture(); + const source = new ShellCompletionSource({id: 'unavailable', query: async () => []}, new NativeCompletionSource()); + const values = await source.query({buffer: 'cat al', cwd: home}, new AbortController().signal); + if (!values.length) throw new Error('Native fallback benchmark returned no candidates'); + return values; + }}, ...suggestionCounts.map(count => ({ name: `history/current-text-scan-${count}`, run: () => histories.get(count)!.map(entry => entry.command).filter(command => command.toLowerCase().includes('nonexistent')).slice(0, 100), @@ -187,9 +285,10 @@ function percentile(sorted: number[], value: number): number { async function report(benchmark: Benchmark): Promise { const samples = benchmark.samples ?? sampleDefault; const warmup = benchmark.warmup ?? warmupDefault; - for (let index = 0; index < warmup; index += 1) await benchmark.run(); + for (let index = 0; index < warmup; index += 1) { await benchmark.prepare?.(); await benchmark.run(); } const times: number[] = []; for (let index = 0; index < samples; index += 1) { + await benchmark.prepare?.(); const start = performance.now(); await benchmark.run(); times.push(performance.now() - start); @@ -204,12 +303,15 @@ async function report(benchmark: Benchmark): Promise { console.log(`NMSh benchmark harness | Node ${process.version} | ${platform()} ${arch()} | ${totalmem()} bytes RAM`); console.log('Fixtures: seeded command histories, generated ANSI and Unicode lines; timings are informational.'); setup(); +process.on('exit', () => { configuredSource?.dispose(); if (completionHome) rmSync(completionHome, {recursive: true, force: true}); }); const runnable = benchmarks.filter(benchmark => selected.length === 0 || selected.some(name => benchmark.name.includes(name))); if (runnable.length === 0) { console.error(`No benchmarks matched: ${selected.join(', ')}`); process.exitCode = 1; } else { - for (const benchmark of runnable) await report(benchmark); + try { for (const benchmark of runnable) await report(benchmark); } + finally { configuredSource?.dispose(); if (completionHome) rmSync(completionHome, {recursive: true, force: true}); } + if (configuredBigResults !== undefined) console.log(`Configured large query: ${configuredBigHits} complete, ${configuredBigMisses} budget/failure fallbacks; last result=${configuredBigResults}/4096.`); } if (memory) { if (globalThis.gc) globalThis.gc(); diff --git a/scripts/lib/linguistLanguageColors.mjs b/scripts/lib/linguistLanguageColors.mjs new file mode 100644 index 00000000..275adba6 --- /dev/null +++ b/scripts/lib/linguistLanguageColors.mjs @@ -0,0 +1,53 @@ +function yamlString(value) { + const scalar = value.trim(); + if (scalar.startsWith('"')) return JSON.parse(scalar); + if (scalar.startsWith("'")) return scalar.slice(1, scalar.lastIndexOf("'")).replaceAll("''", "'"); + return scalar.replace(/\s+#.*$/u, '').trim(); +} + +/** Extracts only Linguist's canonical names, official aliases, and CSS colors. */ +export function parseLanguageColors(source) { + const languages = []; + let current; + + const finish = () => { + if (current?.color) languages.push(current); + }; + + for (const line of source.split(/\r?\n/u)) { + const header = /^([^\s#].*):\s*$/u.exec(line); + if (header && !header[1].startsWith('---')) { + finish(); + current = {name: yamlString(header[1]), aliases: [], color: undefined}; + continue; + } + if (!current) continue; + + const field = /^ ([\w-]+):(?:\s*(.*))?$/u.exec(line); + if (field) { + current.field = field[1]; + if (field[1] === 'color') { + const color = yamlString(field[2] ?? ''); + if (/^#[\da-fA-F]{6}$/u.test(color)) current.color = color.toUpperCase(); + } + continue; + } + + if (current.field === 'aliases') { + const alias = /^ -\s+(.+?)\s*$/u.exec(line); + if (alias) { + const value = yamlString(alias[1]); + if (value) current.aliases.push(value); + } + } + } + finish(); + return languages.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +} + +export function renderLanguageColorsModule(languages, revision) { + const rows = languages.map(language => ` ${JSON.stringify(language.name)}: {color: ${JSON.stringify(language.color)}, aliases: ${JSON.stringify(language.aliases)}}`).join(',\n'); + return `/** Generated from github-linguist/linguist lib/linguist/languages.yml. Do not edit by hand. */\n` + + `export const LINGUIST_LANGUAGE_COLORS_REVISION = ${JSON.stringify(revision)};\n` + + `export const LINGUIST_LANGUAGE_COLORS = {\n${rows}\n} as const;\n`; +} diff --git a/scripts/platform-benchmarks.ts b/scripts/platform-benchmarks.ts new file mode 100644 index 00000000..fc904452 --- /dev/null +++ b/scripts/platform-benchmarks.ts @@ -0,0 +1,51 @@ +import {once} from 'node:events'; +import {existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {performance} from 'node:perf_hooks'; +import {ShellSession} from '../src/shell/ShellSession.js'; +import {connectSession} from '../src/session/connectSession.js'; +import {socketPathFor} from '../src/session/runtimeDir.js'; + +// A short, owned POSIX root avoids Unix-socket limits. Never touch existing roots. +const root = realpathSync(mkdtempSync('/tmp/npb-')); +writeFileSync(join(root, '.zshrc'), ''); +const env = {...process.env, HOME: root}; +const samples: Record = {'pty-ready': [], 'service-ready': [], 'service-cleanup': []}; +try { + for (let index = 0; index < 3; index++) { + let started = performance.now(); + const shell = new ShellSession(root, 80, 24, root, env); + let startup = ''; + const raw = shell['pty'].onData(data => { if (startup.length < 4096) startup += data; }); + try { + try { await once(shell, 'prompt', {signal: AbortSignal.timeout(10000)}); } + catch (error) { console.error('Fixture PTY startup bytes:', JSON.stringify(startup)); throw error; } + samples['pty-ready']!.push(performance.now() - started); + } finally { + const exited = once(shell, 'exit', {signal: AbortSignal.timeout(5000)}); + shell.kill(); await exited; raw.dispose(); + } + const runtimeDir = join(root, `r${index}`); + started = performance.now(); + const connection = await connectSession({cwd: root, columns: 80, rows: 24, env, runtimeDir}); + try { + if (connection.mode !== 'service') throw new Error(connection.notice ?? 'service unavailable'); + const ready = once(connection.client, 'prompt', {signal: AbortSignal.timeout(10000)}); + connection.client.start(); await ready; + samples['service-ready']!.push(performance.now() - started); + } finally { + started = performance.now(); + connection.client.kill(); + while (existsSync(socketPathFor(runtimeDir))) { + if (performance.now() - started > 5000) throw new Error('service cleanup exceeded 5s budget'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + samples['service-cleanup']!.push(performance.now() - started); + } + } + console.log(`Platform timing: ${process.platform} ${process.arch}, ${process.version}; 3 isolated samples`); + for (const [name, values] of Object.entries(samples)) { + values.sort((a, b) => a - b); + console.log(`${name}: p50=${values[1]!.toFixed(2)}ms max=${values[2]!.toFixed(2)}ms`); + } +} finally { rmSync(root, {recursive: true, force: true}); } diff --git a/scripts/presentation-benchmarks.ts b/scripts/presentation-benchmarks.ts new file mode 100644 index 00000000..7e1e3dbb --- /dev/null +++ b/scripts/presentation-benchmarks.ts @@ -0,0 +1,71 @@ +import {mixRgb, BRAND_LAVENDER} from '../src/chroma/chroma.js'; +import {colorEscape} from '../src/chroma/escape.js'; +import {performance} from 'node:perf_hooks'; +import {platform, arch} from 'node:os'; +import {normalizeTreatmentSettings, paintTreatment} from '../src/chroma/treatment.js'; +import {UI_COLORS} from '../src/ui/palette.js'; +import {EffectState, applyEffect, effectRegion, effectCells} from '../src/motion/effects.js'; +import {PresentationClock} from '../src/motion/PresentationClock.js'; +import {planScreen} from '../src/app/screenPlan.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {TerminalRenderer} from '../src/terminal/TerminalRenderer.js'; + +const settings = normalizeTreatmentSettings({preset: 'lavender'}); +const animated = {...settings, motion: 'travel' as const}; +let time = 0; +function measure(name: string, run: () => unknown, samples = 100): void { + for (let i = 0; i < 5; i++) run(); + const cpu = process.cpuUsage(); + const values: number[] = []; + for (let i = 0; i < samples; i++) { const start = performance.now(); run(); values.push(performance.now() - start); } + values.sort((a, b) => a - b); + const used = process.cpuUsage(cpu); + console.log(JSON.stringify({name, samples, medianMs: +values[Math.floor(samples / 2)]!.toFixed(3), p95Ms: +values[Math.floor(samples * 0.95)]!.toFixed(3), cpuMs: +(used.user / 1000 + used.system / 1000).toFixed(3)})); +} +console.log(JSON.stringify({node: process.version, platform: platform(), arch: arch(), frameRate: 10})); +measure('static-prompt-sampling-40-cells', () => paintTreatment('notMyShell ~/Projects main node'.padEnd(40), settings, 'native-identity', UI_COLORS.primary)); +measure('animated-prompt-sampling-40-cells', () => paintTreatment('notMyShell ~/Projects main node'.padEnd(40), animated, 'native-identity', UI_COLORS.primary, time += 100)); +measure('animated-divider-120-cells', () => paintTreatment('-'.repeat(120), animated, 'divider', UI_COLORS.separator, time += 100)); +const state = new EffectState(); state.trigger('rain', 'bottom', 0, 42, settings); +const planInput = {rows: 40, inputRows: 1, suggestions: 0, running: false, detached: false, hasOutput: true, contextPlacement: 'header' as const, hasVisibleContext: true, composerLayout: 'twoLine' as const, transcriptRows: 100}; +const output = new OutputBuffer(); +for (let i = 0; i < 1000; i++) { output.beginCommand(`echo ${i}`, [`echo ${i}`], undefined, {cwd: '/work'}); output.write(`raw output ${i}\r\n`); output.complete(0); } +output.presenter.setTreatment(settings); +measure('large-transcript-static-1000-commands', () => output.wrapped(120), 10); +for (const composerPosition of ['bottom', 'top', 'flow'] as const) { + for (const transcriptPresentation of ['normal', 'chat'] as const) { + output.presenter.setLayout(transcriptPresentation); + const base = output.wrapped(120).slice(-35).map(row => row.ansi); + const plan = planScreen({...planInput, composerPosition}); + const region = effectRegion(plan, 'bottom'); + if (!region) continue; + const rows = Array.from({length: plan.rows}, (_, i) => base[i] ?? ''); + let writes = 0, bytes = 0; + const renderer = new TerminalRenderer(data => { writes++; bytes += Buffer.byteLength(data); }); + renderer.enter(); renderer.render({rows, columns: 120, cursorRow: 40, cursorColumn: 1}); writes = 0; bytes = 0; + let changedRows = 0, maxChangedCells = 0; + let previousCells = new Map(); + let previous = rows; + measure(`${composerPosition}-${transcriptPresentation}-cached-effect-frame`, () => { + const now = (time++ % 29) * 100; + const next = applyEffect(rows, state.active!, region, 120, now, true, 'truecolor'); + const cells = new Map(effectCells(state.active!, region, 120, now, true).map(cell => + [`${cell.row}:${cell.column}`, colorEscape(38, mixRgb(BRAND_LAVENDER, {red: 235, green: 220, blue: 255}, cell.intensity), 'truecolor') + cell.glyph])); + maxChangedCells = Math.max(maxChangedCells, [...new Set([...cells.keys(), ...previousCells.keys()])].filter(key => cells.get(key) !== previousCells.get(key)).length); + previousCells = cells; + changedRows = next.filter((row, i) => row !== previous[i]).length; + renderer.render({rows: next, columns: 120, cursorRow: 40, cursorColumn: 1}); previous = next; + }); + console.log(JSON.stringify({layout: `${composerPosition}-${transcriptPresentation}`, writes, bytes, finalChangedRows: changedRows, maxChangedCells, particleBound: 64, cellUpdateBound: 512 * 4})); + renderer.leave(); + } +} +measure('resize-plan-and-effect-20-to-320-columns', () => { + const plan = planScreen({...planInput, rows: 12, composerPosition: 'flow'}); + const region = effectRegion(plan, 'top'); + if (region) for (const width of [20, 80, 320]) applyEffect(Array(12).fill(''), state.active!, region, width, 700, true, 'truecolor'); +}); +const clock = new PresentationClock(); let wakeups = 0; +const stop = clock.subscribe(() => wakeups++); +await new Promise(resolve => setTimeout(resolve, 550)); stop(); +console.log(JSON.stringify({clockWindowMs: 550, wakeups, subscribersAfterStop: clock.subscriberCount, scheduledAfterStop: clock.scheduled})); diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 00000000..db40e959 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,57 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync, readdirSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; + +/** Short private temp roots keep Unix sockets below their path length limit. */ +export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio = 'inherit', report = message => console.error(message)} = {}) { + const root = realpathSync(mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'nt-'))); + let leftovers = []; + try { + // Existing presentation snapshots pin truecolor; baseline fixtures override this explicitly. + const env = {...process.env, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root}; + // A nested runner must not impersonate its parent's test worker. + delete env.NODE_TEST_CONTEXT; + const child = spawn(process.execPath, ['--import=tsx', '--test', ...args, ...files], { + cwd, stdio, env, + }); + let interrupted = false; + const forward = () => { interrupted = true; child.kill('SIGTERM'); }; + process.once('SIGINT', forward); + process.once('SIGTERM', forward); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (status) => resolve(status ?? 1)); + }); + } finally { + process.off('SIGINT', forward); + process.off('SIGTERM', forward); + } + // Crash fixtures own their nested TMPDIR. Ordinary lifecycle leaks at the + // suite root are a failure, reported BEFORE cleanup rather than hidden. + leftovers = readdirSync(root).filter(name => /^nmsh-(?:semantic|zdotdir|completion|capture)-/u.test(name)); + if (leftovers.length) report(`Test lifecycle leaked ${leftovers.length} semantic/zsh temp directories: ${leftovers.join(', ')}`); + return {code: leftovers.length || interrupted ? 1 : code, root, leftovers}; + } finally { + // Only this run's private root. Never remove pre-existing host artifacts. + rmSync(root, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const files = readdirSync('tests', {recursive: true}).filter(name => name.endsWith('.test.ts')).map(name => join('tests', name)).sort(); + // Preserve the history latency budget without measuring competing PTY/render + // fixtures on shared runners. All ranking tests still run canonically. + const ranking = files.filter(file => file.endsWith('suggestionRanking.test.ts')); + const runtime = files.filter(file => !ranking.includes(file)); + try { + process.exitCode = 0; + for (const group of [runtime, ranking]) { + if (group.length) process.exitCode = Math.max(process.exitCode, (await runTestFiles(group, process.argv.slice(2))).code); + } + } + catch (error) { console.error(error); process.exitCode = 1; } +} diff --git a/scripts/update-linguist-language-colors.mjs b/scripts/update-linguist-language-colors.mjs new file mode 100644 index 00000000..755126a2 --- /dev/null +++ b/scripts/update-linguist-language-colors.mjs @@ -0,0 +1,22 @@ +import {writeFile} from 'node:fs/promises'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parseLanguageColors, renderLanguageColorsModule} from './lib/linguistLanguageColors.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const api = await fetch('https://api.github.com/repos/github-linguist/linguist/commits/main', { + headers: {'User-Agent': 'notMyShell-language-color-update'}, +}); +if (!api.ok) throw new Error(`Unable to read Linguist revision: HTTP ${api.status}`); +const {sha} = await api.json(); +if (typeof sha !== 'string' || !/^[\da-f]{40}$/u.test(sha)) throw new Error('GitHub returned an invalid Linguist revision'); + +const sourceUrl = `https://raw.githubusercontent.com/github-linguist/linguist/${sha}/lib/linguist/languages.yml`; +const source = await fetch(sourceUrl, {headers: {'User-Agent': 'notMyShell-language-color-update'}}); +if (!source.ok) throw new Error(`Unable to read Linguist language data: HTTP ${source.status}`); +const languages = parseLanguageColors(await source.text()); +if (languages.length < 100) throw new Error(`Refusing to write incomplete Linguist data (${languages.length} entries)`); + +const output = resolve(root, 'src/languages/linguistLanguageColors.generated.ts'); +await writeFile(output, renderLanguageColorsModule(languages, sha)); +process.stdout.write(`Wrote ${languages.length} language colors from Linguist ${sha}\n`); diff --git a/scripts/write-build-info.mjs b/scripts/write-build-info.mjs index 7c649f3b..4d777bf5 100644 --- a/scripts/write-build-info.mjs +++ b/scripts/write-build-info.mjs @@ -1,5 +1,5 @@ import {execFileSync} from 'node:child_process'; -import {mkdir, readFile, writeFile} from 'node:fs/promises'; +import {copyFile, mkdir, readFile, writeFile} from 'node:fs/promises'; import {dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -21,6 +21,10 @@ try { } await mkdir(outputDirectory, {recursive: true}); +await mkdir(join(outputDirectory, 'shell'), {recursive: true}); +for (const name of ['capture.zsh', 'configured-completion.zsh', 'configured-widget.zsh']) { + await copyFile(join(root, 'src/shell', name), join(outputDirectory, 'shell', name)); +} await writeFile(join(outputDirectory, 'build-info.json'), `${JSON.stringify({ version: typeof packageJson.version === 'string' ? packageJson.version : 'unknown', commit, diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f4fa4eaa..e7fa4d8c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1,5 +1,23 @@ +import {presentationClock} from '../motion/PresentationClock.js'; +import {EffectState, applyEffect, effectRegion} from '../motion/effects.js'; +import {paintTreatment} from '../chroma/treatment.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import type {TerminalFrame} from '../terminal/TerminalRenderer.js'; +import {detectTerminalHost} from '../host/terminalHost.js'; +import {probeHost} from '../host/probe.js'; +import {SessionPresetStore, PresetStartup, presetNeedsAcknowledgement, type SessionPreset} from '../session/SessionPresets.js'; +import {createPresetPanel, presetPanelKey, renderPresetPanel, type PresetPanel} from '../session/PresetPanel.js'; +import {MiseProjectService, detectMiseProject} from '../tools/MiseProject.js'; +import {misePanelKey, renderMisePanel, type MisePanel} from '../tools/MisePanel.js'; import {homedir} from 'node:os'; -import {GLYPHS, setIconStyle} from '../ui/glyphs.js'; +import {createNotificationService, formatCommandNotification, shouldNotify, type TerminalFocus} from '../notifications/commandNotifications.js'; +import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId} from '../ui/BlockActions.js'; +import {paletteItems} from '../ui/CommandPalette.js'; +import {createConfigurationPanel, configurationKey, renderConfigurationPanel, type ConfigurationPanel} from '../tools/ConfigurationPanel.js'; +import {openSupportedConfiguration} from '../tools/SupportedConfiguration.js'; +import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; +import {inspectCommand, renderInspector} from '../shell/CommandInspector.js'; +import {GLYPHS, setIconStyle, getCurrentGlyphMode} from '../ui/glyphs.js'; import {framePanel} from '../ui/PanelShell.js'; import { adjustSettingsRow, isInlineEditable, resetSettingsRow, settingsRowChanged, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination, @@ -14,11 +32,15 @@ import {delimiter, join} from 'node:path'; import {renderCompletion, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js'; import {resolveAction} from '../ui/actions.js'; import {CompletionService, type CompletionCandidate} from '../shell/CompletionService.js'; +import {classifyShellFailure, parseShellKnowledge} from '../shell/ShellKnowledge.js'; import {HistoryService} from '../shell/HistoryService.js'; import {SuggestionController} from '../suggestions/SuggestionController.js'; import {createPalette, handlePaletteKey, renderPalette, type PaletteItem, type PaletteState} from '../ui/CommandPalette.js'; import {NativeSuggestions} from '../suggestions/NativeSuggestions.js'; import {DejaSuggestions} from '../suggestions/DejaSuggestions.js'; +import {CommandCorrectionService, CORRECTION_ACTIONS, renderCorrection, type CommandCorrection} from '../shell/CommandCorrection.js'; +import {DirectoryService, directoryCommand, NAVIGATION_PROVIDERS, type DirectoryCandidate} from '../shell/DirectoryService.js'; +import {openPicker, PICKER_PROVIDERS, type PickerHandoff} from '../pickers/Picker.js'; import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; import type {HistoryEntry} from '../shell/HistoryIndex.js'; import {isPrivateCommand, ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.js'; @@ -53,7 +75,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 {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow} from '../commands/slashCommands.js'; -import {copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; +import {ClipboardUnavailableError, copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {layoutInput, graphemes} from '../input/inputLayout.js'; import {editText} from '../ui/formControls.js'; @@ -68,11 +90,10 @@ import {foreground, background, UI_COLORS} from '../ui/palette.js'; import {cursorScreenRow, planScreen, regionAt, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.js'; import {AppearanceState, handleAppearanceKey, renderAppearancePanel, BLUR_MODES} from '../appearance/AppearancePanel.js'; import {KeyboardState, handleKeyboardKey, renderKeyboardPanel} from '../keyboard/KeyboardPanel.js'; -import {installGhosttyKeybinding} from '../keyboard/ghosttyKeyboard.js'; -import {detectGhosttyConfigPath, readGhosttySettings, saveGhosttySettings} from '../appearance/ghostty.js'; import {Highlighter} from '../input/Highlighter.js'; import {handleSyntaxPanelKey, renderSyntaxPanel, type SyntaxPanelState} from '../input/SyntaxPanel.js'; import {AlternateScreenTracker} from '../session/TerminalModes.js'; +import {renderStartupPanel} from '../ui/StartupPanel.js'; import {createLayoutPanel, handleLayoutPanelKey, renderLayoutPanel, type LayoutPanelState} from '../ui/LayoutPanel.js'; import {syntaxCharStyles, syntaxSgrForConfiguration, type SyntaxSgr} from '../input/syntaxTheme.js'; import {SemanticService} from '../shell/SemanticService.js'; @@ -91,6 +112,7 @@ import {defaultRuntimeDir} from '../session/runtimeDir.js'; /** Editor text that marks interactive history search. */ const HISTORY_SEARCH = '/history '; +const DIRECTORY_SEARCH = '/dirs '; const PRIMARY = foreground(UI_COLORS.primary); const SECONDARY = foreground(UI_COLORS.secondary); const SUBTLE = foreground(UI_COLORS.subtle); @@ -98,6 +120,7 @@ const SEPARATOR = foreground(UI_COLORS.separator); const ACCENT = foreground(UI_COLORS.accent); const SUCCESS = foreground(UI_COLORS.success); const ERROR = foreground(UI_COLORS.failure); +const clipboardFailure = (error: unknown): string => error instanceof ClipboardUnavailableError ? error.message : 'Clipboard copy failed'; /** Keys that edit or submit the composer; in Flow they bring a scrolled-back view back to it. */ const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'backspace', 'delete', 'deleteWord', 'deleteLineBefore', 'deleteLineAfter', 'enter', 'newline', 'complete', 'historySearch']); @@ -105,8 +128,6 @@ const STOPPED = foreground({red: 198, green: 156, blue: 109}); const INFO = SECONDARY; const RESET = '\u001B[0m'; const PASTE_ATOM_BACKGROUND = background({red: 63, green: 65, blue: 82}); -const STATUS_REFRESH_MS = 100; - export class TerminalApp { private readonly buildIdentity = readBuildIdentity(); private updateInProgress = false; @@ -114,7 +135,10 @@ export class TerminalApp { private offeredUpdate?: string; private readonly initialCwd = process.cwd(); private shellCwd = this.initialCwd; - private readonly renderer = new TerminalRenderer(); + private terminalFocus: TerminalFocus = 'unknown'; + private readonly notificationService = createNotificationService(); + private readonly host = detectTerminalHost(); + private readonly renderer = new TerminalRenderer(undefined, this.host.capabilities); private readonly editor = new CommandEditor(); private readonly highlighter = new Highlighter(); private readonly semanticService: SemanticService; @@ -134,9 +158,19 @@ export class TerminalApp { private readonly submittedCommands: string[] = []; private readonly transcriptStore = new TranscriptStore(); private readonly completionService = new CompletionService(); + private inspectorVisible = false; private shellSuggestions: CompletionCandidate[] = []; private lastSuggestionInput = ""; private completionGeneration = 0; + private readonly correctionService = new CommandCorrectionService(); + private correction?: CommandCorrection; + private correctionAbort?: AbortController; + private readonly directoryService = new DirectoryService(); + private directoryQuery?: string; + private directoryQueryAbort?: AbortController; + private directoryResults: DirectoryCandidate[] = []; + private pickerOpening = false; + private pickerAbort?: AbortController; private historyQuery?: string; private historyQueryAbort?: AbortController; private historyResults: HistoryEntry[] = []; @@ -155,6 +189,18 @@ export class TerminalApp { private transcriptPanelState?: TranscriptPanelState; /** The shared provider gallery for families without a bespoke panel (Welcome, Suggestions). */ private providerPanelState?: ProviderPanelState; + private toolConfiguration?: ConfigurationPanel; + private presetPanel?: PresetPanel; + private readonly presetStore = new SessionPresetStore(); + private presetStartup?: PresetStartup; + private presetShellReady = false; + private presetFrontendReady = false; + switchPreset?: SessionPreset; + private toolsPanel?: ToolsPanel; + private misePanel?: MisePanel; + private readonly miseService = new MiseProjectService(); + private toolConfigurationLoading = false; + private toolConfigurationGeneration = 0; private paletteState?: PaletteState; /** Palette entry ids used this session, most recent first. */ private paletteRecent: string[] = []; @@ -167,7 +213,7 @@ export class TerminalApp { /** Terminal modes the running command has set, for handing the terminal to it mid-command. */ private readonly commandModes = new AlternateScreenTracker(); private settingsPanelState?: SettingsPanelState; - private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number}; + private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number; awaitingExec?: boolean}; private hoveredLineIndex?: number; private focusedLineIndex?: number; private focusedActivityId?: string; @@ -176,10 +222,13 @@ export class TerminalApp { private externalPassthrough = false; private lastOutputTime = 0; private selectedSuggestion = 0; - private activityTimer?: NodeJS.Timeout; + private presentationStarted = false; + private presentationSubscription?: () => void; + private readonly effects = new EffectState(); + private presentationFrame?: {frame: TerminalFrame; plan: ScreenPlan}; private activityAnimationNow = Date.now(); /** One pending timeout at a time drives the welcome cat's occasional blink. */ - private welcomeBlinkTimer?: NodeJS.Timeout; + private welcomeBlinkTimer?: () => void; private welcomeBlinkCount = 0; private contextGeneration = 0; private appearanceState?: AppearanceState; @@ -208,13 +257,19 @@ export class TerminalApp { private readonly done: Promise; private finish!: (exitCode: number) => void; - constructor(connection?: SessionConnection) { + constructor(connection?: SessionConnection, preset?: SessionPreset) { + if (preset) { + if (!connection || connection.mode !== 'service' || connection.attached) throw new Error('Presets require a new live session.'); + this.presetStartup = new PresetStartup(preset); + } setIconStyle(this.promptConfiguration.glyphStyle); this.startWelcome(this.initialCwd); this.applySuggestionProvider(); this.output.setTranscriptAppearance(this.promptConfiguration.transcript); + this.output.presenter.setTreatment(this.promptConfiguration.presentation); this.output.setOutputFolding(this.promptConfiguration.outputFolding); this.output.presenter.setLayout(this.promptConfiguration.transcriptPresentation); + this.output.presenter.setHyperlinks(this.host.capabilities.hyperlinks); const dimensions = this.dimensions(); this.session = connection?.client ?? new InProcessSessionClient({cwd: this.initialCwd, columns: dimensions.columns, rows: Math.max(2, dimensions.rows - 4)}); @@ -223,9 +278,22 @@ export class TerminalApp { this.finish = resolve; }); this.session.on('data', (data, stamp) => { if (this.inStream(stamp)) this.onShellData(data); }); - this.session.on('prompt', (marker, stamp) => { if (this.inStream(stamp)) this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); }); + this.session.on('prompt', (marker, stamp) => { + if (this.inStream(stamp)) { + if (marker.knowledge !== undefined) { + this.semanticService.applyShellKnowledge(marker.knowledge); + this.completionService.setShellKnowledge(parseShellKnowledge(marker.knowledge)); + } + this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); + } + }); this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at, stamp.historyAllowed); }); this.session.on('replayed', summary => this.finishReplay(summary)); + this.session.on('inputRejected', (data, submission) => this.onInputRejected(data, submission)); + this.session.on('startup', tail => { + this.startupTail = tail; + if (this.startupPanel) { this.startupPanel.tail = tail; this.render(); } + }); this.session.on('exit', event => { this.shellEnded = true; if (event.lost) { @@ -240,9 +308,52 @@ export class TerminalApp { if (connection?.attached) this.beginReattach(connection.attached, connection.journal); // After any restored transcript, or reattaching would erase the launch notice. if (connection?.notice) this.output.addFrontendInteraction('session', connection.notice, ERROR); + this.beginStartupWatch(connection?.attached); this.session.start(); } + /** The shell has not reached its first prompt; set for a new session, or a reattached one still starting. */ + private startupPending = false; + private startupTail = ''; + private startupTimer?: NodeJS.Timeout; + private startupPanel?: {since: number; tail: string}; + + /** + * Normal startup finishes before this fires and shows nothing. A shell that is still not at its first prompt + * (slow, or blocked on a startup file waiting for input) gets an explicit state instead of a composer that + * looks ready; commands stay held by the shell until it is. + */ + private beginStartupWatch(attached: AttachedSession | undefined): void { + this.startupPending = attached ? attached.startup !== undefined : true; + this.startupTail = attached?.startup ?? ''; + if (!this.startupPending) return; + const configured = Number(process.env.NMSH_STARTUP_NOTICE_MS); + const delay = Number.isFinite(configured) && configured >= 50 ? Math.min(60_000, configured) : 1500; + this.startupTimer = setTimeout(() => { + this.startupTimer = undefined; + if (!this.startupPending || this.stopped) return; + this.startupPanel = {since: Date.now() - delay, tail: this.startupTail}; + this.render(); + }, delay); + this.startupTimer.unref?.(); + } + + /** Explicit recovery from a blocked startup: end the shell and this session; nothing is left detached. */ + private abortStartup(): void { + this.shellEnded = true; + this.detaching = false; + try { this.session.kill(); } catch { /* the shell may already be gone */ } + this.stop(130); + process.stderr.write('NMSh: shell startup aborted; the session was ended.\n'); + } + + private endStartupWatch(): void { + this.startupPending = false; + this.startupTail = ''; + if (this.startupTimer) { clearTimeout(this.startupTimer); this.startupTimer = undefined; } + this.startupPanel = undefined; + } + /** Last shell stream event reflected in the transcript (service sessions). */ private streamSeq = 0; /** Between reattach and the end of the backlog: rebuild the transcript quietly. */ @@ -279,6 +390,10 @@ export class TerminalApp { this.replaying = true; this.shellCwd = attached.cwd; this.streamSeq = attached.ackedSeq; + if (attached.knowledge !== undefined) { + this.semanticService.applyShellKnowledge(attached.knowledge); + this.completionService.setShellKnowledge(parseShellKnowledge(attached.knowledge)); + } if (!journal) return; this.continuedJournal = journal; this.welcomeGeneration += 1; @@ -290,7 +405,10 @@ export class TerminalApp { if (running) { this.output.resumeActive(running.command, running.startId, running.outputStartId, mode => this.onActiveModeChange(mode)); this.running = {command: running.command, startedAt: running.startedAt, interrupted: false, cleared: false, - startId: running.startId, cwd: running.cwd, historyAllowed: running.historyAllowed}; + startId: running.startId, cwd: running.cwd, historyAllowed: running.historyAllowed, + // A submission checkpoint can precede the very first shell event. + // Its replayed readiness prompt must not complete the queued command. + awaitingExec: this.streamSeq === 0}; } } } @@ -309,7 +427,8 @@ export class TerminalApp { } // Without a journal the running command is known only from the service. if (!this.running && attached.running) this.onShellExec(attached.running, attached.runningSince); - if (this.running && (attached.fullscreen !== 0 || shouldPassthrough(this.running.command))) { + if (!this.startupPending && this.running && (attached.fullscreen !== 0 || shouldPassthrough(this.running.command))) { + this.cancelPresentation(); this.passthrough = true; this.attachedModes = attached.modes ?? ''; if (this.rendererEntered) this.enterAttachedPassthrough(); @@ -324,6 +443,7 @@ export class TerminalApp { private enterAttachedPassthrough(): void { // Reattached into a fullscreen app: hand it the whole terminal again, // including the mouse/paste/cursor-key modes it set before the detach. + this.terminalFocus = 'unknown'; this.renderer.suspendForPassthrough(this.attachedModes); this.attachedModes = ''; const dimensions = this.dimensions(); @@ -332,9 +452,11 @@ export class TerminalApp { private onActiveModeChange(mode: PresentationMode): void { if (this.replaying) return; - if (mode === 'PASSTHROUGH' && !this.passthrough) { + if (mode === 'PASSTHROUGH' && !this.passthrough && !this.startupPending) { + this.cancelPresentation(); this.passthrough = true; // Modes the program set in earlier output never reached the terminal; hand them over with it. + this.terminalFocus = 'unknown'; this.renderer.suspendForPassthrough(this.commandModes.restoreSequence()); const dimensions = this.dimensions(); this.session.resize(dimensions.columns, dimensions.rows); @@ -347,7 +469,20 @@ export class TerminalApp { * detached, or came from type-ahead): give it its own transcript block. */ private onShellExec(command: string, at = Date.now(), historyAllowed?: number): void { - if (this.running) { this.running.historyAllowed = historyAllowed; return; } + this.effects.cancel(); + if (this.running) { + this.running.awaitingExec = false; + this.running.historyAllowed = historyAllowed; + if (!this.startupPending && !this.passthrough && shouldPassthrough(command)) { + this.cancelPresentation(); + this.terminalFocus = 'unknown'; + this.passthrough = true; + this.renderer.suspendForPassthrough(); + const dimensions = this.dimensions(); + this.session.resize(dimensions.columns, dimensions.rows); + } + return; + } this.commandModes.reset(); const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), mode => this.onActiveModeChange(mode), {cwd: this.shellCwd, project: this.context.project, branch: this.context.branch, prompt: this.currentPromptSnapshot(command)}); @@ -373,6 +508,25 @@ export class TerminalApp { } async run(): Promise { + let earlyInput = ''; + // A reattached interactive program owns terminal queries and replies. + if (!this.passthrough && process.stdin.isTTY && process.stdout.isTTY) { + this.originalRawMode = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.setEncoding('utf8'); + const resolved = await probeHost(this.host.capabilities, { + write: data => process.stdout.write(data), + listen: receive => { + process.stdin.on('data', receive); + process.stdin.resume(); + return () => { process.stdin.off('data', receive); process.stdin.pause(); }; + }, + }); + process.stdin.setRawMode(this.originalRawMode); + this.host.capabilities = resolved.capabilities; + this.renderer.setCapabilities(resolved.capabilities); + earlyInput = resolved.input; + } this.journal = new SessionJournal(this.transcriptStore, this.promptConfiguration.sessionRetention, () => ({startCwd: this.presentationStartCwd, finalCwd: this.shellCwd, transcript: this.output.transcript(), live: this.liveLink()}), () => this.output.addFrontendInteraction('/resume', 'Could not persist the current session; check local storage.', ERROR), @@ -385,16 +539,17 @@ export class TerminalApp { } catch { this.output.addFrontendInteraction('/resume', 'Continuous session journaling could not start; check local storage.', ERROR); } - if (!this.promptConfiguration.glyphChoiceComplete) { + if (!this.presetStartup && !this.promptConfiguration.glyphChoiceComplete) { this.settingsPanelState = {section: 'appearance', selectedIndex: this.promptConfiguration.glyphStyle === 'nerd' ? 0 : 1, glyphStyle: this.promptConfiguration.glyphStyle, onboarding: true}; - } else if (!this.promptConfiguration.onboardingComplete) { + } else if (!this.presetStartup && !this.promptConfiguration.onboardingComplete) { this.promptPanelState = {onboarding: true, step: 'provider', selectedIndex: PROVIDER_ORDER.indexOf(this.promptConfiguration.provider), draft: structuredClone(this.promptConfiguration), saved: structuredClone(this.promptConfiguration)}; + } else if (!this.presetStartup && !this.promptConfiguration.toolsSetupComplete) { + this.startTools(true); } - this.renderer.enter(); - this.rendererEntered = true; - if (this.passthrough) this.enterAttachedPassthrough(); + // Restored terminal modes are a visible handoff: keys can arrive at once. + // Install raw input first so the host cannot echo or translate those keys. if (process.stdin.isTTY) { this.originalRawMode = process.stdin.isRaw; process.stdin.setRawMode(true); @@ -402,25 +557,29 @@ export class TerminalApp { process.stdin.setEncoding('utf8'); process.stdin.resume(); process.stdin.on('data', this.onInput); + this.renderer.enter(); + this.rendererEntered = true; + if (this.passthrough) this.enterAttachedPassthrough(); + if (earlyInput) this.onInput(earlyInput); process.stdout.on('resize', this.onResize); + process.on('SIGTSTP', this.onSuspend); + process.on('SIGCONT', this.onContinue); process.once('SIGTERM', this.onTerminate); process.once('SIGHUP', this.onTerminate); process.on('exit', () => { if (!this.stopped) { if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode); + this.terminalFocus = 'unknown'; this.renderer.leave(); } }); - this.activityTimer = setInterval(() => { - if (!this.running) return; - this.activityAnimationNow = Date.now(); - this.output.tickActiveCommand(); - this.render(); - }, STATUS_REFRESH_MS); + this.presentationStarted = true; this.scheduleWelcomeBlink(); void this.loadHistory(); this.render(); void this.quietUpdateCheck(); + this.presetFrontendReady = true; + if (this.presetShellReady) this.advancePresetStartup(0, this.shellCwd); const exitCode = await this.done; try { await this.journal.close(!this.detaching || this.shellEnded); } catch { process.stderr.write('NMSh could not finish persisting the current presentation session.\n'); @@ -434,7 +593,7 @@ export class TerminalApp { const escaped = JSON.stringify(data); appendFileSync('/tmp/nmsh-key-debug.log', `RAW hex=${hex} escaped=${escaped}\n`); } - if (this.passthrough) { + if (this.passthrough && !this.startupPending) { this.session.write(data); return; } @@ -445,6 +604,7 @@ export class TerminalApp { }; private readonly onResize = (): void => { + this.effects.cancel(); if (this.externalPassthrough) return; this.renderer.invalidate(); this.lastPtyRows = 0; @@ -465,7 +625,84 @@ export class TerminalApp { this.stop(0); }; + private frontendSuspended = false; + private readonly onSuspend = (): void => { + if (this.stopped || this.externalPassthrough) return; + this.cancelPresentation(); + this.frontendSuspended = true; + this.terminalFocus = 'unknown'; + this.renderer.leave(); + if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode); + process.stdin.pause(); + process.kill(process.pid, 'SIGSTOP'); + }; + + private readonly onContinue = (): void => { + if (!this.frontendSuspended || this.stopped) return; + this.frontendSuspended = false; + this.terminalFocus = 'unknown'; + this.renderer.enter(); + if (this.passthrough) this.renderer.suspendForPassthrough(this.commandModes.restoreSequence()); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + this.keyDecoder.reset(); + this.onResize(); + }; + private handleKey(key: Key): void { + if (this.effects.active && (key.kind === 'escape' || (key.kind === 'interrupt' && !this.running))) { + this.effects.cancel(); this.render(); return; + } + if (key.kind === 'focusIn' || key.kind === 'focusOut') { + this.terminalFocus = key.kind === 'focusIn' ? 'focused' : 'blurred'; + return; + } + if (this.startupPending && key.kind === 'interrupt') { this.abortStartup(); return; } + if (this.startupPanel) { + // The shell is not ready; it may be waiting on a startup file. Abort is explicit, and nothing the + // composer produces is submitted until the shell reaches its first prompt (typed text is kept). + if (key.kind === 'interrupt') { this.abortStartup(); return; } + if (key.kind === 'enter' || key.kind === 'newline') return; + } + if (this.presetStartup?.active) { + if (key.kind === 'interrupt') { + this.presetStartup.cancel(); this.session.interrupt(); + this.output.addFrontendInteraction('/presets', 'Preset startup cancelled; remaining commands were not run.', INFO); + } + return; + } + if (this.presetPanel) { + this.handlePresetKey(key, this.presetPanel); + return; + } + if (this.toolConfigurationLoading) { + if (key.kind === 'escape' || key.kind === 'interrupt') { + this.toolConfigurationGeneration++; + this.toolConfigurationLoading = false; + this.returnFromPanel(); + this.render(); + } + return; + } + if (this.toolConfiguration) { + const state = this.toolConfiguration; + void configurationKey(state, key).then(close => { + if (close && this.toolConfiguration === state) { + this.toolConfiguration = undefined; + if (!this.toolsPanel) this.returnFromPanel(); + } + this.render(); + }); + return; + } + if (this.misePanel) { + void this.handleMiseKey(key, this.misePanel); + return; + } + if (this.toolsPanel) { + void this.handleToolsKey(key, this.toolsPanel); + return; + } if (this.paletteState) { const state = this.paletteState; if (key.kind === 'escape' || key.kind === 'interrupt' || key.kind === 'palette') this.paletteState = undefined; @@ -643,6 +880,13 @@ export class TerminalApp { } else if (localVisibleIndex >= 0) { const row = wrapped[viewStart + localVisibleIndex]; if (row) { + const affordance = blockAffordance(row, columns); + if (!this.running && key.kind === 'mouseClick' && row.lineIndex === this.hoveredLineIndex + && affordance && (key.x ?? 0) >= affordance.column && row.blockStartId !== undefined) { + this.openBlockPalette(row.blockStartId); + this.render(); + return; + } if (key.kind === 'mouseClick' && row.isFoldHint && row.commandIndex !== undefined) { this.output.toggleExpanded(row.commandIndex); this.render(); @@ -731,6 +975,14 @@ export class TerminalApp { this.historyViewport.latest(); } + if (!this.running && key.kind === 'enter' && this.focusedCommandIndex !== undefined) { + const record = this.output.recent(this.focusedCommandIndex + 1); + if (record) this.openBlockPalette(record.startId); + return; + } + if (key.kind === 'escape') this.clearBlockFocus(); + if (FLOW_EDIT_KEYS.has(key.kind) && key.kind !== 'enter') this.clearBlockFocus(); + 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(() => { @@ -742,8 +994,7 @@ export class TerminalApp { } if (key.kind === 'historySearch') { if (!this.running) { - this.editor.clear(); - this.editor.insert(HISTORY_SEARCH); + void this.openHistoryPicker(''); } return; } @@ -759,6 +1010,7 @@ export class TerminalApp { return; } if (key.kind === 'interrupt') { + this.clearCorrection(); if (this.running) { this.running.interrupted = true; this.editor.clear(); @@ -786,7 +1038,25 @@ export class TerminalApp { return; } - if (!this.running && !this.historySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 + if (this.correction && !this.running && this.editor.text.length === 0) { + const action = resolveAction(CORRECTION_ACTIONS, key); + if (action?.id === 'insert') { + this.applySuggestion(this.correction); + this.clearCorrection(); + return; + } + if (action?.id === 'dismiss') { this.clearCorrection(); return; } + } + if (key.kind === 'text' || key.kind === 'paste' || key.kind === 'enter') this.clearCorrection(); + // Input can contain several decoded keys before the next render; guard stale candidates here too. + if (this.shellSuggestions.some(candidate => candidate.context && (candidate.context.buffer !== this.editor.text || candidate.context.cwd !== this.context.cwd + || candidate.context.cursor !== undefined && candidate.context.cursor !== this.completionCursor + || candidate.context.expiresAt !== undefined && Date.now() >= candidate.context.expiresAt))) { + this.shellSuggestions = []; this.completionGeneration += 1; this.completionService.cancel(); + this.lastSuggestionInput = ''; + } + + if (!this.running && !this.historySearchActive && !this.directorySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0 && !this.suggestions.alternativesOpen) { const action = resolveAction(COMPLETION_ACTIONS, key); if (action?.id === 'move') { @@ -795,7 +1065,10 @@ export class TerminalApp { } if (action?.id === 'insert') { const candidate = this.shellSuggestions[this.selectedSuggestion] ?? this.shellSuggestions[0]; - if (candidate) this.applySuggestion(candidate); + if (candidate) { + if (this.promptConfiguration.picker !== 'native' && this.shellSuggestions.length > 1) void this.openCompletionPicker(); + else this.applySuggestion(candidate); + } return; } if (action?.id === 'cancel') { @@ -812,7 +1085,7 @@ export class TerminalApp { } // History search navigates its own matches; other slash text navigates slash commands. - const suggestions = this.historySearchActive + const suggestions = this.directorySearchActive ? this.directoryMatches(this.editor.text.substring(DIRECTORY_SEARCH.length)) : this.historySearchActive ? this.historyMatches(this.editor.text.substring(HISTORY_SEARCH.length)) : this.editor.hasPasteAtoms ? [] : slashSuggestions(this.editor.text); const isSlash = !this.editor.hasPasteAtoms && this.editor.text.startsWith('/'); @@ -827,7 +1100,7 @@ export class TerminalApp { if (suggestion) this.applySuggestion(suggestion); } else if (action === 'slash-suggestion') { const slash = suggestions[Math.min(this.selectedSuggestion, suggestions.length - 1)]; - if (slash) this.applySuggestion({insertion: slash.name}); + if (slash) this.applySuggestion({insertion: this.historySearchActive || this.directorySearchActive ? slash.insertion : slash.name}); } return; } else if (key.kind === 'text') { @@ -847,8 +1120,9 @@ export class TerminalApp { .filter(r => r.isFoldHint && r.lineIndex !== undefined) .map(r => ({lineIndex: r.lineIndex as number, activityId: r.activityId, commandIndex: r.commandIndex})); + const commandRows: Array<{lineIndex: number; activityId?: string; commandIndex?: number}> = this.output.view().completed.map((record, commandIndex) => ({lineIndex: record.startId, commandIndex})); const seenTargets = new Set(); - const focusableRows = [...metadataRows, ...foldHintRows] + const focusableRows = [...commandRows, ...metadataRows, ...foldHintRows] .filter(target => { const key = target.activityId ? `activity:${target.activityId}` : target.commandIndex !== undefined ? `command:${target.commandIndex}` : `line:${target.lineIndex}`; @@ -885,7 +1159,7 @@ export class TerminalApp { const wrappedIndex = wrapped.findIndex(r => this.focusedActivityId ? r.activityId === this.focusedActivityId && r.isFoldHint : this.focusedCommandIndex !== undefined - ? r.commandIndex === this.focusedCommandIndex && r.isFoldHint + ? r.blockStartId === this.output.recent(this.focusedCommandIndex + 1)?.startId : r.lineIndex === this.focusedLineIndex); if (wrappedIndex !== -1) { this.historyViewport.resolve(wrapped.length, outputHeight); @@ -945,7 +1219,10 @@ export class TerminalApp { this.editor.clear(); this.output.addFrontendInteraction('/resume', 'Wait for the foreground command to finish before switching transcripts.', INFO); } else { - this.session.write(`${this.editor.text}\r`); + const input = this.editor.text; + this.editor.clear(); + this.session.write(`${input}\r`); + return; } this.editor.clear(); } else { @@ -958,14 +1235,18 @@ export class TerminalApp { private async fetchSuggestions(): Promise { const input = this.editor.text; + if (!this.directorySearchActive && this.directoryQuery !== undefined) { + this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; + } if (!this.historySearchActive && this.historyQuery !== undefined) { this.historyQueryAbort?.abort(); this.historyQuery = undefined; this.historyResults = []; } const cwd = this.context.cwd; + const cursor = this.completionCursor; const eligible = !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms && !input.startsWith('/') && Boolean(input.trim()); - const key = eligible ? JSON.stringify([input, cwd]) : ''; + const key = eligible ? JSON.stringify([input, cursor, cwd]) : ''; if (key === this.lastSuggestionInput) return; this.lastSuggestionInput = key; const generation = ++this.completionGeneration; @@ -974,14 +1255,38 @@ export class TerminalApp { this.shellSuggestions = []; this.selectedSuggestion = 0; if (!eligible) return; - const comps = await this.completionService.suggest(input, cwd); + const comps = await this.completionService.suggest(input, cwd, cursor); if (!this.stopped && generation === this.completionGeneration && this.editor.text === input && this.context.cwd === cwd - && !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms) { + && this.completionCursor === cursor && !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms) { this.shellSuggestions = comps; this.render(); } } + private get completionCursor(): number { + return graphemes(this.editor.text).slice(0, this.editor.cursorIndex).join('').length; + } + + private async openCompletionPicker(): Promise { + if (this.running || this.externalPassthrough || this.pickerOpening) return; + const candidates = [...this.shellSuggestions]; + const original = this.editor.text; + const cursor = this.completionCursor; + const cwd = this.context.cwd; + this.pickerOpening = true; + try { + const native = () => { /* Keep the existing native menu on fallback. */ }; + const result = await openPicker(this.promptConfiguration.picker, candidates.map((candidate, index) => ({ + id: String(index), label: candidate.display, description: candidate.description, value: candidate.insertion, + })), native, this.pickerHandoff); + if (!this.stopped && !this.running && this.editor.text === original && this.completionCursor === cursor && this.context.cwd === cwd + && result?.kind === 'selected') { + const selected = candidates[Number(result.candidate.id)]; + if (selected && selected.insertion === result.candidate.value) this.applySuggestion(selected); + } + } finally { this.pickerOpening = false; } + } + /** * Enter in history search: bare `/history` opens the search; otherwise the * selected match is restored into the editor (not run). With no match the @@ -996,18 +1301,106 @@ export class TerminalApp { } } - private applySuggestion(suggestion: {insertion: string}): void { + private async openHistoryPicker(query: string): Promise { + if (this.running || this.externalPassthrough || this.pickerOpening) return; + this.pickerOpening = true; + try { + const original = this.editor.text; + const native = () => this.applySuggestion({insertion: `${HISTORY_SEARCH}${query}`}); + const candidates = this.promptConfiguration.picker === 'native' ? [] : (await this.historyService.search(query)).map(entry => ({ + id: entry.id, label: entry.command, value: entry.command, description: entry.cwd, + })); + if (this.stopped || this.running || this.editor.text !== original) return; + const result = await openPicker(this.promptConfiguration.picker, candidates, native, this.pickerHandoff); + if (this.stopped) return; + if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); + if (result?.kind === 'fallback') this.output.addFrontendInteraction('/history', result.reason, INFO); + this.render(); + } finally { this.pickerOpening = false; } + } + + private async openDirectoryPicker(query: string): Promise { + if (this.running || this.externalPassthrough || this.pickerOpening) return; + this.pickerOpening = true; + try { + const original = this.editor.text; + const native = () => this.applySuggestion({insertion: `${DIRECTORY_SEARCH}${query}`}); + const directories = this.promptConfiguration.picker === 'native' ? [] : await this.directoryService.query( + this.historyService.index.all(), query, this.promptConfiguration.navigation); + if (this.stopped || this.running || this.editor.text !== original) return; + const result = await openPicker(this.promptConfiguration.picker, directories.map(item => ({ + id: item.path, label: item.path, description: item.project, value: directoryCommand(item.path), + })), native, this.pickerHandoff); + if (this.stopped) return; + if (result?.kind === 'selected') this.applySuggestion({insertion: result.candidate.value}); + if (result?.kind === 'fallback') this.output.addFrontendInteraction('/dirs', result.reason, INFO); + this.render(); + } finally { this.pickerOpening = false; } + } + + /** External pickers temporarily own the host terminal, never the managed shell PTY. */ + private readonly pickerHandoff: PickerHandoff = async run => { + if (!process.stdin.isTTY || !process.stdout.isTTY || this.running || this.passthrough || this.externalPassthrough) + return {kind: 'fallback', reason: 'A free interactive terminal is required; using Native'}; + const controller = new AbortController(); + const abort = () => controller.abort(); + const ignoreInterrupt = () => { /* The foreground picker handles Ctrl+C. */ }; + this.cancelPresentation(); + this.externalPassthrough = true; + let detached = false; + let released = false; + let left = false; + try { + process.stdin.off('data', this.onInput); process.stdin.pause(); detached = true; + process.stdin.setRawMode(false); released = true; + this.terminalFocus = 'unknown'; + this.renderer.leave(); left = true; + process.on('SIGINT', ignoreInterrupt); + process.on('SIGWINCH', abort); + this.pickerAbort = controller; + return await run(controller.signal); + } catch (error) { return {kind: 'fallback', reason: `Picker failed: ${String(error)}; using Native`}; } + finally { + process.off('SIGINT', ignoreInterrupt); process.off('SIGWINCH', abort); + this.pickerAbort = undefined; + if (!this.stopped) { + if (left) this.renderer.enter(); + if (released) process.stdin.setRawMode(true); + this.keyDecoder.reset(); + if (detached) { process.stdin.on('data', this.onInput); process.stdin.resume(); } + this.renderer.invalidate(); + } + this.externalPassthrough = false; + this.render(); + } + }; + + private applySuggestion(suggestion: {insertion: string; insertionCursor?: number}): void { this.editor.clear(); this.editor.insert(suggestion.insertion); + if (suggestion.insertionCursor !== undefined) { + const trailing = graphemes(suggestion.insertion.slice(suggestion.insertionCursor)).length; + for (let i = 0; i < trailing; i++) this.editor.moveLeft(); + } this.selectedSuggestion = 0; } /** Runs one NMSh slash command; the palette and the composer share this dispatch. */ private async runSlash(command: string, slash: NonNullable>): Promise { - if (slash.kind === 'copy') await this.copyRecent(slash.index); + if (slash.kind === 'effects') { + if (slash.effect === 'help') this.output.addFrontendInteraction(command, '/effects sparkles|rain [top|bottom] · /effects stop · Escape cancels. Owned gaps/rules only; Reduced Motion and Effects Off suppress previews.', INFO); + else if (slash.effect === 'stop') this.effects.cancel(); + else if (!this.running && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended) { + this.effects.trigger(slash.effect, slash.placement, Date.now(), 0x4e4d5348, {...this.promptConfiguration.presentation, + reducedMotion: this.promptConfiguration.presentation.reducedMotion || isReducedMotion()}); + } + this.render(); + } + else if (slash.kind === 'copy') await this.copyRecent(slash.index); else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); + else if (slash.kind === 'tools') { this.panelOrigin = undefined; this.startTools(); } else if (slash.kind === 'transcript') { this.panelOrigin = undefined; this.startTranscriptSettings(); } else if (slash.kind === 'syntax') { this.panelOrigin = undefined; this.startSyntaxSettings(); } else if (slash.kind === 'layout') { this.panelOrigin = undefined; this.startLayoutSettings(); } @@ -1016,16 +1409,60 @@ export class TerminalApp { else if (slash.kind === 'version') this.output.addFrontendInteraction(command, formatBuildIdentity(this.buildIdentity), INFO); else if (slash.kind === 'update') void this.runUpdateCommand(command, slash.apply); else if (slash.kind === 'clear') await this.startFreshPresentation(); + else if (slash.kind === 'presets') this.startPresets(); else if (slash.kind === 'resume') await this.openResumePicker(); else if (slash.kind === 'help') this.showHelp(command); - else if (slash.kind === 'history') this.submitHistorySearch(slash.query, command.startsWith(HISTORY_SEARCH)); + else if (slash.kind === 'history') { + if (command.startsWith(HISTORY_SEARCH)) this.submitHistorySearch(slash.query, true); + else await this.openHistoryPicker(slash.query); + } + else if (slash.kind === 'directories') { + if (command.startsWith(DIRECTORY_SEARCH)) { + const selected = this.directoryMatches(slash.query)[this.selectedSuggestion]; + this.applySuggestion({insertion: selected?.insertion ?? `${DIRECTORY_SEARCH}${slash.query}`}); + } else await this.openDirectoryPicker(slash.query); + } else if (slash.kind === 'palette') this.openPalette(); else this.output.addFrontendInteraction(command, `Unknown NMSh command: ${(slash as any).input || command}`, ERROR); } private openPalette(): void { if (this.settingsPanelActive) return; - this.paletteState = createPalette(); + const record = this.focusedCommandIndex === undefined ? this.output.recent(1) : this.output.recent(this.focusedCommandIndex + 1); + this.paletteState = createPalette([...paletteItems(), ...(record ? blockPaletteItems(record) : [])]); + } + + private clearBlockFocus(): void { + this.focusedCommandIndex = undefined; + this.focusedLineIndex = undefined; + this.focusedActivityId = undefined; + } + + private openBlockPalette(startId: number): void { + if (this.running || this.settingsPanelActive) return; + const record = this.output.view().completed.find(item => item.startId === startId); + if (record) this.paletteState = createPalette(blockPaletteItems(record)); + } + + private async runBlockAction(startId: number, action: BlockActionId): Promise { + if (this.running || this.stopped) return; + const records = this.output.view().completed; + const index = records.findIndex(item => item.startId === startId); + const record = records[index]; + if (!record) return; // A clear/restore must never act on stale screen coordinates. + const payload = blockCopyPayload(record, action); + if (payload !== undefined) { + try { await writeClipboard(payload); } + catch (error) { this.output.addFrontendInteraction('/copy', clipboardFailure(error), ERROR); } + } else if (action === 'fold') this.output.toggleExpanded(index); + else if (action === 'edit' || action === 'rerun') { + this.clearBlockFocus(); + this.editor.clear(); + this.editor.insert(record.command); + this.historyViewport.latest(); + // A real stored shell command must never become an NMSh slash dispatch. + if (action === 'rerun' && !parseSlashCommand(record.command)) await this.submit(); + } } /** Executes only the declared NMSh action of the chosen entry. */ @@ -1034,6 +1471,9 @@ export class TerminalApp { const action = item.action; const config = structuredClone(this.promptConfiguration); switch (action.kind) { + case 'block': + await this.runBlockAction(action.startId, action.id); + break; case 'slash': { const slash = parseSlashCommand(action.command); if (slash) await this.runSlash(action.command, slash); @@ -1067,18 +1507,22 @@ export class TerminalApp { case 'latest': this.historyViewport.latest(); break; + case 'toggleInspector': + this.inspectorVisible = !this.inspectorVisible; + break; case 'toggleDetails': this.output.toggleMostRelevant(); break; } } - private async submit(): Promise { + private async submit(realShell = false): Promise { + this.clearCorrection(); const command = this.editor.text; this.editor.clear(); if (!command.trim()) return; - const slash = parseSlashCommand(command); + const slash = realShell ? undefined : parseSlashCommand(command); if (slash) { await this.runSlash(command, slash); this.render(); @@ -1086,10 +1530,13 @@ export class TerminalApp { } const contextAtSubmission = this.context; + this.effects.cancel(); this.commandModes.reset(); const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), (mode) => { - if (mode === 'PASSTHROUGH' && !this.passthrough) { + if (mode === 'PASSTHROUGH' && !this.passthrough && !this.startupPending) { + this.cancelPresentation(); this.passthrough = true; + this.terminalFocus = 'unknown'; this.renderer.suspendForPassthrough(); const dimensions = this.dimensions(); this.session.resize(dimensions.columns, dimensions.rows); @@ -1101,15 +1548,16 @@ export class TerminalApp { this.output.setActiveActivities([]); this.formatCommandAnsi(command, startId); const startedAt = Date.now(); - this.running = {command, startedAt, interrupted: false, cleared: false, startId, cwd: this.shellCwd}; + this.running = {command, startedAt, interrupted: false, cleared: false, startId, cwd: this.shellCwd, awaitingExec: true}; void this.journal?.flush().catch(() => { this.output.addFrontendInteraction('/resume', 'Could not persist the submitted command.', ERROR); }); this.activityAnimationNow = startedAt; // Initial static heuristic, but dynamic can override - this.passthrough = shouldPassthrough(command); + this.passthrough = !this.startupPending && shouldPassthrough(command); if (this.passthrough) { + this.terminalFocus = 'unknown'; this.renderer.suspendForPassthrough(); const dimensions = this.dimensions(); this.session.resize(dimensions.columns, dimensions.rows); @@ -1130,7 +1578,7 @@ export class TerminalApp { this.output.addHistoryLine(`${INFO}✻ Saving appearance settings...${RESET}`); this.render(); - const result = await saveGhosttySettings({ + const result = await this.host.integration!.saveAppearance({ opacity: state.opacity, blurMode: BLUR_MODES[state.blurModeIndex], blurStrength: state.blurStrength @@ -1140,7 +1588,7 @@ export class TerminalApp { this.output.addHistoryLine(`${SUCCESS}✻ Saved to ${result.fragmentPath}${RESET}`); this.output.addHistoryLine(`${INFO}✻ Host config updated: ${result.hostPath}${RESET}`); if (state.opacity < 1) { - this.output.addHistoryLine(`${INFO}✻ Note: opacity changes require Ghostty restart${RESET}`); + this.output.addHistoryLine(`${INFO}✻ ${this.host.integration!.appearanceRestart}${RESET}`); } } else { this.output.addHistoryLine(`${ERROR}✻ Failed to save appearance${RESET}`); @@ -1150,33 +1598,11 @@ export class TerminalApp { } private async startKeyboard(): Promise { - const isGhostty = process.env.TERM_PROGRAM === 'ghostty'; - const isVSCode = process.env.TERM_PROGRAM === 'vscode'; - if (isVSCode) { - // VS Code sends identical bytes for Enter and Shift+Enter (both \r at PTY level). - // NMSh cannot distinguish them without an explicit VS Code keybinding. - // The binding below sends the Kitty Shift+Enter sequence \u001B[13;2u which - // NMSh already maps to insertNewline. - const vscodeNote = [ - `VS Code sends identical bytes for Enter and Shift+Enter.`, - `To enable Shift+Enter → insert newline, add this to your VS Code keybindings.json:`, - ``, - ` { "key": "shift+enter",`, - ` "command": "workbench.action.terminal.sendSequence",`, - ` "args": { "text": "\\u001b[13;2u" },`, - ` "when": "terminalFocus" }`, - ``, - `Ctrl+J always inserts a newline without any config (portable fallback).`, - ].join('\n'); - this.output.addFrontendInteraction('/keyboard', vscodeNote, INFO); + if (!this.host.capabilities.hostConfiguration || !this.host.integration) { + this.output.addFrontendInteraction('/keyboard', this.host.keyboardGuidance ?? 'Ctrl+J inserts a newline; Ctrl+W deletes a word.', INFO); this.render(); return; } - if (!isGhostty && !await detectGhosttyConfigPath()) { - this.output.addFrontendInteraction('/keyboard', `Host is not Ghostty. Keyboard integration is specific to Ghostty currently.`, INFO); - this.render(); - return; - } this.keyboardState = { selectedIndex: 0 }; this.render(); } @@ -1186,14 +1612,14 @@ export class TerminalApp { if (!this.keyboardState) return; this.keyboardState = undefined; - this.output.addHistoryLine(`${INFO}✻ Installing Ghostty Cmd+A binding...${RESET}`); + this.output.addHistoryLine(`${INFO}✻ Installing ${this.host.name} keyboard bindings...${RESET}`); this.render(); - const result = await installGhosttyKeybinding(); + const result = await this.host.integration!.installKeyboard(); if (result.success) { - this.output.addHistoryLine(`${SUCCESS}✻ Installed Cmd+A binding in Ghostty config${RESET}`); - this.output.addHistoryLine(`${INFO}✻ Reload Ghostty config (Cmd+Shift+,) for changes to take effect${RESET}`); + this.output.addHistoryLine(`${SUCCESS}✻ Installed keyboard bindings in ${this.host.name} config${RESET}`); + this.output.addHistoryLine(`${INFO}✻ ${this.host.integration!.keyboardReload}${RESET}`); } else { this.output.addHistoryLine(`${ERROR}✻ Failed to install binding${RESET}`); this.output.addHistoryLine(` ⎿ ${result.error}`); @@ -1202,17 +1628,13 @@ export class TerminalApp { } private async startAppearance(): Promise { - const isGhostty = process.env.TERM_PROGRAM === 'ghostty'; - const isVSCode = process.env.TERM_PROGRAM === 'vscode'; - - if (isVSCode || (!isGhostty && !await detectGhosttyConfigPath())) { - this.output.addFrontendInteraction('/appearance', `Host: ${isVSCode ? 'VS Code Integrated Terminal' : 'Unsupported Host'}\nWindow opacity and blur are controlled by the host.`, INFO); - this.returnFromPanel(); - this.render(); - return; + if (!this.host.capabilities.appearanceIntegration || !this.host.integration) { + this.output.addFrontendInteraction('/appearance', `Host: ${this.host.name}\nWindow opacity and blur are controlled by the host.`, INFO); + this.returnFromPanel(); + this.render(); + return; } - - const settings = await readGhosttySettings(); + const settings = await this.host.integration.readAppearance(); this.appearanceState = { opacity: settings.opacity, blurModeIndex: Math.max(0, BLUR_MODES.indexOf(settings.blurMode)), @@ -1233,8 +1655,8 @@ export class TerminalApp { const payload = serializeCopyPayload(record); await writeClipboard(payload); this.output.addFrontendInteraction(command, copyFeedback(copyStats(payload), index), INFO); - } catch { - this.output.addFrontendInteraction(command, 'Clipboard copy failed', ERROR); + } catch (error) { + this.output.addFrontendInteraction(command, clipboardFailure(error), ERROR); } } @@ -1434,8 +1856,24 @@ export class TerminalApp { this.output.addFrontendInteraction(command, helpText, INFO); } + private onInputRejected(data: string, submission: boolean): void { + if (submission && this.running?.awaitingExec) { + const command = this.running.command; + this.output.complete(1); + this.running = undefined; + if (!this.editor.text) this.editor.insert(command); + } else { + const input = data.replace(/\r$/u, ''); + if (!this.editor.text) this.editor.insert(input); + else this.output.addFrontendInteraction('rejected input', input, ERROR); + } + this.output.addFrontendInteraction('session', 'Input was not sent: shell startup queue exceeds 64 KiB. Rejected input is retained. Wait for readiness, then submit again.', ERROR); + this.render(); + } + private onShellData(data: string): void { if (this.passthrough) { + this.renderer.observePassthrough(data); process.stdout.write(data); } else { this.commandModes.observeModes(data); @@ -1445,6 +1883,7 @@ export class TerminalApp { this.journal?.schedule(); this.output.setActiveActivities(this.tapActivityObserver.push(data, Date.now())); if (!wasPassthrough && this.passthrough) { + this.renderer.observePassthrough(data); process.stdout.write(data); } else if (!this.replaying) { this.render(); @@ -1453,15 +1892,35 @@ export class TerminalApp { } private onShellPrompt(exitCode: number, cwd: string, at = Date.now()): void { + this.completionService.invalidate(); + this.shellSuggestions = []; + this.lastSuggestionInput = ''; this.shellCwd = cwd; + this.endStartupWatch(); + const initialPrompt = !this.presetShellReady; + this.presetShellReady = true; this.context.exitStatus = exitCode; + // A slow global/user bootstrap may finish after the frontend submits. + // Its initial prompt is readiness, not completion of that queued command. + if (initialPrompt && this.running?.awaitingExec) { + void this.refreshContext(cwd); + this.render(); + return; + } if (!this.running) { void this.refreshContext(cwd); this.render(); + this.advancePresetStartup(exitCode, cwd); return; } const command = this.running; + const notification = {command: command.command, elapsedMs: Math.max(0, at - command.startedAt), exitCode, + interrupted: command.interrupted || exitCode === 130}; + if (!this.replaying && shouldNotify(notification, this.promptConfiguration.notifications, this.terminalFocus)) { + // Delivery failures must never affect completion, transcript or journal. + try { void this.notificationService.notify(formatCommandNotification(notification)).catch(() => {}); } catch { /* best effort */ } + } if (this.replaying) this.replayedCompletions += 1; const completedAt = new Date(at); const elapsed = completedAt.getTime() - command.startedAt; @@ -1471,6 +1930,8 @@ export class TerminalApp { completedRecord.startedAt = command.startedAt; completedRecord.durationMs = Math.max(0, elapsed); completedRecord.historyEligible = command.historyAllowed === 1 && !isPrivateCommand(command.command, ignorePatternFromEnv()); + this.directoryQuery = undefined; + this.directoryQueryAbort?.abort(); this.historyService.record(completedRecord, this.journal?.id ?? this.sessionId ?? 'current'); this.historyQuery = undefined; } @@ -1483,11 +1944,14 @@ export class TerminalApp { const isInterrupted = command.interrupted || exitCode === 130; const displayCompletedAt = presentationCompletionTime(completedAt); const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); + const failure = isInterrupted ? undefined : classifyShellFailure(command.command, exitCode, outputText); + if (failure) parts.main = parts.main.replace('Command failed', failure === 'command-not-found' ? 'Command not found' : 'Shell syntax error'); this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`); const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS); this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); } this.running = undefined; + if (!this.replaying && !command.interrupted && !command.cleared) void this.suggestCorrection(command.command, exitCode, completedRecord?.output ?? '').catch(() => {}); void this.journal?.flush().catch(() => { this.output.addFrontendInteraction('/resume', 'Could not persist the completed command.', ERROR); }); @@ -1500,6 +1964,7 @@ export class TerminalApp { } void this.refreshContext(cwd); this.render(); + this.advancePresetStartup(exitCode, cwd); } @@ -1577,8 +2042,9 @@ export class TerminalApp { this.externalPromptError = error instanceof Error ? error.message : String(error); this.externalPrompt = undefined; this.effectivePromptProvider = 'nmsh'; + const saved = structuredClone(this.promptConfiguration); this.promptConfiguration.provider = 'nmsh'; - try { savePromptConfiguration(this.promptConfiguration); } catch { /* Runtime fallback remains in effect. */ } + try { savePromptConfiguration(this.promptConfiguration, undefined, saved); } catch { /* Runtime fallback remains in effect. */ } } } @@ -1610,6 +2076,7 @@ export class TerminalApp { if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('The Powerlevel10k wizard requires a real terminal.'); if (this.running || this.passthrough || this.externalPassthrough) throw new Error('The terminal is busy.'); const ignoreInterrupt = (): void => { /* The foreground wizard handles Ctrl+C. */ }; + this.cancelPresentation(); this.externalPassthrough = true; let inputDetached = false; let rawModeReleased = false; @@ -1621,6 +2088,7 @@ export class TerminalApp { inputDetached = true; process.stdin.setRawMode(false); rawModeReleased = true; + this.terminalFocus = 'unknown'; this.renderer.leave(); rendererLeft = true; process.on('SIGINT', ignoreInterrupt); @@ -1796,7 +2264,7 @@ export class TerminalApp { state.step = 'starship'; state.selectedIndex = 0; } else { state.step = 'installProgress'; - state.task = new TaskProgress('Installing Starship with Homebrew', () => this.render(), Date.now(), 'Starship'); + state.task = new TaskProgress('Installing Starship with Homebrew', () => this.renderTaskPresentation(), Date.now(), 'Starship'); this.render(); const outcome = await state.task.run('brew', ['install', 'starship']); if (this.stopped) return; @@ -1829,9 +2297,10 @@ export class TerminalApp { if (!state) return; state.draft.onboardingComplete = true; try { - savePromptConfiguration(state.draft); + savePromptConfiguration(state.draft, undefined, this.promptConfiguration); this.promptConfiguration = structuredClone(state.draft); this.promptPanelState = undefined; + if (state.onboarding && !this.promptConfiguration.toolsSetupComplete) this.startTools(true); this.panelExternalPrompt = undefined; // Turning Rich Git on needs a status probe the last refresh may have skipped. if (state.saved?.nmsh.gitEnabled !== state.draft.nmsh.gitEnabled) void this.refreshContext(this.shellCwd); @@ -1902,11 +2371,17 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState - || this.resumeBrowser || this.appearanceState || this.keyboardState); + return Boolean(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.appearanceState || this.keyboardState || this.startupPanel); } private settingsPanelRows(columns: number): string[] { + if (this.startupPanel) return framePanel(renderStartupPanel({tail: this.startupPanel.tail, elapsedMs: Date.now() - this.startupPanel.since}, columns, this.dimensions().rows), columns); + if (this.toolConfigurationLoading) return framePanel([' Reading supported configuration...', ' Esc cancel'], columns); + if (this.toolConfiguration) return renderConfigurationPanel(this.toolConfiguration, columns, this.dimensions().rows); + if (this.presetPanel) return renderPresetPanel(this.presetPanel, columns, this.dimensions().rows); + if (this.misePanel) return renderMisePanel(this.misePanel, columns, this.dimensions().rows); + if (this.toolsPanel) return renderTools(this.toolsPanel, columns, this.dimensions().rows); if (this.settingsPanelState) { return renderSettingsPanel(this.settingsPanelState, columns, this.dimensions().rows, {configuration: this.promptConfiguration, status: settingsView(this.settingsPanelState) === 'status' ? this.statusSections() : undefined}); @@ -1971,7 +2446,7 @@ export class TerminalApp { return framePanel(rows, columns); } if (this.appearanceState) return framePanel(renderAppearancePanel(this.appearanceState, columns), columns); - if (this.keyboardState) return framePanel(renderKeyboardPanel(this.keyboardState, columns), columns); + if (this.keyboardState) return framePanel(renderKeyboardPanel(this.keyboardState, columns, this.host.name), columns); return framePanel(this.renderedPromptPanel(columns), columns); } @@ -1992,7 +2467,7 @@ export class TerminalApp { private saveGlyphChoice(style: PromptConfiguration['glyphStyle']): void { const next = {...this.promptConfiguration, glyphStyle: style, glyphChoiceComplete: true}; try { - savePromptConfiguration(next); + savePromptConfiguration(next, undefined, this.promptConfiguration); this.promptConfiguration = next; setIconStyle(style); const onboarding = this.settingsPanelState?.onboarding; @@ -2003,8 +2478,9 @@ export class TerminalApp { this.promptPanelState = {onboarding: true, step: 'provider', selectedIndex: PROVIDER_ORDER.indexOf(next.provider), draft: structuredClone(next), saved: structuredClone(next)}; } - } catch { + } catch (error) { // Keep the chooser visible so the user can retry without losing their choice. + this.output.addHistoryLine(`${ERROR}${error instanceof Error ? error.message : String(error)}${RESET}`); if (this.settingsPanelState) this.settingsPanelState.glyphStyle = style; } } @@ -2100,15 +2576,124 @@ export class TerminalApp { this.panelOriginView = view; this.panelOriginRow = rowIndex; this.settingsPanelState = undefined; - if (destination === 'appearance') void this.startAppearance(); + if (destination === 'tools') this.startTools(); + else if (destination === 'toolConfig') void this.startToolConfiguration('starship'); + else if (destination === 'appearance') void this.startAppearance(); else if (destination === 'prompt') void this.startPromptSettings(false); else if (destination === 'transcript') this.startTranscriptSettings(); else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); - else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history') this.startProviderPanel(destination); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); else void this.startKeyboard(); } + private async startToolConfiguration(id: string): Promise { + const generation = ++this.toolConfigurationGeneration; + this.toolConfigurationLoading = true; + this.render(); + try { + const adapter = await openSupportedConfiguration(id, this.starshipEnvironment(this.promptConfiguration)); + const state = await createConfigurationPanel(adapter); + if (!this.stopped && generation === this.toolConfigurationGeneration) this.toolConfiguration = state; + } catch { + if (!this.stopped && generation === this.toolConfigurationGeneration) { + this.output.addFrontendInteraction('/settings', 'Supported tool configuration is unavailable. Check installation and configuration.', INFO); + this.returnFromPanel(); + } + } finally { + if (generation === this.toolConfigurationGeneration) this.toolConfigurationLoading = false; + } + this.render(); + } + + private startPresets(): void { + try { this.presetPanel = createPresetPanel(this.presetStore.list()); } + catch (error) { this.output.addFrontendInteraction('/presets', error instanceof Error ? error.message : 'Could not read presets.', ERROR); } + } + + private handlePresetKey(key: Key, state: PresetPanel): void { + const action = presetPanelKey(state, key, this.shellCwd); + try { + if (action === 'close') this.presetPanel = undefined; + else if (action === 'create' && state.form) { + const created = this.presetStore.create({name:state.form.name,cwd:state.form.cwd,commands:state.form.commands.split('\n').filter(command=>command.trim())}); + state.presets = this.presetStore.list(); state.selected = state.presets.findIndex(preset => preset.name === created.name); state.form = undefined; state.message = 'Preset created. Enter inspects it; L launches a new session.'; + } else if (action === 'delete' && state.detail) { + this.presetStore.delete(state.detail.name); state.presets = this.presetStore.list(); state.detail = undefined; state.message = 'Preset deleted; live sessions are unchanged.'; + } else if (action === 'launch' && state.detail) { + if (this.sessionMode !== 'service') throw new Error('Preset launch requires the live-session service. Start a new terminal with nmsh --preset .'); + // If the stored content changed since inspection, acknowledge rejects it. + const current = this.presetStore.get(state.detail.name); + if (presetNeedsAcknowledgement(current) && !presetNeedsAcknowledgement(state.detail)) throw new Error('Preset changed; reopen and review it.'); + this.switchPreset = this.presetStore.acknowledge(state.detail); + this.detaching = true; this.session.detach(); this.stop(0); + } + } catch (error) { state.message = error instanceof Error ? error.message : 'Preset operation failed.'; } + if (!this.stopped) this.render(); + } + + private advancePresetStartup(exitCode: number, cwd: string): void { + if (!this.presetFrontendReady || !this.presetShellReady || this.stopped || this.running || !this.presetStartup?.active) return; + const next = this.presetStartup.next(exitCode,cwd); + if (next && 'error' in next) this.output.addFrontendInteraction('/presets',next.error,ERROR); + else if (next) { + this.editor.clear(); this.editor.insert(next.command); + void this.submit(true); + } + } + + private startTools(onboarding = false): void { + const config = this.promptConfiguration; + const state = this.toolsPanel = createToolsPanel(new Set([config.history, config.picker, config.navigation, config.welcome, config.provider]), onboarding); + void refreshTools(state, () => { if (!this.stopped && this.toolsPanel === state) this.render(); }); + } + + private async handleToolsKey(key: Key, state: ToolsPanel): Promise { + if (state.confirm) { + await confirmToolInstall(state, key, () => this.renderTaskPresentation()); + this.render(); + return; + } + const wasOnboarding = state.onboarding !== undefined; + const action = toolsKey(state, key); + if (wasOnboarding && (action === 'close' || action === 'finishOnboarding')) { + this.applySettingsConfiguration({...this.promptConfiguration, toolsSetupComplete: true}); + } + if (action === 'close') { this.toolsPanel = undefined; this.returnFromPanel(); } + else if (action === 'mise') { + const project = detectMiseProject(this.shellCwd); + this.misePanel = {project, selected: 0, result: this.miseService.cached(project)}; + } + else if (action === 'configure' && state.detail?.configuration) await this.startToolConfiguration(state.detail.configuration); + else if (action === 'provider') { + const family = state.detail?.providerFamily; + if (family === 'welcome' || family === 'history' || family === 'picker' || family === 'navigation') { + this.toolsPanel = undefined; + this.startProviderPanel(family); + } + } else if (action === 'refresh') await refreshTools(state, () => this.render()); + this.render(); + } + + private async handleMiseKey(key: Key, state: MisePanel): Promise { + const action = misePanelKey(state, key); + if (action === 'close') { this.miseService.cancel(); this.misePanel = undefined; } + else if (action === 'inspect') { + state.busy = true; + this.render(); + // A fresh identity after explicit consent; no metadata on cwd/render events. + state.project = detectMiseProject(this.shellCwd); + const result = await this.miseService.inspect(state.project, true, true); + if (!this.stopped && this.misePanel === state) { state.result = result; state.selected = 0; state.busy = false; } + } else if (action && typeof action === 'object') { + this.misePanel = undefined; this.toolsPanel = undefined; + this.returnFromPanel(); + this.editor.clear(); this.editor.insert(action.command); + this.historyViewport.latest(); + } + if (!this.stopped) this.render(); + } + /** * Read-only facts for the Status view, from in-memory state only: no * subprocesses, no environment values beyond the terminal's self-reported @@ -2120,18 +2705,19 @@ export class TerminalApp { const {columns, rows} = this.dimensions(); const home = homedir(); const tilde = (path: string) => path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; - const terminal = process.env.TERM_PROGRAM - ? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ''}` - : undefined; + const terminal = this.host.name; const active = this.effectivePromptProvider; return [ [ {label: 'Version', value: build.version}, {label: 'Build', value: `${build.commit}${build.branch ? ` (${build.branch}${build.dirty ? ', dirty' : ''})` : ''}`, tone: build.commit === 'unknown' ? 'muted' : undefined}, - {label: 'Shell', value: 'zsh (/bin/zsh)'}, + {label: 'Platform', value: `${process.platform} ${process.arch}`}, + {label: 'Node', value: process.version}, + {label: 'Shell', value: 'zsh'}, {label: 'Session', value: this.sessionId ? `live · ${this.sessionId}` : 'in-process', tone: this.sessionMode === 'service' ? undefined : 'muted'}, {label: 'Working directory', value: tilde(this.shellCwd)}, ...(terminal ? [{label: 'Terminal', value: terminal}] : []), + {label: 'Host capabilities', value: Object.entries(this.host.capabilities).filter(([, value]) => value === true).map(([key]) => key).join(', ') || 'baseline'}, {label: 'Terminal size', value: `${columns}×${rows}`}, ], [ @@ -2140,6 +2726,8 @@ export class TerminalApp { {label: 'Composer', value: layoutLabel(config)}, {label: 'Glyph style', value: config.glyphStyle === 'nerd' ? 'Nerd Font' : 'Safe / ASCII'}, {label: 'Syntax', value: !config.syntax.highlighting ? 'Off' : config.syntax.colors === 'followPrompt' ? 'Follow prompt theme' : config.syntax.colors === 'theme' ? 'Choose theme' : 'Grayscale'}, + {label: 'Directory navigation', value: this.directoryService.status.detail ?? this.directoryService.status.active}, + {label: 'Picker', value: this.promptConfiguration.picker}, {label: 'Command history', value: this.historyService.status.detail ?? (this.historyService.status.active === 'atuin' ? 'Atuin · local read-only' : 'NMSh Native')}, {label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, ], @@ -2155,13 +2743,16 @@ export class TerminalApp { private applySettingsConfiguration(next: PromptConfiguration | undefined): void { if (!next) return; try { - savePromptConfiguration(next); - } catch { + savePromptConfiguration(next, undefined, this.promptConfiguration); + } catch (error) { + this.output.addHistoryLine(`${ERROR}${error instanceof Error ? error.message : String(error)}${RESET}`); + this.render(); return; } this.promptConfiguration = next; setIconStyle(next.glyphStyle); this.output.setTranscriptAppearance(next.transcript); + this.output.presenter.setTreatment(next.presentation); this.output.setOutputFolding(next.outputFolding); this.output.presenter.setLayout(next.transcriptPresentation); if (this.settingsPanelState) this.settingsPanelState.glyphStyle = next.glyphStyle; @@ -2272,9 +2863,11 @@ export class TerminalApp { }); } - private startProviderPanel(family: 'welcome' | 'suggestions' | 'history'): void { + private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'): void { const state: ProviderPanelState = family === 'welcome' ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) + : family === 'navigation' ? createProviderPanel(family, 'Directory navigation', NAVIGATION_PROVIDERS, this.promptConfiguration.navigation) + : family === 'picker' ? createProviderPanel(family, 'Picker', PICKER_PROVIDERS, this.promptConfiguration.picker) : family === 'history' ? createProviderPanel(family, 'Command history', HISTORY_PROVIDERS, this.promptConfiguration.history) : createProviderPanel(family, 'Suggestions', SUGGESTION_PROVIDERS, this.promptConfiguration.suggestions); this.providerPanelState = state; @@ -2290,6 +2883,8 @@ export class TerminalApp { /** The highlighted provider rendered by its own family; captures are cached per panel. */ private providerPreview(state: ProviderPanelState, width: number): string[] { const selected = providerPanelSelection(state); + if (state.family === 'navigation') return [`${SUBTLE}Find with /dirs; selecting inserts a visible cd command. Press Enter separately to navigate.${RESET}`]; + if (state.family === 'picker') return [`${SUBTLE}Selections restore the composer; cancel leaves it unchanged. Missing or failing tools use Native.${RESET}`]; if (state.family === 'history') return [`${SUBTLE}${selected.id === 'atuin' ? 'Read-only local history; existing hooks unchanged; no sync.' : 'Shell-approved journal metadata and imported zsh history.'}${RESET}`]; if (state.family === 'suggestions') { if (selected.id === 'none') return [`${SUBTLE}No ghost text while typing.${RESET}`]; @@ -2301,7 +2896,7 @@ export class TerminalApp { const cached = this.welcomePreviews.get(selected.id); if (cached) return cached; this.welcomePreviews.set(selected.id, [`${SUBTLE}Running ${selected.label}…${RESET}`]); - void captureWelcome(selected.id as 'fastfetch' | 'neofetch', this.shellCwd).then(result => { + void captureWelcome(selected.id as Exclude, this.shellCwd).then(result => { this.welcomePreviews.set(selected.id, result.ok ? renderWelcome({...createWelcomeSnapshot(this.buildIdentity, this.shellCwd), captured: result.lines}, width).map(row => row.ansi) : [`${SUBTLE}${selected.label} failed: ${result.reason}${RESET}`]); @@ -2319,7 +2914,7 @@ export class TerminalApp { const selected = providerPanelSelection(state); if (state.step === 'installConfirm' && selected.install) { state.step = 'installProgress'; - state.task = new TaskProgress(`Installing ${selected.label}`, () => this.render(), Date.now(), selected.label); + state.task = new TaskProgress(`Installing ${selected.label}`, () => this.renderTaskPresentation(), Date.now(), selected.label); this.render(); const outcome = await state.task.run(selected.install.command, [...selected.install.args]); if (this.stopped) return; @@ -2342,14 +2937,17 @@ export class TerminalApp { const selected = providerPanelSelection(state); const next = state.family === 'welcome' ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} + : state.family === 'navigation' ? {...structuredClone(this.promptConfiguration), navigation: selected.id as PromptConfiguration['navigation']} + : state.family === 'picker' ? {...structuredClone(this.promptConfiguration), picker: selected.id as PromptConfiguration['picker']} : state.family === 'history' ? {...structuredClone(this.promptConfiguration), history: selected.id as PromptConfiguration['history']} : {...structuredClone(this.promptConfiguration), suggestions: selected.id as PromptConfiguration['suggestions']}; try { - savePromptConfiguration(next); + savePromptConfiguration(next, undefined, this.promptConfiguration); this.promptConfiguration = next; this.providerPanelState = undefined; if (state.family === 'suggestions') this.applySuggestionProvider(); if (state.family === 'history') void this.loadHistory(); + if (state.family === 'navigation') { this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; } this.output.addHistoryLine(state.family === 'welcome' ? `${SUCCESS}Welcome · ${selected.label} · shown on launch and /clear.${RESET}` : `${SUCCESS}${state.title} · ${selected.label}.${RESET}`); @@ -2377,9 +2975,10 @@ export class TerminalApp { if (!state) return; const next = {...structuredClone(this.promptConfiguration), transcript: structuredClone(state.draft)}; try { - savePromptConfiguration(next); + savePromptConfiguration(next, undefined, this.promptConfiguration); this.promptConfiguration = next; this.output.setTranscriptAppearance(next.transcript); + this.output.presenter.setTreatment(next.presentation); this.transcriptPanelState = undefined; this.output.addHistoryLine(`${SUCCESS}Transcript settings saved.${RESET}`); } catch (error) { @@ -2422,7 +3021,7 @@ export class TerminalApp { if (!state) return; const next = {...structuredClone(this.promptConfiguration), syntax: structuredClone(state.draft)}; try { - savePromptConfiguration(next); + savePromptConfiguration(next, undefined, this.promptConfiguration); this.promptConfiguration = next; this.syntaxPanelState = undefined; this.returnFromPanel(); @@ -2485,23 +3084,25 @@ export class TerminalApp { * change. Blinks are skipped (not queued) while no welcome is present. */ private scheduleWelcomeBlink(): void { - if (this.stopped || isReducedMotion()) return; - this.welcomeBlinkTimer = setTimeout(() => { - if (this.stopped) return; + if (this.stopped || !this.decorativeMotionAllowed() || !this.output.hasWelcome) return; + this.welcomeBlinkTimer = presentationClock.after(() => { + this.welcomeBlinkTimer = undefined; + if (this.stopped || !this.decorativeMotionAllowed()) return; if (!this.output.hasWelcome || this.passthrough) { this.welcomeBlinkCount += 1; this.scheduleWelcomeBlink(); return; } this.output.setWelcomeFrame('blink'); - this.render(); - this.welcomeBlinkTimer = setTimeout(() => { + this.welcomeBlinkTimer = presentationClock.after(() => { + this.welcomeBlinkTimer = undefined; this.output.setWelcomeFrame('open'); if (this.stopped) return; - this.render(); this.welcomeBlinkCount += 1; this.scheduleWelcomeBlink(); + this.render(); }, WELCOME_BLINK_CLOSED_MS); + this.render(); }, welcomeBlinkDelay(this.welcomeBlinkCount)); } @@ -2590,6 +3191,41 @@ export class TerminalApp { return !this.editor.hasPasteAtoms && this.editor.text.startsWith(HISTORY_SEARCH); } + private clearCorrection(): void { + this.correctionAbort?.abort(); this.correctionAbort = undefined; this.correction = undefined; + } + + private async suggestCorrection(command: string, exitCode: number, output: string): Promise { + this.clearCorrection(); + if (exitCode !== 127 || this.editor.text || this.running) return; + const active = new AbortController(); + this.correctionAbort = active; + const correction = await this.correctionService.suggest(command, exitCode, output, active.signal); + if (!this.stopped && !active.signal.aborted && !this.editor.text && !this.running && this.correctionAbort === active) { + this.correction = correction; this.render(); + } + } + + private get directorySearchActive(): boolean { + return !this.editor.hasPasteAtoms && this.editor.text.startsWith(DIRECTORY_SEARCH); + } + + private directoryMatches(query: string): Array<{name: string; insertion: string; description: string}> { + if (query !== this.directoryQuery) { + this.directoryQuery = query; + this.directoryQueryAbort?.abort(); + const active = new AbortController(); + this.directoryQueryAbort = active; + this.directoryResults = []; + void this.directoryService.query(this.historyService.index.all(), query, this.promptConfiguration.navigation, active.signal).then(items => { + if (this.stopped || active.signal.aborted || this.directoryQuery !== query) return; + this.directoryResults = items; this.selectedSuggestion = 0; this.render(); + }).catch(() => {}); + } + return this.directoryResults.map(item => ({name: item.path, insertion: directoryCommand(item.path), + description: [item.project, item.visits === undefined ? 'zoxide' : `${item.visits} visits`].filter(Boolean).join(' · ')})); + } + private historyMatches(query: string): Array<{id: string; name: string; insertion: string; description: string}> { if (query !== this.historyQuery) { this.historyQuery = query; @@ -2612,6 +3248,8 @@ export class TerminalApp { /** Composer suggestion rows for the current editor state; the same list render paints and geometry counts. */ private composerSuggestions(): any[] { if (this.running || this.settingsPanelActive) return []; + if (this.correction && this.editor.text.length === 0) return [this.correction]; + if (this.directorySearchActive) return this.directoryMatches(this.editor.text.substring(DIRECTORY_SEARCH.length)); if (this.historySearchActive) return this.historyMatches(this.editor.text.substring(HISTORY_SEARCH.length)); if (!this.editor.hasPasteAtoms && this.editor.text.startsWith('/')) return slashSuggestions(this.editor.text); const alternatives = this.suggestions.alternatives(); @@ -2623,6 +3261,11 @@ export class TerminalApp { * The one screen plan for the current state. Render, hit-testing, scroll, * focus, cursor and PTY sizing all call this instead of counting rows. */ + private inspectorRows(columns: number): string[] { + if (!this.inspectorVisible || this.running || this.settingsPanelActive || this.editor.hasPasteAtoms || this.editor.text.startsWith('/')) return []; + return renderInspector(inspectCommand(this.editor.text, this.editor.cursorIndex, this.shellCwd, this.shellSuggestions, this.semanticService.cache), columns); + } + private planFrame( columns: number, rows: number, @@ -2635,6 +3278,7 @@ export class TerminalApp { rows, inputRows: fullInput.allRows.length, suggestions, + inspectorRows: this.inspectorRows(columns).length, running: Boolean(this.running), detached: this.historyViewport.detached, hasOutput: transcriptRows > 0, @@ -2654,7 +3298,9 @@ export class TerminalApp { } private render(): void { - if (this.stopped || this.passthrough || this.externalPassthrough) return; + if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } + for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed()); + if (!this.decorativeMotionAllowed()) this.effects.cancel(); void this.fetchSuggestions(); const {columns, rows} = this.dimensions(); const availableSuggestions = this.composerSuggestions(); @@ -2682,8 +3328,12 @@ export class TerminalApp { const interaction = {hoveredLineIndex: this.hoveredLineIndex, focusedLineIndex: this.focusedLineIndex, focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, now: presentationNow().getTime()}; - const visible = wrapped.slice(viewStart, viewStart + outputHeight).map(row => - presenter.decorate(row, row.lineIndex === undefined ? undefined : this.output.lineTypes.get(row.lineIndex), interaction)); + const visible = wrapped.slice(viewStart, viewStart + outputHeight).map(row => { + const ansi = presenter.decorate(row, row.lineIndex === undefined ? undefined : this.output.lineTypes.get(row.lineIndex), interaction); + const focused = this.focusedCommandIndex !== undefined && row.lineIndex === this.output.recent(this.focusedCommandIndex + 1)?.startId; + const controls = !this.running && (focused || row.lineIndex === this.hoveredLineIndex) ? blockAffordance(row, columns) : undefined; + return controls ? `${ansi}${RESET}${controls.suffix}` : ansi; + }); const sticky = this.stickyHeader(wrapped, viewStart); const stickyRow = sticky && this.output.presentSticky(sticky.startId, columns); if (stickyRow && visible.length > 0) visible[0] = stickyRow; @@ -2697,7 +3347,12 @@ export class TerminalApp { for (const token of tokens) { if (token.type === 'Command') { - void this.semanticService.classifyCommand(token.text).then(() => this.render()); + const before = this.semanticService.cache.get(token.text); + void this.semanticService.classifyCommand(token.text).then(() => { + // Unavailable/uncached results must not schedule another immediate + // render and classification loop that starves editor input. + if (this.semanticService.cache.get(token.text) !== before) this.render(); + }); } } @@ -2736,8 +3391,10 @@ export class TerminalApp { // Panels frame their composer-side edge: under Dock Top the frame line moves below the panel. case 'panel': return plan.composerPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; + case 'inspector': return this.inspectorRows(columns); case 'suggestions': return suggestionView.items.map((suggestion, visibleIndex) => { const selected = suggestionView.start + visibleIndex === effectiveSelection; + if ('correction' in suggestion) return renderCorrection(suggestion, columns); if ('source' in suggestion && 'replacement' in suggestion) return renderCompletion(suggestion, selected, columns); return truncateAnsi( `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, @@ -2762,23 +3419,108 @@ export class TerminalApp { for (let index = 0; index < region.height; index += 1) frameRows[region.top + index] = content[index] ?? ''; } - this.renderer.render({ + const frame: TerminalFrame = { rows: frameRows, columns, cursorRow: terminalRowFromScreen(cursorScreenRow(plan, input.caretRow)), cursorColumn: Math.max(1, Math.min(columns, input.caretColumn + 1)), // Flow can scroll the input row off screen. cursorVisible: !plan.panelActive && plan.inputHeight > 0, - }); + }; + this.presentationFrame = {frame, plan}; + this.paintPresentation(Date.now()); + this.syncPresentationClock(); + } + + /** Existing #91 tasks repaint their panel only while its geometry is unchanged. */ + private renderTaskPresentation(): void { + if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } + const cached = this.presentationFrame; + const region = cached?.plan.regions.find(item => item.kind === 'panel'); + if (!cached || !region) { this.render(); return; } + for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed()); + const content = this.settingsPanelRows(cached.frame.columns ?? 80); + if (Math.min(cached.plan.rows, content.length) !== region.height) { this.render(); return; } + const projected = cached.plan.composerPosition === 'top' && /^[─-]+$/u.test(stripAnsi(content[0] ?? '')) + ? [...content.slice(1), content[0]!] : content; + const rows = [...cached.frame.rows]; + for (let index = 0; index < region.height; index++) rows[region.top + index] = projected[index] ?? ''; + this.presentationFrame = {...cached, frame: {...cached.frame, rows}}; + this.paintPresentation(Date.now()); + } + + private decorativeMotionAllowed(): boolean { + return !isReducedMotion() && !this.promptConfiguration.presentation.reducedMotion && !this.promptConfiguration.presentation.effectsOff; + } + + private cancelPresentation(): void { + this.effects.cancel(); + this.presentationSubscription?.(); this.presentationSubscription = undefined; + this.welcomeBlinkTimer?.(); this.welcomeBlinkTimer = undefined; + this.output.setWelcomeFrame('open'); + this.presentationFrame = undefined; + } + + /** Decorative frames reuse the base projection; they never walk transcript history. */ + private paintPresentation(now: number): void { + const cached = this.presentationFrame; + if (!cached) return; + const {frame, plan} = cached; + const settings = this.promptConfiguration.presentation; + const rows = [...frame.rows]; + for (const region of plan.regions) { + if (region.kind === 'activity' && this.running) { + const line = truncateAnsi(this.currentActivity(), frame.columns ?? 80); + const content = plan.composerPosition === 'top' ? ['', line] : [line, '']; + for (let index = 0; index < region.height; index++) rows[region.top + index] = content[index] ?? ''; + } + if (region.kind === 'separator' || region.kind === 'composerBorder') { + rows[region.top] = paintTreatment(repeatToWidth(GLYPHS.separator, frame.columns ?? 80), settings, 'divider', UI_COLORS.separator, now) + RESET; + } + } + const active = this.effects.active; + const region = active && effectRegion(plan, active.placement); + if (active && !region) this.effects.cancel(); + try { + this.renderer.render({...frame, rows: active && region + ? applyEffect(rows, active, region, frame.columns ?? 80, now, getCurrentGlyphMode() === 'safe', colorLevel()) : rows}); + } catch (error) { this.onTerminate(); throw error; } + } + + private renderPresentation(now: number): void { + if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; } + if (!this.decorativeMotionAllowed()) this.effects.cancel(); + this.effects.expire(now); + if (this.running) { + this.activityAnimationNow = now; + this.output.tickActiveCommand(); + if (this.passthrough) { this.cancelPresentation(); return; } + } + this.paintPresentation(now); + this.syncPresentationClock(); + } + + private syncPresentationClock(): void { + if (!this.presentationStarted || this.stopped) return; + const settings = this.promptConfiguration.presentation; + const animatedRule = this.presentationFrame?.plan.regions.some(region => region.kind === 'separator' || region.kind === 'composerBorder') + && settings.preset !== 'off' && settings.motion !== 'static' && colorLevel() !== 'none'; + const needsFrames = Boolean(this.running || this.effects.active || (animatedRule && this.decorativeMotionAllowed())); + if (needsFrames && !this.presentationSubscription) this.presentationSubscription = presentationClock.subscribe(now => this.renderPresentation(now)); + if (!needsFrames) { this.presentationSubscription?.(); this.presentationSubscription = undefined; } + if (this.decorativeMotionAllowed() && this.output.hasWelcome && !this.welcomeBlinkTimer) this.scheduleWelcomeBlink(); + if (!this.decorativeMotionAllowed()) { + this.welcomeBlinkTimer?.(); this.welcomeBlinkTimer = undefined; this.output.setWelcomeFrame('open'); + } } private currentActivity(): string { if (!this.running) return ''; const elapsed = this.activityAnimationNow - this.running.startedAt; const isActive = (Date.now() - this.lastOutputTime) < 750; - const animationElapsed = presentationAnimationElapsed(elapsed); + const animationElapsed = this.decorativeMotionAllowed() ? presentationAnimationElapsed(elapsed) : 0; const parts = liveActivityParts(this.running.command, elapsed, animationElapsed); - return `${shimmerText(parts.phrase, animationElapsed, isReducedMotion() ? false : isActive)}${SECONDARY}${parts.duration}${RESET}`; + return `${shimmerText(parts.phrase, animationElapsed, this.decorativeMotionAllowed() ? isActive : false)}${SECONDARY}${parts.duration}${RESET}`; } private jumpAffordance(columns: number): string { @@ -2858,19 +3600,30 @@ export class TerminalApp { private stop(exitCode: number): void { if (this.stopped) return; this.stopped = true; - if (this.activityTimer) clearInterval(this.activityTimer); + this.cancelPresentation(); this.promptPanelState?.task?.dispose(); - if (this.welcomeBlinkTimer) clearTimeout(this.welcomeBlinkTimer); + this.presetStartup?.cancel(); + this.endStartupWatch(); + this.miseService.cancel(); + this.toolsPanel?.task?.dispose(); + this.providerPanelState?.task?.dispose(); + this.welcomeBlinkTimer?.(); this.welcomeBlinkTimer = undefined; process.stdin.off('data', this.onInput); process.stdout.off('resize', this.onResize); + process.off('SIGTSTP', this.onSuspend); + process.off('SIGCONT', this.onContinue); process.off('SIGTERM', this.onTerminate); process.off('SIGHUP', this.onTerminate); if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode); process.stdin.pause(); - this.renderer.leave(); - this.completionService.cancel(); + this.terminalFocus = 'unknown'; + try { this.renderer.leave(); } catch { /* A closed terminal must not prevent resource cleanup. */ } + this.completionService.dispose(); this.historyQueryAbort?.abort(); + this.clearCorrection(); + this.directoryQueryAbort?.abort(); + this.pickerAbort?.abort(); this.historyService.dispose(); this.semanticService.kill(); this.finish(exitCode); diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 1230a180..098af397 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -20,6 +20,7 @@ export type RegionKind = | 'gap' | 'jump' | 'panel' + | 'inspector' | 'suggestions' | 'activity' | 'composerBorder' @@ -40,6 +41,7 @@ export interface ScreenPlanInput { inputRows: number; /** Suggestion rows the composer would like to show. */ suggestions: number; + inspectorRows?: number; running: boolean; detached: boolean; hasOutput: boolean; @@ -95,8 +97,9 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel], {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); } + const inspectorHeight = Math.min(Math.max(0, input.inspectorRows ?? 0), Math.max(0, rows - 8)); const layout = calculateScreenLayout( - rows, + rows - inspectorHeight, input.inputRows, input.suggestions, input.running, @@ -117,6 +120,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['prompt', Number(layout.showPrompt)], ['input', layout.inputHeight], ['separator', Number(layout.showSeparator)], + ['inspector', inspectorHeight], ['suggestions', layout.suggestionCount], ['gap', Number(layout.showGap)], ['transcript', shown], @@ -130,7 +134,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { // Geometry is measured as if following, so the PTY never resizes as output // grows or the view scrolls back. const followLayout = input.detached - ? calculateScreenLayout(rows, input.inputRows, input.suggestions, input.running, false, input.hasOutput, + ? calculateScreenLayout(rows - inspectorHeight, input.inputRows, input.suggestions, input.running, false, input.hasOutput, input.contextPlacement, input.hasVisibleContext, input.composerLayout) : layout; const capacity = followLayout.outputHeight; @@ -144,6 +148,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['transcript', shown], ['gap', Number(followLayout.showGap)], ['activity', followLayout.showLiveActivity ? 2 : 0], + ['inspector', inspectorHeight], ['composerBorder', Number(followLayout.showComposerTopBorder)], ['prompt', Number(followLayout.showPrompt)], ['input', followLayout.inputHeight], @@ -162,6 +167,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ['suggestions', layout.suggestionCount], // Activity line plus its blank spacer. ['activity', layout.showLiveActivity ? 2 : 0], + ['inspector', inspectorHeight], ['composerBorder', Number(layout.showComposerTopBorder)], ['prompt', Number(layout.showPrompt)], ['input', layout.inputHeight], diff --git a/src/chroma/escape.ts b/src/chroma/escape.ts index db8053fa..e282d7dd 100644 --- a/src/chroma/escape.ts +++ b/src/chroma/escape.ts @@ -30,9 +30,31 @@ export function rgbTo256(color: Rgb): number { return distance(gray) < distance(cube) ? 232 + grayStep : 16 + 36 * r + 6 * g + b; } +/** Conventional ANSI palette. Host palettes can differ; keep fallback predictable. */ +const ANSI16 = [ + [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], + [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192], + [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], + [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255], +]; + +export function rgbTo16(color: Rgb): number { + let nearest = 0; + let distance = Infinity; + ANSI16.forEach(([r, g, b], index) => { + const next = (color.red - r!) ** 2 + (color.green - g!) ** 2 + (color.blue - b!) ** 2; + if (next < distance) { distance = next; nearest = index; } + }); + return nearest; +} + /** SGR sequence for a foreground (38) or background (48) color at a capability level; empty when uncolored. */ export function colorEscape(layer: 38 | 48, color: Rgb, level: ColorLevel = colorLevel()): string { if (level === 'none') return ''; + if (level === 'ansi16') { + const index = rgbTo16(color); + return `\u001B[${(layer === 38 ? 30 : 40) + (index < 8 ? index : 60 + index - 8)}m`; + } if (level === 'ansi256') return `\u001B[${layer};5;${rgbTo256(color)}m`; return `\u001B[${layer};2;${color.red};${color.green};${color.blue}m`; } diff --git a/src/chroma/treatment.ts b/src/chroma/treatment.ts new file mode 100644 index 00000000..6aa4eb44 --- /dev/null +++ b/src/chroma/treatment.ts @@ -0,0 +1,98 @@ +import {mixRgb, resolveColor, sampleGradient, solid, theme, BRAND_LAVENDER, type ColorRef} from './chroma.js'; +import {colorEscape, type Rgb} from './escape.js'; +import {colorLevel, type ColorLevel} from '../presentation/capabilities.js'; +import {isReducedMotion} from '../presentation/environment.js'; +import {sampleMotion} from '../motion/motion.js'; +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth} from '../util/text.js'; + +export const TREATMENT_PRESETS = ['off', 'lavender', 'aurora', 'theme', 'custom'] as const; +export const TREATMENT_GEOMETRIES = ['linear', 'center-out', 'outside-in'] as const; +export const TREATMENT_MOTIONS = ['static', 'travel', 'breathe'] as const; +export type TreatmentRole = 'native-identity' | 'divider' | 'panel-frame' | 'effect' | 'status' | 'focus' | 'raw' | 'provider'; +const ELIGIBLE = new Set(['native-identity', 'divider', 'panel-frame', 'effect']); + +export interface TreatmentSettings { + preset: typeof TREATMENT_PRESETS[number]; + geometry: typeof TREATMENT_GEOMETRIES[number]; + motion: typeof TREATMENT_MOTIONS[number]; + intensity: number; + customStops: string[]; + reducedMotion: boolean; + effectsOff: boolean; +} +export const DEFAULT_TREATMENT_SETTINGS: TreatmentSettings = { + preset: 'off', geometry: 'linear', motion: 'static', intensity: 0.65, + customStops: [], reducedMotion: false, effectsOff: false, +}; + +/** Additive, declarative configuration: no code or transient state. */ +export function normalizeTreatmentSettings(value: unknown): TreatmentSettings { + const v = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + const customStops = Array.isArray(v.customStops) && v.customStops.length >= 2 && v.customStops.length <= 8 + && v.customStops.every(stop => typeof stop === 'string' && /^#[0-9a-f]{6}$/iu.test(stop)) ? [...v.customStops] as string[] : []; + const preset = TREATMENT_PRESETS.includes(v.preset as TreatmentSettings['preset']) ? v.preset as TreatmentSettings['preset'] : 'off'; + return { + preset: preset === 'custom' && !customStops.length ? 'off' : preset, + geometry: TREATMENT_GEOMETRIES.includes(v.geometry as TreatmentSettings['geometry']) ? v.geometry as TreatmentSettings['geometry'] : 'linear', + motion: TREATMENT_MOTIONS.includes(v.motion as TreatmentSettings['motion']) ? v.motion as TreatmentSettings['motion'] : 'static', + intensity: typeof v.intensity === 'number' && Number.isFinite(v.intensity) ? Math.max(0, Math.min(1, v.intensity)) : 0.65, + customStops, reducedMotion: v.reducedMotion === true, effectsOff: v.effectsOff === true, + }; +} + +export interface Treatment { + stops: readonly ColorRef[]; + geometry: TreatmentSettings['geometry']; + motion: TreatmentSettings['motion']; + intensity: number; +} +export interface TreatmentContext { + role: TreatmentRole; + base: Rgb; + reducedMotion?: boolean; + effectsOff?: boolean; + level?: ColorLevel; +} +export function treatmentFor(settings: TreatmentSettings): Treatment | undefined { + const hex = (value: string): ColorRef => solid({red: parseInt(value.slice(1, 3), 16), green: parseInt(value.slice(3, 5), 16), blue: parseInt(value.slice(5, 7), 16)}); + const palettes: Record, readonly ColorRef[]> = { + lavender: [solid(BRAND_LAVENDER), hex('#C5A4FA'), hex('#9979D9')], + aurora: [hex('#B597F4'), hex('#DA9FC8'), hex('#91B5A4')], + theme: [theme('accent'), theme('primary'), theme('secondary')], custom: settings.customStops.map(hex), + }; + return settings.preset === 'off' ? undefined : {...settings, stops: palettes[settings.preset]}; +} + +/** Pure cell sampling. Columns are display columns, time is supplied by the owner. */ +export function sampleTreatment(treatment: Treatment, context: TreatmentContext & {column: number; width: number}, time: number): Rgb { + if (!ELIGIBLE.has(context.role) || treatment.stops.length === 0) return context.base; + let position = context.width <= 1 ? 0 : Math.max(0, Math.min(1, context.column / (context.width - 1))); + if (treatment.geometry === 'center-out') position = Math.abs(2 * position - 1); + if (treatment.geometry === 'outside-in') position = 1 - Math.abs(2 * position - 1); + const motion = context.reducedMotion || context.effectsOff ? 'static' : treatment.motion; + if (motion === 'travel') position = (position + ((time % 6000) + 6000) % 6000 / 6000) % 1; + const intensity = treatment.intensity * (motion === 'breathe' + ? 0.65 + 0.35 * sampleMotion({shape: 'sine', spread: 'uniform', cycleMs: 4000, repeat: true}, time, 0, {reduced: false}) : 1); + const stops = treatment.stops.map((color, index) => ({color, at: treatment.stops.length <= 1 ? 0 : index / (treatment.stops.length - 1)})); + return mixRgb(context.base, sampleGradient({stops}, position), Math.max(0, Math.min(1, intensity))); +} + +/** Takes owned plain content only; never strip/repaint provider or PTY ANSI. */ +export function treatmentText(text: string, treatment: Treatment, context: TreatmentContext, time: number): string { + const level = context.level ?? colorLevel(); + if (level === 'none' || !ELIGIBLE.has(context.role)) return text; + const width = displayWidth(text); + let column = 0; + return graphemes(text).map(glyph => { + const color = sampleTreatment(treatment, {...context, column, width}, time); + column += displayWidth(glyph); + return `${colorEscape(38, color, level)}${glyph}`; + }).join('') + '\u001B[39m'; +} + +export function paintTreatment(text: string, settings: TreatmentSettings, role: TreatmentRole, base: Rgb, time = 0): string { + const treatment = treatmentFor(settings); + return treatment ? treatmentText(text, treatment, {role, base, reducedMotion: settings.reducedMotion || isReducedMotion(), effectsOff: settings.effectsOff}, time) + : `${colorEscape(38, base)}${text}`; +} diff --git a/src/clipboard/clipboard.ts b/src/clipboard/clipboard.ts index 82523ff9..cfd9996c 100644 --- a/src/clipboard/clipboard.ts +++ b/src/clipboard/clipboard.ts @@ -1,4 +1,5 @@ import {spawn} from 'node:child_process'; +import {resolveCommand} from '../providers/providers.js'; export interface CopyStats { characters: number; @@ -22,18 +23,74 @@ export function copyFeedback(stats: CopyStats, index = 1): string { return `${target} · ${stats.characters.toLocaleString()} ${characterWord} · ${stats.lines.toLocaleString()} ${lineWord}`; } -export async function writeClipboard(text: string): Promise { +export const CLIPBOARD_MAX_BYTES = 1024 * 1024; +export const CLIPBOARD_TIMEOUT_MS = 3000; + +/** No usable clipboard tool for this platform/session; commands are unaffected. */ +export class ClipboardUnavailableError extends Error { + constructor(detail: string) { super(`Clipboard unavailable: ${detail}`); this.name = 'ClipboardUnavailableError'; } +} + +export interface ClipboardBackend {command: string; args: string[]} +export interface ClipboardEnvironment { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + /** Resolve an executable name to a path, or undefined when not installed. */ + resolve?: (name: string) => string | undefined; + timeoutMs?: number; +} + +/** Pick a conventional desktop clipboard tool: pbcopy on macOS; wl-copy under Wayland; xclip/xsel under X11. Nothing is installed. */ +export function selectClipboardBackend(options: ClipboardEnvironment = {}): ClipboardBackend | undefined { + const {platform = process.platform, env = process.env, resolve = (name: string) => resolveCommand(name)} = options; + if (platform === 'darwin') return {command: 'pbcopy', args: []}; + if (platform !== 'linux') return undefined; + const candidates: [string, string[]][] = []; + if (env.WAYLAND_DISPLAY) candidates.push(['wl-copy', []]); + if (env.DISPLAY) candidates.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]); + for (const [name, args] of candidates) { + const command = resolve(name); + if (command) return {command, args}; + } + return undefined; +} + +export async function writeClipboard(text: string, options: ClipboardEnvironment = {}): Promise { + if (Buffer.byteLength(text, 'utf8') > CLIPBOARD_MAX_BYTES) throw new ClipboardUnavailableError(`text exceeds the ${CLIPBOARD_MAX_BYTES / 1024 / 1024} MiB copy limit`); + const backend = selectClipboardBackend(options); + if (!backend) { + const platform = options.platform ?? process.platform; + throw new ClipboardUnavailableError(platform === 'linux' ? 'install wl-copy (Wayland) or xclip/xsel (X11)' : `unsupported platform ${platform}`); + } + const timeoutMs = options.timeoutMs ?? CLIPBOARD_TIMEOUT_MS; await new Promise((resolve, reject) => { - const child = spawn('pbcopy', [], {stdio: ['pipe', 'ignore', 'pipe']}); - let error = ''; - child.stderr.on('data', chunk => { - error += String(chunk); - }); - child.once('error', reject); - child.once('close', code => { - if (code === 0) resolve(); - else reject(new Error(error.trim() || `pbcopy exited with code ${code}`)); + // Clipboard tools may fork a background selection owner that keeps inherited pipes open, + // so only the tool's own exit is awaited and its output is not captured. + const child = spawn(backend.command, backend.args, {detached: process.platform !== 'win32', stdio: ['pipe', 'ignore', 'ignore']}); + let settled = false; + let inputDone = false; + let exitedSuccessfully = 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(); + }; + const timer = setTimeout(() => { finish(new Error(`${backend.command} timed out`)); }, timeoutMs); + child.once('error', finish); + child.stdin.once('error', error => finish(new Error(`${backend.command} stdin failed: ${error.message}`))); + child.once('exit', code => { + if (code !== 0) finish(new Error(`${backend.command} exited with code ${code}`)); + else { exitedSuccessfully = true; if (inputDone) finish(); } }); + // Writable 'finish' means all input was written successfully; end callbacks + // also run on write errors, potentially before the stream's error event. + child.stdin.once('finish', () => { inputDone = true; if (exitedSuccessfully) finish(); }); child.stdin.end(text); }); } diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 3f7b378b..446c80e2 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -5,11 +5,13 @@ export interface SlashCommand { } export const slashCommands: readonly SlashCommand[] = [ + {name: '/effects', insertion: '/effects ', description: 'Preview sparkles or rain in owned chrome; /effects stop cancels'}, {name: '/copy', insertion: '/copy', description: 'Copy latest command output'}, {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output'}, {name: '/appearance', insertion: '/appearance', description: 'Configure terminal appearance'}, {name: '/prompt', insertion: '/prompt', description: 'Configure prompt provider and composer layout'}, {name: '/settings', insertion: '/settings', description: 'Open NMSh settings (Config view)'}, + {name: '/tools', insertion: '/tools', description: 'Browse optional tools, installation previews and supported configuration'}, {name: '/config', insertion: '/config', description: 'Open NMSh settings (Config view)'}, {name: '/status', insertion: '/status', description: 'Show NMSh status'}, {name: '/syntax', insertion: '/syntax', description: 'Configure syntax highlighting'}, @@ -21,16 +23,20 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/update', insertion: '/update', description: 'Check for a newer NMSh release'}, {name: '/update apply', insertion: '/update apply', description: 'Install the release that /update offered'}, {name: '/clear', insertion: '/clear', description: 'Archive this transcript and start a fresh view'}, + {name: '/presets', insertion: '/presets', description: 'Create, inspect and launch named session presets'}, {name: '/resume', insertion: '/resume', description: 'Browse archived NMSh transcripts'}, {name: '/help', insertion: '/help', description: 'Show NMSh commands'}, {name: '/palette', insertion: '/palette', description: 'Search NMSh actions (Ctrl+Shift+P / F1)'}, + {name: '/dirs', insertion: '/dirs ', description: 'Find a directory; insert a visible cd command'}, {name: '/history', insertion: '/history ', description: 'Search history'}, ]; export type ParsedSlashCommand = + | {kind: 'effects'; effect: 'sparkles' | 'rain' | 'stop' | 'help'; placement: 'top' | 'bottom'} | {kind: 'copy'; index: number} | {kind: 'appearance'} | {kind: 'prompt'} + | {kind: 'tools'} | {kind: 'settings'; view: 'config' | 'status'} | {kind: 'transcript'} | {kind: 'syntax'} @@ -40,18 +46,23 @@ export type ParsedSlashCommand = | {kind: 'version'} | {kind: 'update'; apply: boolean} | {kind: 'clear'} + | {kind: 'presets'} | {kind: 'resume'} | {kind: 'help'} | {kind: 'palette'} + | {kind: 'directories', query: string} | {kind: 'history', query: string} | {kind: 'unknown'; input: string}; export function parseSlashCommand(input: string): ParsedSlashCommand | undefined { if (!input.startsWith('/')) return undefined; + const effect = /^\/effects(?:\s+(sparkles|rain|stop))?(?:\s+(top|bottom))?\s*$/u.exec(input); + if (effect) return {kind: 'effects', effect: (effect[1] ?? 'help') as 'sparkles' | 'rain' | 'stop' | 'help', placement: (effect[2] ?? 'bottom') as 'top' | 'bottom'}; const match = /^\/copy(?:\s+([1-9]\d*))?\s*$/u.exec(input); if (match) return {kind: 'copy', index: Number(match[1] ?? '1')}; if (/^\/appearance\s*$/u.test(input)) return {kind: 'appearance'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; + if (/^\/tools\s*$/u.test(input)) return {kind: 'tools'}; if (/^\/(?:settings|config)\s*$/u.test(input)) return {kind: 'settings', view: 'config'}; if (/^\/status\s*$/u.test(input)) return {kind: 'settings', view: 'status'}; if (/^\/transcript\s*$/u.test(input)) return {kind: 'transcript'}; @@ -63,9 +74,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const update = /^\/update(?:\s+(apply))?\s*$/u.exec(input); if (update) return {kind: 'update', apply: update[1] === 'apply'}; if (/^\/clear\s*$/u.test(input)) return {kind: 'clear'}; + if (/^\/presets\s*$/u.test(input)) return {kind: 'presets'}; if (/^\/resume\s*$/u.test(input)) return {kind: 'resume'}; if (/^\/help\s*$/u.test(input)) return {kind: 'help'}; if (/^\/palette\s*$/u.test(input)) return {kind: 'palette'}; + const directories = /^\/dirs(?:\s+([\s\S]*))?$/u.exec(input); + if (directories) return {kind: 'directories', query: (directories[1] ?? '').trim()}; const history = /^\/history(?:\s+([\s\S]*))?$/u.exec(input); if (history) return {kind: 'history', query: (history[1] ?? '').trim()}; return {kind: 'unknown', input}; diff --git a/src/configuration/paths.ts b/src/configuration/paths.ts index b588da68..815d6915 100644 --- a/src/configuration/paths.ts +++ b/src/configuration/paths.ts @@ -1,11 +1,12 @@ -import {join} from 'node:path'; +import {homedir} from 'node:os'; +import {isAbsolute, join} from 'node:path'; export function nmshConfigDirectory( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): string { - const home = env.HOME || env.USERPROFILE || ''; - if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'nmsh'); + const home = [env.HOME, env.USERPROFILE].find(value => value && isAbsolute(value)) || homedir(); + if (env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) return join(env.XDG_CONFIG_HOME, 'nmsh'); if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'notMyShell'); return join(home, '.config', 'nmsh'); } diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index f8320dc9..68290c47 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -12,12 +12,28 @@ export function helpMarkdown(): AuthoredMarkdown { | --- | --- | ${commands} +## Session presets + +Use /presets (also in the palette) to create, inspect, launch and delete named startup configurations. N creates one with an explicit cwd and optional commands, one per line; Tab changes fields, Ctrl+J adds a command line, Enter saves. Never put secrets in saved commands; reference your existing environment tooling instead. + +Enter inspects; L launches a NEW live session and keeps this one detached for /resume. First launch and changed commands/cwd require acknowledgement of the visible real cd and startup commands. PgUp/PgDn scroll the review. Decline runs nothing. Startup stops on a failed command; Ctrl+C cancels remaining commands. Launch also works with nmsh --preset ; nmsh --presets lists without executing. Presets require the live-session service and bypass automatic startup restoration. + ## Command history Use /history with plain text or combine cwd:, project:, exit:, before:, after:, session: and duration: filters. Quote filter values containing spaces. Example: /history cwd:/work exit:failure duration:>1s git 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. +## 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. + +Config offers optional zoxide ranking and optional fzf/Television pickers. zoxide reads a temporary copy of the existing database; hooks and the original database remain unchanged. Missing or failing tools use Native. + +## Command correction + +After an unambiguous simple command-not-found typo, NMSh may show a local executable correction below the composer. Tab places it in the editor; review and press Enter separately. Esc dismisses it. Complex expressions, ambiguous matches and destructive targets are suppressed. The suggestion is frontend UI and stays out of command output, copy and history. + ## Tips - A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap. diff --git a/src/help/markdown.ts b/src/help/markdown.ts index 6363fee6..f5ef71d9 100644 --- a/src/help/markdown.ts +++ b/src/help/markdown.ts @@ -1,3 +1,4 @@ +import {resolveHostCapabilities} from '../host/capabilities.js'; import {backgroundOf, foregroundOf, theme} from '../chroma/chroma.js'; import {graphemes} from '../input/inputLayout.js'; import {colorLevel} from '../presentation/capabilities.js'; @@ -35,9 +36,7 @@ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu; /** Terminals known to implement OSC 8. `NMSH_HYPERLINKS=1|0` overrides detection. */ export function supportsHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean { - if (env.NMSH_HYPERLINKS === '1') return true; - if (env.NMSH_HYPERLINKS === '0' || env.TERM === 'dumb') return false; - return ['ghostty', 'iTerm.app', 'WezTerm', 'vscode', 'Hyper'].includes(env.TERM_PROGRAM ?? '') || Boolean(env.KITTY_WINDOW_ID); + return resolveHostCapabilities(env).hyperlinks; } // ---- Inline --------------------------------------------------------------- diff --git a/src/host/capabilities.ts b/src/host/capabilities.ts new file mode 100644 index 00000000..d393788f --- /dev/null +++ b/src/host/capabilities.ts @@ -0,0 +1,63 @@ +/** Capabilities of the current frontend. No shell/session serialization. */ +export interface TerminalCapabilities { + enhancedKeyboard: boolean; + kittyKeyboard: boolean; + appearanceIntegration: boolean; + hostConfiguration: boolean; + graphicsProtocol: 'none' | 'kitty' | 'iterm2' | 'sixel'; + mouseReporting: boolean; + mouseMovement: boolean; + clickSupport: boolean; + textSelectionInteraction: 'native' | 'shift'; + synchronizedOutput: boolean; + hyperlinks: boolean; + truecolor: boolean; +} + +export const BASELINE_CAPABILITIES: Readonly = Object.freeze({ + enhancedKeyboard: false, kittyKeyboard: false, appearanceIntegration: false, + hostConfiguration: false, graphicsProtocol: 'none', mouseReporting: false, + mouseMovement: false, clickSupport: false, textSelectionInteraction: 'native', + synchronizedOutput: false, hyperlinks: false, truecolor: false, +}); + +/** Profiles are passive hints. Optional keyboard/sync features still use the shared probe. */ +const MOUSE_PROFILE = { + mouseReporting: true, mouseMovement: true, clickSupport: true, + textSelectionInteraction: 'shift' as const, hyperlinks: true, truecolor: true, +}; + +export function terminalProfile(env: NodeJS.ProcessEnv): 'ghostty' | 'iterm2' | 'kitty' | 'wezterm' | 'baseline' { + // An explicit program wins over inherited outer-host variables. + if (env.TERM_PROGRAM) { + switch (env.TERM_PROGRAM) { + case 'ghostty': return 'ghostty'; + case 'iTerm.app': return 'iterm2'; + case 'kitty': return 'kitty'; + case 'WezTerm': return 'wezterm'; + default: return 'baseline'; + } + } + if (env.TERM === 'xterm-kitty' || env.KITTY_WINDOW_ID) return 'kitty'; + if (env.WEZTERM_PANE) return 'wezterm'; + if (env.GHOSTTY_RESOURCES_DIR) return 'ghostty'; + return 'baseline'; +} + +/** Adapter hints are subordinate to protocol evidence. Multiplexers hide outer hints. */ +export function resolveHostCapabilities(env: NodeJS.ProcessEnv = process.env): TerminalCapabilities { + const result = {...BASELINE_CAPABILITIES}; + const nested = Boolean(env.TMUX || env.STY || env.ZELLIJ) || /^(tmux|screen)/u.test(env.TERM ?? ''); + if (!nested && env.TERM !== 'dumb') { + const profile = terminalProfile(env); + if (profile !== 'baseline') Object.assign(result, MOUSE_PROFILE); + if (profile === 'ghostty') Object.assign(result, {enhancedKeyboard: true, kittyKeyboard: true, + appearanceIntegration: true, hostConfiguration: true}); + if (profile === 'kitty') Object.assign(result, {enhancedKeyboard: true, kittyKeyboard: true, graphicsProtocol: 'kitty'}); + if (profile === 'iterm2' || profile === 'wezterm') result.graphicsProtocol = 'iterm2'; + } + if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || /(?:direct|truecolor)/u.test(env.TERM ?? '')) result.truecolor = true; + if (env.NMSH_HYPERLINKS === '1') result.hyperlinks = true; + if (env.NMSH_HYPERLINKS === '0' || env.TERM === 'dumb') result.hyperlinks = false; + return result; +} diff --git a/src/host/integration.ts b/src/host/integration.ts new file mode 100644 index 00000000..5bc719a2 --- /dev/null +++ b/src/host/integration.ts @@ -0,0 +1,41 @@ +import {readGhosttySettings, saveGhosttySettings, type GhosttySettings, type SaveResult} from '../appearance/ghostty.js'; +import {installGhosttyKeybinding} from '../keyboard/ghosttyKeyboard.js'; +import {resolveHostCapabilities} from './capabilities.js'; + +/** Explicit panel actions only. Constructing an adapter never reads or writes preferences. */ +export interface HostIntegration { + readAppearance(): Promise; + saveAppearance(settings: GhosttySettings): Promise; + installKeyboard(): Promise<{success: boolean; error?: string}>; + keyboardReload: string; + appearanceRestart: string; +} + +export function hostIntegration(env: NodeJS.ProcessEnv = process.env): HostIntegration | undefined { + if (!resolveHostCapabilities(env).hostConfiguration) return undefined; + return { + readAppearance: readGhosttySettings, + saveAppearance: saveGhosttySettings, + installKeyboard: installGhosttyKeybinding, + keyboardReload: 'Reload Ghostty config (Cmd+Shift+,) for changes to take effect.', + appearanceRestart: 'Opacity changes require Ghostty restart.', + }; +} + +export function keyboardGuidance(env: NodeJS.ProcessEnv = process.env): string { + if (env.TERM_PROGRAM === 'vscode') return [ + 'VS Code sends identical bytes for Enter and Shift+Enter.', + 'To enable Shift+Enter, add this to your VS Code keybindings.json:', '', + ' { "key": "shift+enter",', + ' "command": "workbench.action.terminal.sendSequence",', + ' "args": { "text": "\\u001b[13;2u" },', + ' "when": "terminalFocus" }', '', + 'Ctrl+J always inserts a newline without configuration.', + ].join('\n'); + return 'No keyboard configuration adapter for this host. Ctrl+J inserts a newline; Ctrl+W deletes a word; Alt+A selects the editor.'; +} + +/** Keep bootstrap compatibility hints out of shell core. */ +export const BOOTSTRAP_TERM_COMPATIBILITY = `if [[ "$TERM" == "xterm-ghostty" ]]; then + export TERM="xterm-256color" +fi`; diff --git a/src/host/probe.ts b/src/host/probe.ts new file mode 100644 index 00000000..16b770fe --- /dev/null +++ b/src/host/probe.ts @@ -0,0 +1,53 @@ +import type {TerminalCapabilities} from './capabilities.js'; + +export const HOST_PROBE_TIMEOUT_MS = 80; +export const HOST_QUERY = '\u001b[?u\u001b[?2026$p'; + +/** Read only the two requested replies, never bytes inside a bracketed paste. */ +export function resolveProbeReplies(input: string, hints: Readonly): {capabilities: TerminalCapabilities; input: string} { + const capabilities = {...hints}; + let pasted = false; + const remaining = input.replace(/\u001b\[200~|\u001b\[201~|\u001b\[\?(\d+)u|\u001b\[\?2026;([0-4])\$y/gu, + (match, flags: string | undefined, status: string | undefined) => { + if (match === '\u001b[200~') { pasted = true; return match; } + if (match === '\u001b[201~') { pasted = false; return match; } + if (pasted) return match; + if (flags !== undefined) capabilities.kittyKeyboard = capabilities.enhancedKeyboard = true; + if (status !== undefined) capabilities.synchronizedOutput = status === '1' || status === '2'; + return ''; + }); + return {capabilities, input: remaining}; +} + +export interface ProbeTransport { + write(data: string): unknown; + listen(receive: (data: string) => void): () => void; +} + +/** One bounded parallel query batch per attachment; caller owns raw mode. */ +export function probeHost(hints: Readonly, transport: ProbeTransport, + timeoutMs = HOST_PROBE_TIMEOUT_MS): Promise> { + return new Promise(resolve => { + let input = ''; + let finished = false; + let remove = () => {}; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + remove(); + resolve(resolveProbeReplies(input, hints)); + }; + const timer = setTimeout(finish, Math.max(0, Math.min(HOST_PROBE_TIMEOUT_MS, timeoutMs))); + try { + remove = transport.listen(data => { + input += data; + // Stop collecting promptly for large paste/input bursts. + if (input.length >= 65536) finish(); + }); + // A transport can deliver buffered input synchronously from listen(). + if (finished) { remove(); return; } + transport.write(HOST_QUERY); + } catch { finish(); } + }); +} diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index 3d4d897b..96ffc4b8 100644 --- a/src/host/terminalHost.ts +++ b/src/host/terminalHost.ts @@ -1,4 +1,6 @@ +import {hostIntegration, keyboardGuidance, type HostIntegration} from './integration.js'; import {spawn} from 'node:child_process'; +import {resolveHostCapabilities, type TerminalCapabilities} from './capabilities.js'; /** * The terminal NMSh runs in, and whether NMSh can ask it to open another @@ -8,6 +10,10 @@ import {spawn} from 'node:child_process'; export interface TerminalHost { /** Human-readable name for messages. */ name: string; + /** Current frontend attachment, never persistent shell state. */ + capabilities: Readonly; + integration?: HostIntegration; + keyboardGuidance?: string; /** How to open a new window running `argv`, if this host supports it. */ newWindow?: (argv: readonly string[]) => {command: string; args: string[]}; } @@ -41,24 +47,27 @@ export const GHOSTTY_NEW_WINDOW_SCRIPT = [ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): TerminalHost { const program = env.TERM_PROGRAM ?? ''; + const capabilities = resolveHostCapabilities(env); + const integration = hostIntegration(env); + const guidance = keyboardGuidance(env); if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) { - return {name: 'Ghostty', newWindow: argv => platform === 'darwin' + return {capabilities, integration, keyboardGuidance: guidance, name: 'Ghostty', newWindow: argv => platform === 'darwin' // Ghostty's AppleScript API opens a normal window in the running app. The // command words arrive as osascript argv, never inside the script text. ? {command: 'osascript', args: [...GHOSTTY_NEW_WINDOW_SCRIPT.flatMap(line => ['-e', line]), ...argv]} : {command: 'ghostty', args: ['-e', ...argv]}}; } if (program === 'Apple_Terminal' && platform === 'darwin') { - return {name: 'Terminal', newWindow: argv => ({command: 'osascript', args: ['-e', + return {capabilities, integration, keyboardGuidance: guidance, name: 'Terminal', newWindow: argv => ({command: 'osascript', args: ['-e', `tell application "Terminal" to do script ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; } if (env.KITTY_WINDOW_ID) { // Needs kitty remote control (allow_remote_control); failure falls back like any unsupported host. - return {name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; + return {capabilities, integration, keyboardGuidance: guidance, name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; } - if (program === 'vscode') return {name: 'VS Code'}; - if (program === 'zed' || env.ZED_TERM) return {name: 'Zed'}; - return {name: program || 'this terminal'}; + if (program === 'vscode') return {capabilities, integration, keyboardGuidance: guidance, name: 'VS Code'}; + if (program === 'zed' || env.ZED_TERM) return {capabilities, integration, keyboardGuidance: guidance, name: 'Zed'}; + return {capabilities, integration, keyboardGuidance: guidance, name: program || 'this terminal'}; } export type Spawner = (command: string, args: string[], timeoutMs?: number) => Promise; diff --git a/src/index.ts b/src/index.ts index 15818c8a..b9d5619a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import {resolveZsh} from './shell/zshExecutable.js'; +import {SessionPresetStore, validatePresetCwd, presetNeedsAcknowledgement, type SessionPreset} from './session/SessionPresets.js'; import {isVersionInvocation, formatBuildIdentity, readBuildIdentity} from './buildInfo.js'; import {NESTED_NMSH_MESSAGE, createOrdinaryZshEnvironment, isManagedNmshEnvironment} from './shell/ShellHandoff.js'; import {spawn} from 'node:child_process'; @@ -6,7 +8,7 @@ import {PRODUCT_ABBREVIATION, PRODUCT_NAME} from './config.js'; function startOrdinaryZsh(cwd?: string): Promise { return new Promise(resolve => { try { - const shell = spawn('/bin/zsh', ['-i'], { + const shell = spawn(resolveZsh(), ['-i'], { ...(cwd ? {cwd} : {}), env: createOrdinaryZshEnvironment(), stdio: 'inherit', @@ -26,9 +28,16 @@ function startOrdinaryZsh(cwd?: string): Promise { const args = process.argv.slice(2); const attachIndex = args.indexOf('--attach'); +const presetIndex = args.indexOf('--preset'); if (isVersionInvocation(args)) { process.stdout.write(`${formatBuildIdentity(readBuildIdentity())}\n`); +} else if (args.includes('--presets')) { + try { process.stdout.write(new SessionPresetStore().list().map(preset => `${preset.name} ${preset.cwd} ${preset.commands.length} startup command(s)`).join('\n') + '\n'); } + catch (error) { process.stderr.write(`${error instanceof Error ? error.message : 'Could not list presets.'}\n`); process.exitCode = 1; } +} else if (presetIndex !== -1 && (!args[presetIndex+1] || args[presetIndex+1]!.startsWith('--') || attachIndex !== -1)) { + process.stderr.write('Usage: nmsh --preset (creates a new session; cannot combine with --attach)\n'); + process.exitCode = 2; } else if (args.includes('--sessions')) { const {listLiveSessions} = await import('./session/connectSession.js'); const {formatSessionList} = await import('./session/sessionList.js'); @@ -53,6 +62,33 @@ if (isVersionInvocation(args)) { const size = () => ({cwd: process.cwd(), columns: process.stdout.columns || 80, rows: Math.max(2, (process.stdout.rows || 24) - 4)}); const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); + let pendingPreset: SessionPreset | undefined; + if (presetIndex !== -1) { + try { + const store = new SessionPresetStore(); + const preset = store.get(args[presetIndex+1]!); + validatePresetCwd(preset); + if (process.env[SESSION_SERVICE_ENV] === '0') throw new Error('Presets require the live-session service.'); + if (presetNeedsAcknowledgement(preset)) { + const {createPresetPanel, presetPanelKey, renderPresetPanel} = await import('./session/PresetPanel.js'); + const {runStartupScreen} = await import('./session/StartupPicker.js'); + const state = createPresetPanel([preset]); + state.detail = preset; state.operation = 'launch'; state.confirm = {choice:'no'}; + const agreed = await runStartupScreen(columns => renderPresetPanel(state,columns,process.stdout.rows || 24), key => { + if (key.kind === 'escape' || key.kind === 'interrupt') return false; + const action = presetPanelKey(state,key,process.cwd()); + if (action === 'launch') return true; + if (!state.confirm) return false; + }); + if (!agreed) process.exit(0); + pendingPreset = store.acknowledge(preset); + } else pendingPreset = preset; + } catch (error) { + process.stderr.write(`${errorText(error)}\n`); + process.exit(1); + } + } + // Which session this launch attaches, if any. --attach is explicit and // fails loudly; discovery only ever picks a detached session, and --new // skips it. @@ -73,7 +109,7 @@ if (isVersionInvocation(args)) { } } catch { /* recovery is best effort and never blocks launch */ } } - if (!explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') { + if (!pendingPreset && !explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') { let live: Awaited> = []; try { live = await listLiveSessions(); } catch { /* no usable service: start fresh */ } const {restoreAtStartup} = await import('./session/startupRestore.js'); @@ -84,7 +120,7 @@ if (isVersionInvocation(args)) { const restored = await restoreAtStartup(live, { policy: {startup: config.liveSessionStartup, multiple: config.liveSessionMultiple}, saveStartup: startup => { - try { savePromptConfiguration({...loadPromptConfiguration(), liveSessionStartup: startup}); } catch { /* keep going; applies this launch */ } + try { const base = loadPromptConfiguration(); savePromptConfiguration({...base, liveSessionStartup: startup}, undefined, base); } catch { /* keep going; applies this launch */ } }, askOne: session => picker.runStartupScreen(columns => picker.renderSinglePrompt(session, columns, Date.now()), picker.singlePromptKey), pick: sessions => { @@ -114,12 +150,29 @@ if (isVersionInvocation(args)) { } connection ??= await connectSession(size()); if (notice) connection = {...connection, notice: [connection.notice, notice].filter(Boolean).join(' ')}; - const app = new TerminalApp(connection); + if (pendingPreset && connection.mode !== 'service') { + connection.client.kill(); + process.stderr.write('Preset launch requires an available live-session service. Existing sessions were left intact; see nmsh --sessions.\n'); + process.exit(1); + } + let app: InstanceType; + try { app = new TerminalApp(connection, pendingPreset); } + catch (error) { + connection.client.kill(); + process.stderr.write(`Could not launch session: ${errorText(error)}\n`); + process.exit(1); + } + pendingPreset = undefined; const exitCode = await app.run(); if (app.lostServiceConnection) { process.stderr.write('NMSh lost the connection to its session service; the live session ended and its transcript was archived.\n'); } notice = undefined; + if (app.switchPreset) { + pendingPreset = app.switchPreset; + target = undefined; explicit = false; + continue; + } if (app.switchTarget) { target = app.switchTarget; explicit = false; diff --git a/src/input/Highlighter.ts b/src/input/Highlighter.ts index 4764b0e5..659d2526 100644 --- a/src/input/Highlighter.ts +++ b/src/input/Highlighter.ts @@ -11,6 +11,34 @@ export interface Token { text: string; } +const RESERVED = new Set(['if', 'then', 'else', 'elif', 'fi', 'for', 'select', 'while', 'until', 'do', 'done', 'case', 'esac', 'function', 'time', 'repeat', 'coproc', '!', '{', '}']); +const COMMAND_AFTER = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', 'time', 'coproc', '!', '{']); + +/** Keep common expansions together without trying to parse their shell programs. */ +function expansionEnd(characters: string[], start: number): number | undefined { + if (characters[start] === '`') { + for (let i = start + 1; i < Math.min(characters.length, start + 4096); i++) { + if (characters[i] === '\\') { i++; continue; } + if (characters[i] === '`') return i + 1; + } + return; + } + const open = characters[start + 1]; + if (characters[start] !== '$' || open !== '(' && open !== '{') return; + const close = open === '(' ? ')' : '}'; + let depth = 1; + let quote = ''; + for (let i = start + 2; i < Math.min(characters.length, start + 4096); i++) { + const character = characters[i]; + if (character === '\\' && quote !== "'") { i++; continue; } + if (quote) { if (character === quote) quote = ''; continue; } + if (character === "'" || character === '"') { quote = character; continue; } + if (character === open && ++depth > 16) return; + if (character === close && --depth === 0) return i + 1; + } + return; +} + export class Highlighter { tokenize(characters: string[], semanticCache: Map): Token[] { const tokens: Token[] = []; @@ -18,6 +46,8 @@ export class Highlighter { const len = characters.length; let expectCommand = true; + let redirectTarget = false; + let condition = false; while (i < len) { const c = characters[i]; @@ -42,8 +72,15 @@ export class Highlighter { } // Operators - if ('|&;<>()'.includes(c) || (c === '2' && i + 1 < len && characters[i+1] === '>')) { + const redirect = /^(?:\d+)?(?:(?:>&|<&)(?:[0-9-]+)?|<<<|<<-|<<|>>|<>|>\||>|<)/u.exec(characters.slice(i, i + 24).join(''))?.[0]; + if ('|&;<>()'.includes(c) || redirect) { const start = i; + if (redirect) { + i += redirect.length; + redirectTarget = !/(?:>&|<&)[0-9-]+$/u.test(redirect); + tokens.push({type: 'Operator', start, end: i, text: redirect}); + continue; + } let op = characters.slice(i, i + 4).join(''); if (op.startsWith('2>&1')) i += 4; else { @@ -60,8 +97,9 @@ export class Highlighter { tokens.push({ type: 'Operator', start, end: i, text }); // Only | && || ; ;; ( ) reset expectCommand. Redirects take an argument. - if (['|', '||', '&&', ';', ';;', '(', ')'].includes(text)) { + if (['|', '||', '&&', '&', ';', ';;', '(', ')'].includes(text)) { expectCommand = true; + redirectTarget = false; } continue; } @@ -75,10 +113,14 @@ export class Highlighter { while (i < len) { const char = characters[i]; - if (char === '\\') { - i += 2; + if (char === '\\' && !inSingle) { + i = Math.min(len, i + 2); continue; } + if ((char === '$' || char === '`') && !inSingle) { + const end = expansionEnd(characters, i); + if (end !== undefined) { i = end; isVar = true; continue; } + } if (char === "'" && !inDouble) { inSingle = !inSingle; hasQuotes = true; @@ -101,13 +143,30 @@ export class Highlighter { const word = characters.slice(start, i).join(''); let type: TokenType = 'Argument'; - if (hasQuotes && (word.startsWith("'") || word.startsWith('"'))) { + if (expectCommand && word === '[[') { + type = 'KnownCommand'; condition = true; expectCommand = false; + } else if (condition && word === ']]') { + type = 'KnownCommand'; condition = false; expectCommand = false; + } else if (redirectTarget) { + type = hasQuotes ? 'String' : word.includes('/') ? 'Path' : 'Argument'; + redirectTarget = false; + } else if (expectCommand && RESERVED.has(word)) { + type = 'KnownCommand'; + expectCommand = COMMAND_AFTER.has(word); + } else if (expectCommand && ['alias', 'function'].includes(semanticCache.get(word) ?? '')) { + type = semanticCache.get(word) === 'alias' ? 'Alias' : 'Function'; + expectCommand = false; + } else if (expectCommand && /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(word)) { + type = 'Argument'; + } else if (hasQuotes && (word.startsWith("'") || word.startsWith('"'))) { type = 'String'; - } else if (word.startsWith('$')) { + expectCommand = false; + } else if (word.startsWith('$') || isVar) { type = 'Variable'; + expectCommand = false; } else if (word.startsWith('-')) { type = 'Flag'; - } else if (word.includes('/') || word.startsWith('.') || word.startsWith('~')) { + } else if (word.includes('/') || word.startsWith('.') || word.startsWith('~') || !hasQuotes && /(? index === state.selectedIndex ? `${INTERACTIVE}>${RESET}` : ' '; const labelColor = (index: number) => index === state.selectedIndex ? PRIMARY : SECONDARY; - rows.push(` ${sel(0)} ${labelColor(0)}Cmd+A, Cmd+Arrows, Opt+Backspace Install for Ghostty${RESET}`); + rows.push(` ${sel(0)} ${labelColor(0)}Cmd+A, Cmd+Arrows, Opt+Backspace Install for ${hostName}${RESET}`); rows.push(''); - rows.push(` ${SECONDARY}Ghostty normally collapses Backspace and Option+Backspace to the${RESET}`); + rows.push(` ${SECONDARY}${hostName} normally collapses Backspace and Option+Backspace to the${RESET}`); rows.push(` ${SECONDARY}same DEL byte. Installing this allows NMSh to distinguish them.${RESET}`); rows.push(''); rows.push(` ${SECONDARY}Enter install · Esc cancel${RESET}`); diff --git a/src/languages/linguistLanguageColors.generated.ts b/src/languages/linguistLanguageColors.generated.ts new file mode 100644 index 00000000..399e0ad4 --- /dev/null +++ b/src/languages/linguistLanguageColors.generated.ts @@ -0,0 +1,698 @@ +/** Generated from github-linguist/linguist lib/linguist/languages.yml. Do not edit by hand. */ +export const LINGUIST_LANGUAGE_COLORS_REVISION = "d0921d10bb68a1249fc9eac64e472759f36e2fd8"; +export const LINGUIST_LANGUAGE_COLORS = { + "1C Enterprise": {color: "#814CCC", aliases: []}, + "2-Dimensional Array": {color: "#38761D", aliases: []}, + "4D": {color: "#004289", aliases: []}, + "ABAP": {color: "#E8274B", aliases: []}, + "ABAP CDS": {color: "#555E25", aliases: []}, + "AGS Script": {color: "#B9D9FF", aliases: ["ags"]}, + "AIDL": {color: "#34EB6B", aliases: []}, + "AL": {color: "#3AA2B5", aliases: []}, + "ALGOL": {color: "#D1E0DB", aliases: []}, + "AMPL": {color: "#E6EFBB", aliases: []}, + "ANTLR": {color: "#9DC3FF", aliases: []}, + "API Blueprint": {color: "#2ACCA8", aliases: []}, + "APL": {color: "#5A8164", aliases: []}, + "ASP.NET": {color: "#9400FF", aliases: ["aspx","aspx-vb"]}, + "ATS": {color: "#1AC620", aliases: ["ats2"]}, + "ActionScript": {color: "#882B0F", aliases: ["actionscript 3","actionscript3","as3"]}, + "Ada": {color: "#02F88C", aliases: ["ada95","ada2005"]}, + "Adblock Filter List": {color: "#800000", aliases: ["ad block filters","ad block","adb","adblock"]}, + "Adobe Font Metrics": {color: "#FA0F00", aliases: ["acfm","adobe composite font metrics","adobe multiple font metrics","amfm"]}, + "Agda": {color: "#315665", aliases: []}, + "Aiken": {color: "#640FF8", aliases: []}, + "Aleo": {color: "#154BF9", aliases: []}, + "Alloy": {color: "#64C800", aliases: []}, + "Alpine Abuild": {color: "#0D597F", aliases: ["abuild","apkbuild"]}, + "Altium Designer": {color: "#A89663", aliases: ["altium"]}, + "AngelScript": {color: "#C7D7DC", aliases: []}, + "Answer Set Programming": {color: "#A9CC29", aliases: []}, + "Ant Build System": {color: "#A9157E", aliases: []}, + "Antlers": {color: "#FF269E", aliases: []}, + "ApacheConf": {color: "#D12127", aliases: ["aconf","apache"]}, + "Apex": {color: "#1797C0", aliases: []}, + "Apollo Guidance Computer": {color: "#0B3D91", aliases: []}, + "AppleScript": {color: "#101F1F", aliases: ["apples","osascript"]}, + "Arc": {color: "#AA2AFE", aliases: []}, + "ArkTS": {color: "#0080FF", aliases: []}, + "AsciiDoc": {color: "#73A0C5", aliases: []}, + "AspectJ": {color: "#A957B0", aliases: []}, + "Assembly": {color: "#6E4C13", aliases: ["asm","nasm"]}, + "Astro": {color: "#FF5A03", aliases: []}, + "Asymptote": {color: "#FF0000", aliases: []}, + "Augeas": {color: "#9CC134", aliases: []}, + "AutoHotkey": {color: "#6594B9", aliases: ["ahk"]}, + "AutoIt": {color: "#1C3552", aliases: ["au3","AutoIt3","AutoItScript"]}, + "Avro IDL": {color: "#0040FF", aliases: []}, + "Awk": {color: "#C30E9B", aliases: []}, + "B": {color: "#DA7666", aliases: []}, + "B (Formal Method)": {color: "#8AA8C5", aliases: []}, + "B4X": {color: "#00E4FF", aliases: ["basic for android"]}, + "BAML": {color: "#A855F7", aliases: []}, + "BASIC": {color: "#FF0000", aliases: []}, + "BBCode": {color: "#CAFF42", aliases: []}, + "BIRD2": {color: "#B6D7E4", aliases: ["bird","bird3"]}, + "BQN": {color: "#2B7067", aliases: []}, + "Ballerina": {color: "#FF5000", aliases: []}, + "Batchfile": {color: "#C1F12E", aliases: ["bat","batch","dosbatch","winbatch"]}, + "Beef": {color: "#A52F4E", aliases: []}, + "Berry": {color: "#15A13C", aliases: ["be"]}, + "BibTeX": {color: "#778899", aliases: []}, + "Bicep": {color: "#519ABA", aliases: []}, + "Bikeshed": {color: "#5562AC", aliases: []}, + "Bison": {color: "#6A463F", aliases: []}, + "BitBake": {color: "#00BCE4", aliases: []}, + "Blade": {color: "#F7523F", aliases: []}, + "BlitzBasic": {color: "#00FFAE", aliases: ["b3d","blitz3d","blitzplus","bplus"]}, + "BlitzMax": {color: "#CD6400", aliases: ["bmax"]}, + "Blueprint": {color: "#3584E4", aliases: ["blp"]}, + "Bluespec": {color: "#12223C", aliases: ["bluespec bsv","bsv"]}, + "Bluespec BH": {color: "#12223C", aliases: ["bh","bluespec classic"]}, + "Boo": {color: "#D4BEC1", aliases: []}, + "Boogie": {color: "#C80FA0", aliases: []}, + "Brainfuck": {color: "#2F2530", aliases: []}, + "BrighterScript": {color: "#66AABB", aliases: []}, + "Brightscript": {color: "#662D91", aliases: []}, + "Browserslist": {color: "#FFD539", aliases: []}, + "Bru": {color: "#F4AA41", aliases: []}, + "BuildStream": {color: "#006BFF", aliases: []}, + "C": {color: "#555555", aliases: []}, + "C#": {color: "#7355DD", aliases: ["csharp","cake","cakescript"]}, + "C++": {color: "#F34B7D", aliases: ["cpp"]}, + "C3": {color: "#2563EB", aliases: []}, + "CAP CDS": {color: "#0092D1", aliases: ["cds"]}, + "CLIPS": {color: "#00A300", aliases: []}, + "CMake": {color: "#DA3434", aliases: []}, + "COLLADA": {color: "#F1A42B", aliases: []}, + "CQL": {color: "#006091", aliases: []}, + "CSON": {color: "#244776", aliases: []}, + "CSS": {color: "#663399", aliases: []}, + "CSV": {color: "#237346", aliases: []}, + "CUE": {color: "#5886E1", aliases: []}, + "CWeb": {color: "#00007A", aliases: []}, + "Cabal Config": {color: "#483465", aliases: ["Cabal"]}, + "Caddyfile": {color: "#22B638", aliases: ["Caddy"]}, + "Cadence": {color: "#00EF8B", aliases: []}, + "Cairo": {color: "#FF4A48", aliases: []}, + "Cairo Zero": {color: "#FF4A48", aliases: []}, + "CameLIGO": {color: "#3BE133", aliases: []}, + "Cangjie": {color: "#00868B", aliases: []}, + "Cap'n Proto": {color: "#C42727", aliases: []}, + "Carbon": {color: "#222222", aliases: []}, + "Ceylon": {color: "#DFA535", aliases: []}, + "Chapel": {color: "#8DC63F", aliases: ["chpl"]}, + "ChucK": {color: "#3F8000", aliases: []}, + "Circom": {color: "#707575", aliases: []}, + "Cirru": {color: "#CCCCFF", aliases: []}, + "Clarion": {color: "#DB901E", aliases: []}, + "Clarity": {color: "#5546FF", aliases: []}, + "Classic ASP": {color: "#6A40FD", aliases: ["asp"]}, + "Clean": {color: "#3F85AF", aliases: []}, + "Click": {color: "#E4E6F3", aliases: []}, + "Clojure": {color: "#DB5855", aliases: []}, + "Closure Templates": {color: "#0D948F", aliases: ["soy"]}, + "Cloud Firestore Security Rules": {color: "#FFA000", aliases: []}, + "Clue": {color: "#0009B5", aliases: []}, + "CodeQL": {color: "#140F46", aliases: ["ql"]}, + "CoffeeScript": {color: "#244776", aliases: ["coffee","coffee-script"]}, + "ColdFusion": {color: "#ED2CD6", aliases: ["cfm","cfml","coldfusion html"]}, + "ColdFusion CFC": {color: "#ED2CD6", aliases: ["cfc"]}, + "Common Lisp": {color: "#3FB68B", aliases: ["lisp"]}, + "Common Workflow Language": {color: "#B5314C", aliases: ["cwl"]}, + "Component Pascal": {color: "#B0CE4E", aliases: []}, + "Cooklang": {color: "#E15A29", aliases: []}, + "Crystal": {color: "#000100", aliases: []}, + "Csound": {color: "#1A1A1A", aliases: ["csound-orc"]}, + "Csound Document": {color: "#1A1A1A", aliases: ["csound-csd"]}, + "Csound Score": {color: "#1A1A1A", aliases: ["csound-sco"]}, + "Cuda": {color: "#3A4E3A", aliases: []}, + "Curry": {color: "#531242", aliases: []}, + "Cylc": {color: "#00B3FD", aliases: []}, + "Cypher": {color: "#34C0EB", aliases: []}, + "Cython": {color: "#FEDF5B", aliases: ["pyrex"]}, + "D": {color: "#BA595E", aliases: ["Dlang"]}, + "D2": {color: "#526EE8", aliases: ["d2lang"]}, + "DM": {color: "#447265", aliases: ["byond"]}, + "Dafny": {color: "#FFEC25", aliases: []}, + "Darcs Patch": {color: "#8EFF23", aliases: ["dpatch"]}, + "Dart": {color: "#00B4AB", aliases: []}, + "Daslang": {color: "#D3D3D3", aliases: []}, + "DataWeave": {color: "#003A52", aliases: []}, + "Debian Package Control File": {color: "#D70751", aliases: []}, + "DenizenScript": {color: "#FBEE96", aliases: []}, + "Dhall": {color: "#DFAFFF", aliases: []}, + "DirectX 3D File": {color: "#AACE60", aliases: []}, + "Dockerfile": {color: "#384D54", aliases: ["Containerfile"]}, + "Dogescript": {color: "#CCA760", aliases: []}, + "Dotenv": {color: "#E5D559", aliases: []}, + "Dune": {color: "#89421E", aliases: []}, + "Dylan": {color: "#6C616E", aliases: []}, + "E": {color: "#CCCE35", aliases: []}, + "ECL": {color: "#8A1267", aliases: []}, + "ECLiPSe": {color: "#001D9D", aliases: []}, + "EJS": {color: "#A91E50", aliases: []}, + "EQ": {color: "#A78649", aliases: []}, + "Earthly": {color: "#2AF0FF", aliases: ["Earthfile"]}, + "Easybuild": {color: "#069406", aliases: []}, + "Ecere Projects": {color: "#913960", aliases: []}, + "Ecmarkup": {color: "#EB8131", aliases: ["ecmarkdown"]}, + "Edge": {color: "#0DFFE0", aliases: []}, + "EdgeQL": {color: "#31A7FF", aliases: ["esdl"]}, + "EditorConfig": {color: "#FFF1F2", aliases: ["editor-config"]}, + "Eiffel": {color: "#4D6977", aliases: []}, + "Elixir": {color: "#8847B9", aliases: []}, + "Elm": {color: "#60B5CC", aliases: []}, + "Elvish": {color: "#55BB55", aliases: []}, + "Elvish Transcript": {color: "#55BB55", aliases: []}, + "Emacs Lisp": {color: "#C065DB", aliases: ["cask","eask","elisp","emacs"]}, + "EmberScript": {color: "#FFF4F3", aliases: []}, + "Erlang": {color: "#B83998", aliases: []}, + "Euphoria": {color: "#FF790B", aliases: []}, + "F#": {color: "#B845FC", aliases: ["fsharp"]}, + "F*": {color: "#572E30", aliases: ["fstar"]}, + "FIGlet Font": {color: "#FFDDBB", aliases: ["FIGfont"]}, + "FIRRTL": {color: "#2F632F", aliases: []}, + "FLUX": {color: "#88CCFF", aliases: []}, + "FPP": {color: "#D37327", aliases: []}, + "Factor": {color: "#636746", aliases: []}, + "Fancy": {color: "#7B9DB4", aliases: []}, + "Fantom": {color: "#14253C", aliases: []}, + "Faust": {color: "#C37240", aliases: []}, + "Fennel": {color: "#FFF3D7", aliases: []}, + "Filebench WML": {color: "#F6B900", aliases: []}, + "FlatBuffers": {color: "#ED284A", aliases: []}, + "Flix": {color: "#D44A45", aliases: []}, + "Fluent": {color: "#FFCC33", aliases: []}, + "Forth": {color: "#341708", aliases: []}, + "Fortran": {color: "#4D41B1", aliases: []}, + "Fortran Free Form": {color: "#4D41B1", aliases: []}, + "FreeBASIC": {color: "#141AC9", aliases: ["fb"]}, + "FreeMarker": {color: "#0050B2", aliases: ["ftl"]}, + "Frege": {color: "#00CAFE", aliases: []}, + "Futhark": {color: "#5F021F", aliases: []}, + "G-code": {color: "#D08CF2", aliases: []}, + "GAML": {color: "#FFC766", aliases: []}, + "GAMS": {color: "#F49A22", aliases: []}, + "GAP": {color: "#0000CC", aliases: []}, + "GCC Machine Description": {color: "#FFCFAB", aliases: []}, + "GDScript": {color: "#355570", aliases: []}, + "GDShader": {color: "#478CBF", aliases: []}, + "GEDCOM": {color: "#003058", aliases: []}, + "GLSL": {color: "#5686A5", aliases: []}, + "GSC": {color: "#FF6800", aliases: []}, + "Game Maker Language": {color: "#71B417", aliases: []}, + "Gemfile.lock": {color: "#701516", aliases: []}, + "Gemini": {color: "#FF6900", aliases: ["gemtext"]}, + "Genero 4gl": {color: "#63408E", aliases: []}, + "Genero per": {color: "#D8DF39", aliases: []}, + "Genie": {color: "#FB855D", aliases: []}, + "Genshi": {color: "#951531", aliases: ["xml+genshi","xml+kid"]}, + "Gentoo Ebuild": {color: "#9400FF", aliases: []}, + "Gentoo Eclass": {color: "#9400FF", aliases: []}, + "Gerber Image": {color: "#D20B00", aliases: ["rs-274x"]}, + "Gherkin": {color: "#5B2063", aliases: ["cucumber"]}, + "Git Attributes": {color: "#F44D27", aliases: ["gitattributes"]}, + "Git Commit": {color: "#F44D27", aliases: ["commit"]}, + "Git Config": {color: "#F44D27", aliases: ["gitconfig","gitmodules"]}, + "Git Revision List": {color: "#F44D27", aliases: ["Git Blame Ignore Revs"]}, + "Gleam": {color: "#FFAFF3", aliases: []}, + "Glimmer JS": {color: "#F5835F", aliases: ["gjs"]}, + "Glimmer TS": {color: "#3178C6", aliases: ["gts"]}, + "Glyph": {color: "#C1AC7F", aliases: []}, + "Gno": {color: "#226C57", aliases: ["gnolang"]}, + "Gnuplot": {color: "#F0A9F0", aliases: []}, + "Go": {color: "#00ADD8", aliases: ["golang"]}, + "Go Checksums": {color: "#00ADD8", aliases: ["go.sum","go sum","go.work.sum","go work sum"]}, + "Go Module": {color: "#00ADD8", aliases: ["go.mod","go mod"]}, + "Go Template": {color: "#00ADD8", aliases: ["gotmpl"]}, + "Go Workspace": {color: "#00ADD8", aliases: ["go.work","go work"]}, + "Godot Resource": {color: "#355570", aliases: []}, + "Golo": {color: "#88562A", aliases: []}, + "Gosu": {color: "#82937F", aliases: []}, + "Grace": {color: "#615F8B", aliases: []}, + "Gradle": {color: "#02303A", aliases: []}, + "Gradle Kotlin DSL": {color: "#02303A", aliases: []}, + "Grammatical Framework": {color: "#FF0000", aliases: ["gf"]}, + "GraphQL": {color: "#E10098", aliases: []}, + "Graphviz (DOT)": {color: "#2596BE", aliases: []}, + "Groovy": {color: "#4298B8", aliases: []}, + "Groovy Server Pages": {color: "#4298B8", aliases: ["gsp","java server page"]}, + "GtkRC": {color: "#7FE719", aliases: ["gtk","gtk 1","gtk 2"]}, + "HAProxy": {color: "#106DA9", aliases: []}, + "HCL": {color: "#844FBA", aliases: ["HashiCorp Configuration Language","opentofu","terraform"]}, + "HIP": {color: "#4F3A4F", aliases: []}, + "HLSL": {color: "#AACE60", aliases: []}, + "HOCON": {color: "#9FF8EE", aliases: []}, + "HTML": {color: "#E34C26", aliases: ["xhtml"]}, + "HTML+ECR": {color: "#2E1052", aliases: ["ecr"]}, + "HTML+EEX": {color: "#6E4A7E", aliases: ["eex","heex","leex"]}, + "HTML+ERB": {color: "#701516", aliases: ["erb","rhtml","html+ruby"]}, + "HTML+PHP": {color: "#4F5D95", aliases: []}, + "HTML+Razor": {color: "#512BE4", aliases: ["razor"]}, + "HTTP": {color: "#005C9C", aliases: []}, + "HXML": {color: "#F68712", aliases: []}, + "Hack": {color: "#878787", aliases: []}, + "Haml": {color: "#ECE2A9", aliases: []}, + "Handlebars": {color: "#F7931E", aliases: ["hbs","htmlbars"]}, + "Harbour": {color: "#0E60E3", aliases: []}, + "Hare": {color: "#9D7424", aliases: []}, + "Haskell": {color: "#5E5086", aliases: []}, + "Haxe": {color: "#DF7900", aliases: []}, + "HiveQL": {color: "#DCE200", aliases: []}, + "HolyC": {color: "#FFEFAF", aliases: []}, + "Hosts File": {color: "#308888", aliases: ["hosts"]}, + "Hurl": {color: "#FF0288", aliases: []}, + "Hy": {color: "#7790B2", aliases: ["hylang"]}, + "IDL": {color: "#A3522F", aliases: []}, + "IGOR Pro": {color: "#0000CC", aliases: ["igor","igorpro"]}, + "IL Assembly": {color: "#512BD4", aliases: ["ilasm","msil"]}, + "INI": {color: "#D1DBE0", aliases: ["conf","dosini"]}, + "ISPC": {color: "#2D68B1", aliases: []}, + "Idris": {color: "#B30000", aliases: []}, + "Ignore List": {color: "#000000", aliases: ["ignore","gitignore","git-ignore"]}, + "ImHex Pattern Language": {color: "#3A6BE0", aliases: ["ImHex","ImHexPatternLanguage","imhexpl"]}, + "ImageJ Macro": {color: "#99AAFF", aliases: ["ijm"]}, + "Imba": {color: "#16CEC6", aliases: []}, + "Inno Setup": {color: "#264B99", aliases: []}, + "Io": {color: "#A9188D", aliases: []}, + "Ioke": {color: "#078193", aliases: []}, + "Isabelle": {color: "#FEFE00", aliases: []}, + "Isabelle ROOT": {color: "#FEFE00", aliases: []}, + "J": {color: "#9EEDFF", aliases: []}, + "JAR Manifest": {color: "#B07219", aliases: []}, + "JASS": {color: "#FF0303", aliases: ["jass2"]}, + "JCL": {color: "#D90E09", aliases: []}, + "JFlex": {color: "#DBCA00", aliases: []}, + "JSON": {color: "#292929", aliases: ["geojson","jsonl","sarif","topojson"]}, + "JSON with Comments": {color: "#292929", aliases: ["jsonc"]}, + "JSON5": {color: "#267CB9", aliases: []}, + "JSONLD": {color: "#0C479C", aliases: []}, + "JSONiq": {color: "#40D47E", aliases: []}, + "Jac": {color: "#FC792D", aliases: []}, + "Jai": {color: "#AB8B4B", aliases: []}, + "Janet": {color: "#0886A5", aliases: []}, + "Jasmin": {color: "#D03600", aliases: []}, + "Java": {color: "#B07219", aliases: []}, + "Java Properties": {color: "#2A6277", aliases: []}, + "Java Server Pages": {color: "#2A6277", aliases: ["jsp"]}, + "Java Template Engine": {color: "#2A6277", aliases: ["jte"]}, + "JavaScript": {color: "#F1E05A", aliases: ["js","node"]}, + "JavaScript+ERB": {color: "#F1E05A", aliases: []}, + "Jest Snapshot": {color: "#15C213", aliases: []}, + "JetBrains MPS": {color: "#21D789", aliases: ["mps"]}, + "Jinja": {color: "#A52A22", aliases: ["django","html+django","html+jinja","htmldjango"]}, + "Jison": {color: "#56B3CB", aliases: []}, + "Jison Lex": {color: "#56B3CB", aliases: []}, + "Jolie": {color: "#843179", aliases: []}, + "Jsonnet": {color: "#0064BD", aliases: []}, + "Julia": {color: "#A270BA", aliases: []}, + "Julia REPL": {color: "#A270BA", aliases: []}, + "Jupyter Notebook": {color: "#DA5B0B", aliases: ["IPython Notebook"]}, + "Just": {color: "#384D54", aliases: ["Justfile"]}, + "KCL": {color: "#7ABABF", aliases: []}, + "KDL": {color: "#FFB3B3", aliases: []}, + "KFramework": {color: "#4195C5", aliases: []}, + "KRL": {color: "#28430A", aliases: []}, + "Kaitai Struct": {color: "#773B37", aliases: ["ksy"]}, + "KakouneScript": {color: "#6F8042", aliases: ["kak","kakscript"]}, + "KerboScript": {color: "#41ADF0", aliases: []}, + "KiCad Layout": {color: "#2F4AAB", aliases: ["pcbnew"]}, + "KiCad Legacy Layout": {color: "#2F4AAB", aliases: []}, + "KiCad Schematic": {color: "#2F4AAB", aliases: ["eeschema schematic"]}, + "KoLmafia ASH": {color: "#B9D9B9", aliases: []}, + "Koka": {color: "#215166", aliases: []}, + "Kotlin": {color: "#A97BFF", aliases: []}, + "LFE": {color: "#4C3023", aliases: []}, + "LLVM": {color: "#185619", aliases: []}, + "LLVM TableGen": {color: "#6E8B3D", aliases: ["tablegen"]}, + "LOLCODE": {color: "#CC9900", aliases: []}, + "LSL": {color: "#3D9970", aliases: []}, + "LabVIEW": {color: "#FEDE06", aliases: []}, + "Lambdapi": {color: "#8027A3", aliases: []}, + "Langium": {color: "#2C8C87", aliases: []}, + "Lark": {color: "#2980B9", aliases: []}, + "Lasso": {color: "#999999", aliases: ["lassoscript"]}, + "Latte": {color: "#F2A542", aliases: []}, + "Leo": {color: "#C4FFC2", aliases: []}, + "Less": {color: "#1D365D", aliases: ["less-css"]}, + "Lex": {color: "#DBCA00", aliases: ["flex"]}, + "LigoLANG": {color: "#0E74FF", aliases: []}, + "LilyPond": {color: "#9CCC7C", aliases: []}, + "Liquid": {color: "#67B8DE", aliases: []}, + "Liquidsoap": {color: "#990066", aliases: []}, + "Literate Agda": {color: "#315665", aliases: []}, + "Literate CoffeeScript": {color: "#244776", aliases: ["litcoffee"]}, + "Literate Haskell": {color: "#5E5086", aliases: ["lhaskell","lhs"]}, + "LiveCode Script": {color: "#0C5BA5", aliases: []}, + "LiveScript": {color: "#499886", aliases: ["live-script","ls"]}, + "Lobster": {color: "#F95428", aliases: []}, + "Logtalk": {color: "#295B9A", aliases: []}, + "LookML": {color: "#652B81", aliases: []}, + "Lua": {color: "#000080", aliases: []}, + "Luau": {color: "#00A2FF", aliases: []}, + "M3U": {color: "#179C7D", aliases: ["hls playlist","m3u playlist"]}, + "MATLAB": {color: "#E16737", aliases: ["octave"]}, + "MAXScript": {color: "#00A6A6", aliases: []}, + "MDX": {color: "#FCB32C", aliases: []}, + "MLIR": {color: "#5EC8DB", aliases: []}, + "MQL4": {color: "#62A8D6", aliases: []}, + "MQL5": {color: "#4A76B8", aliases: []}, + "MTML": {color: "#B7E1F4", aliases: []}, + "Macaulay2": {color: "#D8FFFF", aliases: ["m2"]}, + "Makefile": {color: "#427819", aliases: ["bsdmake","make","mf"]}, + "Mako": {color: "#7E858D", aliases: []}, + "Markdown": {color: "#083FA1", aliases: ["md","pandoc"]}, + "Marko": {color: "#42BFF2", aliases: ["markojs"]}, + "Mask": {color: "#F97732", aliases: []}, + "Mathematical Programming System": {color: "#0530AD", aliases: []}, + "Max": {color: "#C4A79C", aliases: ["max/msp","maxmsp"]}, + "MeTTa": {color: "#6A5ACD", aliases: []}, + "Mercury": {color: "#FF2B2B", aliases: []}, + "Mermaid": {color: "#FF3670", aliases: ["mermaid example"]}, + "Meson": {color: "#007800", aliases: []}, + "Metal": {color: "#8F14E9", aliases: []}, + "MiniScript": {color: "#4B4A56", aliases: []}, + "MiniYAML": {color: "#FF1111", aliases: []}, + "MiniZinc": {color: "#06A9E6", aliases: []}, + "Mint": {color: "#02B046", aliases: []}, + "Mirah": {color: "#C7A938", aliases: []}, + "Modelica": {color: "#DE1D31", aliases: []}, + "Modula-2": {color: "#10253F", aliases: []}, + "Modula-3": {color: "#223388", aliases: []}, + "Mojo": {color: "#FF4C1F", aliases: []}, + "Monkey C": {color: "#8D6747", aliases: []}, + "MoonBit": {color: "#B92381", aliases: []}, + "MoonScript": {color: "#FF4585", aliases: []}, + "Motoko": {color: "#FBB03B", aliases: []}, + "Motorola 68K Assembly": {color: "#005DAA", aliases: ["m68k"]}, + "Move": {color: "#4A137A", aliases: []}, + "Mustache": {color: "#724B3B", aliases: []}, + "NCL": {color: "#28431F", aliases: []}, + "NMODL": {color: "#00356B", aliases: []}, + "NPM Config": {color: "#CB3837", aliases: ["npmrc"]}, + "NWScript": {color: "#111522", aliases: []}, + "Nasal": {color: "#1D2C4E", aliases: []}, + "Nearley": {color: "#990000", aliases: []}, + "Nemerle": {color: "#3D3C6E", aliases: []}, + "NetLinx": {color: "#0AA0FF", aliases: []}, + "NetLinx+ERB": {color: "#747FAA", aliases: []}, + "NetLogo": {color: "#FF6375", aliases: []}, + "NewLisp": {color: "#87AED7", aliases: []}, + "Nextflow": {color: "#3AC486", aliases: []}, + "Nginx": {color: "#009639", aliases: ["nginx configuration file"]}, + "Nickel": {color: "#E0C3FC", aliases: []}, + "Nim": {color: "#FFC200", aliases: []}, + "Nit": {color: "#009917", aliases: []}, + "Nix": {color: "#7E7EFF", aliases: ["nixos"]}, + "Noir": {color: "#2F1F49", aliases: ["nargo"]}, + "Nu": {color: "#C9DF40", aliases: ["nush"]}, + "NumPy": {color: "#9C8AF9", aliases: []}, + "Nunjucks": {color: "#3D8137", aliases: ["njk"]}, + "Nushell": {color: "#4E9906", aliases: ["nu-script","nushell-script"]}, + "OASv2-json": {color: "#85EA2D", aliases: []}, + "OASv2-yaml": {color: "#85EA2D", aliases: []}, + "OASv3-json": {color: "#85EA2D", aliases: []}, + "OASv3-yaml": {color: "#85EA2D", aliases: []}, + "OCaml": {color: "#EF7A08", aliases: []}, + "OMNeT++ MSG": {color: "#A0E0A0", aliases: ["omnetpp-msg"]}, + "OMNeT++ NED": {color: "#08607C", aliases: ["omnetpp-ned"]}, + "ObjectScript": {color: "#424893", aliases: []}, + "Objective-C": {color: "#438EFF", aliases: ["obj-c","objc","objectivec"]}, + "Objective-C++": {color: "#6866FB", aliases: ["obj-c++","objc++","objectivec++"]}, + "Objective-J": {color: "#FF0C5A", aliases: ["obj-j","objectivej","objj"]}, + "Odin": {color: "#60AFFE", aliases: ["odinlang","odin-lang"]}, + "Omgrofl": {color: "#CABBFF", aliases: []}, + "Opal": {color: "#F7EDE0", aliases: []}, + "Open Policy Agent": {color: "#7D9199", aliases: []}, + "OpenAPI Specification v2": {color: "#85EA2D", aliases: ["oasv2"]}, + "OpenAPI Specification v3": {color: "#85EA2D", aliases: ["oasv3"]}, + "OpenCL": {color: "#ED2E2D", aliases: []}, + "OpenEdge ABL": {color: "#5CE600", aliases: ["progress","openedge","abl"]}, + "OpenQASM": {color: "#AA70FF", aliases: []}, + "OpenSCAD": {color: "#E5CD45", aliases: []}, + "Option List": {color: "#476732", aliases: ["opts","ackrc"]}, + "Org": {color: "#77AA99", aliases: []}, + "OverPy": {color: "#78B355", aliases: ["opy"]}, + "OverpassQL": {color: "#CCE2AA", aliases: []}, + "Oxygene": {color: "#CDD0E3", aliases: []}, + "Oz": {color: "#FAB738", aliases: []}, + "P4": {color: "#7055B5", aliases: []}, + "PDDL": {color: "#0D00FF", aliases: []}, + "PEG.js": {color: "#234D6B", aliases: []}, + "PHP": {color: "#4F5D95", aliases: ["inc"]}, + "PLSQL": {color: "#DAD8D8", aliases: []}, + "PLpgSQL": {color: "#336790", aliases: []}, + "POV-Ray SDL": {color: "#6BAC65", aliases: ["pov-ray","povray"]}, + "Pact": {color: "#F7A8B8", aliases: []}, + "Pan": {color: "#CC0000", aliases: []}, + "Papyrus": {color: "#6600CC", aliases: []}, + "Parrot": {color: "#F3CA0A", aliases: []}, + "Pascal": {color: "#E3F171", aliases: ["delphi","objectpascal"]}, + "Pawn": {color: "#DBB284", aliases: []}, + "Pep8": {color: "#C76F5B", aliases: []}, + "Perl": {color: "#0298C3", aliases: ["cperl"]}, + "PicoLisp": {color: "#6067AF", aliases: []}, + "PigLatin": {color: "#FCD7DE", aliases: []}, + "Pike": {color: "#005390", aliases: []}, + "Pip Requirements": {color: "#FFD343", aliases: []}, + "Pkl": {color: "#6B9543", aliases: []}, + "PlantUML": {color: "#FBBD16", aliases: []}, + "PogoScript": {color: "#D80074", aliases: []}, + "Polar": {color: "#AE81FF", aliases: []}, + "Portugol": {color: "#F8BD00", aliases: []}, + "PostCSS": {color: "#DC3A0C", aliases: []}, + "PostScript": {color: "#DA291C", aliases: ["postscr"]}, + "Power Query": {color: "#D38E0D", aliases: ["powerquery"]}, + "PowerBuilder": {color: "#8F0F8D", aliases: []}, + "PowerShell": {color: "#012456", aliases: ["posh","pwsh"]}, + "Praat": {color: "#C8506D", aliases: []}, + "Prisma": {color: "#0C344B", aliases: []}, + "Pro*C": {color: "#BB8368", aliases: []}, + "Processing": {color: "#0096D8", aliases: []}, + "Procfile": {color: "#3B2F63", aliases: []}, + "Prolog": {color: "#74283C", aliases: []}, + "Promela": {color: "#DE0000", aliases: []}, + "Propeller Spin": {color: "#7FA2A7", aliases: []}, + "Pug": {color: "#A86454", aliases: []}, + "Puppet": {color: "#302B6D", aliases: []}, + "PureBasic": {color: "#5A6986", aliases: []}, + "PureScript": {color: "#1D222D", aliases: []}, + "Pyret": {color: "#EE1E10", aliases: []}, + "Python": {color: "#3572A5", aliases: ["py","py3","python3","rusthon"]}, + "Python console": {color: "#3572A5", aliases: ["pycon"]}, + "Python traceback": {color: "#3572A5", aliases: []}, + "Q#": {color: "#FED659", aliases: ["qsharp"]}, + "QML": {color: "#44A51C", aliases: []}, + "Qt Script": {color: "#00B841", aliases: []}, + "Quake": {color: "#882233", aliases: []}, + "QuakeC": {color: "#975777", aliases: []}, + "Quartus Simulation IP": {color: "#58C42E", aliases: []}, + "QuickBASIC": {color: "#008080", aliases: ["qb","qbasic","qb64","classic qbasic","classic quickbasic"]}, + "Quint": {color: "#9D6CE5", aliases: []}, + "R": {color: "#198CE7", aliases: ["Rscript","splus"]}, + "RAML": {color: "#77D9FB", aliases: []}, + "RAScript": {color: "#2C97FA", aliases: []}, + "RBS": {color: "#701516", aliases: []}, + "RDoc": {color: "#701516", aliases: []}, + "REXX": {color: "#D90E09", aliases: ["arexx"]}, + "RMarkdown": {color: "#198CE7", aliases: []}, + "RON": {color: "#A62C00", aliases: []}, + "ROS Interface": {color: "#22314E", aliases: ["rosmsg"]}, + "RPGLE": {color: "#2BDE21", aliases: ["ile rpg","sqlrpgle"]}, + "RUNOFF": {color: "#665A4E", aliases: []}, + "Racket": {color: "#3C5CAA", aliases: []}, + "Ragel": {color: "#9D5200", aliases: ["ragel-rb","ragel-ruby"]}, + "Raku": {color: "#0000FB", aliases: ["perl6","perl-6"]}, + "Rascal": {color: "#FFFAA0", aliases: []}, + "ReScript": {color: "#ED5051", aliases: []}, + "Reason": {color: "#FF5847", aliases: []}, + "ReasonLIGO": {color: "#FF5847", aliases: []}, + "Rebol": {color: "#358A5B", aliases: []}, + "Record Jar": {color: "#0673BA", aliases: []}, + "Red": {color: "#F50000", aliases: ["red/system"]}, + "Redscript": {color: "#F44336", aliases: []}, + "Regular Expression": {color: "#009A00", aliases: ["regexp","regex"]}, + "Ren'Py": {color: "#FF7F7F", aliases: ["renpy"]}, + "Rez": {color: "#FFDAB3", aliases: []}, + "Rhai": {color: "#FBA63B", aliases: []}, + "Ring": {color: "#2D54CB", aliases: []}, + "Riot": {color: "#A71E49", aliases: []}, + "RobotFramework": {color: "#00C0B5", aliases: []}, + "Roc": {color: "#7C38F5", aliases: []}, + "Rocq Prover": {color: "#D0B68C", aliases: ["coq","rocq"]}, + "Roff": {color: "#ECDEBE", aliases: ["groff","man","manpage","man page","man-page","mdoc","nroff","troff"]}, + "Roff Manpage": {color: "#ECDEBE", aliases: []}, + "Rouge": {color: "#CC0088", aliases: []}, + "RouterOS Script": {color: "#DE3941", aliases: []}, + "Ruby": {color: "#701516", aliases: ["jruby","macruby","rake","rb","rbx"]}, + "Rust": {color: "#DEA584", aliases: ["rs"]}, + "SAS": {color: "#B34936", aliases: []}, + "SCSS": {color: "#C6538C", aliases: []}, + "SIP": {color: "#4E8D83", aliases: []}, + "SPARQL": {color: "#0C4597", aliases: []}, + "SQF": {color: "#3F3F3F", aliases: []}, + "SQL": {color: "#E38C00", aliases: []}, + "SQLPL": {color: "#E38C00", aliases: []}, + "SRecode Template": {color: "#348A34", aliases: []}, + "STL": {color: "#373B5E", aliases: ["ascii stl","stla"]}, + "SVG": {color: "#FF9900", aliases: []}, + "Sail": {color: "#259DD5", aliases: []}, + "Salt": {color: "#57BCAD", aliases: ["saltstack","saltstate"]}, + "Sass": {color: "#A53B70", aliases: []}, + "Scala": {color: "#C22D40", aliases: []}, + "Scaml": {color: "#BD181A", aliases: []}, + "Scenic": {color: "#FDC700", aliases: []}, + "Scheme": {color: "#1E4AEC", aliases: []}, + "Scilab": {color: "#CA0F21", aliases: []}, + "Self": {color: "#0579AA", aliases: []}, + "ShaderLab": {color: "#222C37", aliases: []}, + "Shell": {color: "#89E051", aliases: ["sh","shell-script","bash","zsh","envrc"]}, + "ShellCheck Config": {color: "#CECFCB", aliases: ["shellcheckrc"]}, + "Shen": {color: "#120F14", aliases: []}, + "Simple File Verification": {color: "#C9BFED", aliases: ["sfv"]}, + "Singularity": {color: "#64E6AD", aliases: []}, + "Slang": {color: "#1FBEC9", aliases: []}, + "Slash": {color: "#007EFF", aliases: []}, + "Slice": {color: "#003FA2", aliases: []}, + "Slim": {color: "#2B2B2B", aliases: []}, + "Slint": {color: "#2379F4", aliases: []}, + "SmPL": {color: "#C94949", aliases: ["coccinelle"]}, + "Smalltalk": {color: "#596706", aliases: ["squeak"]}, + "Smarty": {color: "#F0C040", aliases: []}, + "Smithy": {color: "#C44536", aliases: []}, + "Snakemake": {color: "#419179", aliases: ["snakefile"]}, + "Solidity": {color: "#AA6746", aliases: []}, + "SourcePawn": {color: "#F69E1D", aliases: ["sourcemod"]}, + "SpiceDB Schema": {color: "#A5318A", aliases: []}, + "Squirrel": {color: "#800000", aliases: []}, + "Stan": {color: "#B2011D", aliases: []}, + "Standard ML": {color: "#DC566D", aliases: ["sml"]}, + "Starlark": {color: "#76D275", aliases: ["bazel","bzl"]}, + "Stata": {color: "#1A5F91", aliases: []}, + "StringTemplate": {color: "#3FB34F", aliases: []}, + "Stylus": {color: "#FF6347", aliases: []}, + "SubRip Text": {color: "#9E0101", aliases: []}, + "SugarSS": {color: "#2FCC9F", aliases: []}, + "SuperCollider": {color: "#46390B", aliases: []}, + "SurrealQL": {color: "#FF00A0", aliases: ["surql"]}, + "Survex data": {color: "#FFCC99", aliases: []}, + "Svelte": {color: "#FF3E00", aliases: []}, + "Sway": {color: "#00F58C", aliases: []}, + "Sweave": {color: "#198CE7", aliases: []}, + "Swift": {color: "#F05138", aliases: []}, + "SystemVerilog": {color: "#DAE1C2", aliases: []}, + "TI Program": {color: "#A0AA87", aliases: []}, + "TL-Verilog": {color: "#C40023", aliases: []}, + "TLA": {color: "#4B0079", aliases: []}, + "TMDL": {color: "#F0C913", aliases: ["Tabular Model Definition Language"]}, + "TOML": {color: "#9C4221", aliases: []}, + "TSQL": {color: "#E38C00", aliases: []}, + "TSV": {color: "#237346", aliases: ["tab-seperated values"]}, + "TSX": {color: "#3178C6", aliases: ["typescriptreact"]}, + "TXL": {color: "#0178B8", aliases: []}, + "Tact": {color: "#48B5FF", aliases: []}, + "Talon": {color: "#333333", aliases: []}, + "Tcl": {color: "#E4CC98", aliases: ["sdc","xdc"]}, + "TeX": {color: "#3D6117", aliases: ["latex"]}, + "Teal": {color: "#00B1BC", aliases: []}, + "Terra": {color: "#00004C", aliases: []}, + "Terraform Template": {color: "#7B42BB", aliases: []}, + "TextGrid": {color: "#C8506D", aliases: []}, + "TextMate Properties": {color: "#DF66E4", aliases: ["tm-properties"]}, + "Textile": {color: "#FFE7AC", aliases: []}, + "Thrift": {color: "#D12127", aliases: []}, + "Toit": {color: "#C2C9FB", aliases: []}, + "Tolk": {color: "#30A1F5", aliases: []}, + "Tor Config": {color: "#59316B", aliases: ["torrc"]}, + "Tree-sitter Query": {color: "#8EA64C", aliases: ["tsq"]}, + "Turing": {color: "#CF142B", aliases: []}, + "Twig": {color: "#C1D026", aliases: []}, + "TypeScript": {color: "#3178C6", aliases: ["ts"]}, + "TypeSpec": {color: "#4A3665", aliases: ["tsp"]}, + "Typst": {color: "#239DAD", aliases: ["typ"]}, + "Unified Parallel C": {color: "#4E3617", aliases: []}, + "Unity3D Asset": {color: "#222C37", aliases: []}, + "Uno": {color: "#9933CC", aliases: []}, + "UnrealScript": {color: "#A54C4D", aliases: []}, + "Untyped Plutus Core": {color: "#36ADBD", aliases: []}, + "UrWeb": {color: "#CCCCEE", aliases: ["Ur/Web","Ur"]}, + "V": {color: "#4F87C4", aliases: ["vlang"]}, + "VBA": {color: "#867DB1", aliases: ["visual basic for applications"]}, + "VBScript": {color: "#15DCDC", aliases: []}, + "VCL": {color: "#148AA8", aliases: []}, + "VHDL": {color: "#ADB2CB", aliases: []}, + "Vala": {color: "#A56DE2", aliases: []}, + "Valve Data Format": {color: "#F26025", aliases: ["keyvalues","vdf"]}, + "Velocity Template Language": {color: "#507CFF", aliases: ["vtl","velocity"]}, + "Vento": {color: "#FF0080", aliases: []}, + "Verilog": {color: "#B2B7F8", aliases: []}, + "Verse": {color: "#518EF8", aliases: []}, + "Vespa Schema Definition": {color: "#61D790", aliases: ["vespa"]}, + "Vim Help File": {color: "#199F4B", aliases: ["help","vimhelp"]}, + "Vim Snippet": {color: "#199F4B", aliases: ["SnipMate","UltiSnip","UltiSnips","NeoSnippet"]}, + "Vim script": {color: "#199F4B", aliases: ["vim","viml","nvim","vimscript"]}, + "Visual Basic .NET": {color: "#945DB7", aliases: ["visual basic","vbnet","vb .net","vb.net"]}, + "Visual Basic 6.0": {color: "#2C6353", aliases: ["vb6","vb 6","visual basic 6","visual basic classic","classic visual basic"]}, + "Volt": {color: "#1F1F1F", aliases: []}, + "Vue": {color: "#41B883", aliases: []}, + "Vyper": {color: "#9F4CF2", aliases: []}, + "WDL": {color: "#42F1F4", aliases: ["Workflow Description Language"]}, + "WGSL": {color: "#1A5E9A", aliases: []}, + "Web Ontology Language": {color: "#5B70BD", aliases: []}, + "WebAssembly": {color: "#04133B", aliases: ["wast","wasm"]}, + "WebAssembly Interface Type": {color: "#6250E7", aliases: ["wit"]}, + "Whiley": {color: "#D5C397", aliases: []}, + "Wikitext": {color: "#FC5757", aliases: ["mediawiki","wiki"]}, + "Windows Registry Entries": {color: "#52D5FF", aliases: []}, + "Witcher Script": {color: "#FF0000", aliases: []}, + "Wolfram Language": {color: "#DD1100", aliases: ["mathematica","mma","wolfram","wolfram lang","wl"]}, + "Wollok": {color: "#A23738", aliases: []}, + "World of Warcraft Addon Data": {color: "#F7E43F", aliases: []}, + "Wren": {color: "#383838", aliases: ["wrenlang"]}, + "X10": {color: "#4B6BEF", aliases: ["xten"]}, + "XC": {color: "#99DA07", aliases: []}, + "XML": {color: "#0060AC", aliases: ["rss","xsd","wsdl"]}, + "XML Property List": {color: "#0060AC", aliases: []}, + "XQuery": {color: "#5232E7", aliases: []}, + "XSLT": {color: "#EB8CEB", aliases: ["xsl"]}, + "Xmake": {color: "#22A079", aliases: []}, + "Xojo": {color: "#81BD41", aliases: []}, + "Xonsh": {color: "#285EEF", aliases: []}, + "Xtend": {color: "#24255D", aliases: []}, + "YAML": {color: "#CB171E", aliases: ["yml"]}, + "YARA": {color: "#220000", aliases: []}, + "YASnippet": {color: "#32AB90", aliases: ["snippet","yas"]}, + "Yacc": {color: "#4B6C4B", aliases: []}, + "Yul": {color: "#794932", aliases: []}, + "ZAP": {color: "#0D665E", aliases: []}, + "ZIL": {color: "#DC75E5", aliases: []}, + "ZenScript": {color: "#00BCD1", aliases: []}, + "Zephir": {color: "#118F9E", aliases: []}, + "Zig": {color: "#EC915C", aliases: []}, + "Zimpl": {color: "#D67711", aliases: []}, + "Zmodel": {color: "#FF7100", aliases: []}, + "crontab": {color: "#EAD7AC", aliases: ["cron","cron table"]}, + "eC": {color: "#913960", aliases: []}, + "fish": {color: "#4AAE47", aliases: []}, + "hoon": {color: "#00B171", aliases: []}, + "iCalendar": {color: "#EC564C", aliases: ["iCal"]}, + "jq": {color: "#C7254E", aliases: []}, + "kvlang": {color: "#1DA6E0", aliases: []}, + "mIRC Script": {color: "#3D57C3", aliases: []}, + "mcfunction": {color: "#E22837", aliases: []}, + "mdsvex": {color: "#5F9EA0", aliases: []}, + "mupad": {color: "#244963", aliases: []}, + "nanorc": {color: "#2D004D", aliases: []}, + "nesC": {color: "#94B0C7", aliases: []}, + "ooc": {color: "#B0B77E", aliases: []}, + "pkg-config": {color: "#2B5E82", aliases: ["pkgconf"]}, + "q": {color: "#0040CD", aliases: []}, + "reStructuredText": {color: "#141414", aliases: ["rst"]}, + "sed": {color: "#64B970", aliases: []}, + "templ": {color: "#66D0DD", aliases: []}, + "ucode": {color: "#00B8D4", aliases: []}, + "vCard": {color: "#EE2647", aliases: ["virtual contact file","electronic business card"]}, + "wisp": {color: "#7582D1", aliases: []}, + "xBase": {color: "#403A40", aliases: ["advpl","clipper","foxpro"]} +} as const; diff --git a/src/languages/linguistLanguageColors.ts b/src/languages/linguistLanguageColors.ts new file mode 100644 index 00000000..a17c4088 --- /dev/null +++ b/src/languages/linguistLanguageColors.ts @@ -0,0 +1,31 @@ +import {LINGUIST_LANGUAGE_COLORS} from './linguistLanguageColors.generated.js'; +import {identity, type ColorRef} from '../chroma/chroma.js'; + +/** Neutral identity fallback for names without a Linguist color. */ +export const UNKNOWN_LANGUAGE_IDENTITY_COLOR = '#8F8A98'; + +export function normalizeLanguageName(name: string): string { + return name.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLocaleLowerCase('en-US'); +} + +const languageColors = new Map(); +for (const [name, entry] of Object.entries(LINGUIST_LANGUAGE_COLORS)) { + languageColors.set(normalizeLanguageName(name), entry.color); +} +for (const [name, entry] of Object.entries(LINGUIST_LANGUAGE_COLORS)) { + for (const alias of entry.aliases) { + const normalized = normalizeLanguageName(alias); + if (!languageColors.has(normalized)) languageColors.set(normalized, entry.color); + } +} + +/** Returns a Linguist identity color; this value carries no semantic status. */ +export function languageIdentityColor(name: string): string { + return languageColors.get(normalizeLanguageName(name)) ?? UNKNOWN_LANGUAGE_IDENTITY_COLOR; +} + +/** Use Chroma's identity category at every presentation boundary. */ +export function languageIdentity(name: string): ColorRef { + const hex = languageIdentityColor(name).slice(1); + return identity({red: parseInt(hex.slice(0, 2), 16), green: parseInt(hex.slice(2, 4), 16), blue: parseInt(hex.slice(4, 6), 16)}); +} diff --git a/src/motion/PresentationClock.ts b/src/motion/PresentationClock.ts new file mode 100644 index 00000000..ad14f154 --- /dev/null +++ b/src/motion/PresentationClock.ts @@ -0,0 +1,45 @@ +/** One demand-driven frame scheduler. Pure presentation primitives consume its time. */ +export class PresentationClock { + private listeners = new Map void; interval: number; next: number}>(); + private timer?: NodeJS.Timeout; + /** Scheduling uses a monotonic source so wall-clock adjustments cannot stall or burst frames. */ + constructor(private readonly monotonic: () => number = () => performance.now()) {} + get subscriberCount(): number { return this.listeners.size; } + get scheduled(): boolean { return this.timer !== undefined; } + + subscribe(callback: (now: number) => void, interval = 100): () => void { + const key = Symbol(); + const bounded = Number.isFinite(interval) ? Math.max(100, Math.min(60_000, interval)) : 100; + this.listeners.set(key, {callback, interval: bounded, next: this.monotonic() + bounded}); + this.schedule(); + return () => { if (this.listeners.delete(key)) this.schedule(); }; + } + + after(callback: () => void, delay: number): () => void { + const stop = this.subscribe(() => { stop(); callback(); }, delay); + return stop; + } + + private schedule(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + if (!this.listeners.size) return; + const next = Math.min(...Array.from(this.listeners.values(), listener => listener.next)); + this.timer = setTimeout(() => { + this.timer = undefined; + const tick = this.monotonic(); + const now = Date.now(); // Callbacks still receive wall time for presentation sampling. + try { + for (const [key, listener] of [...this.listeners]) { + if (!this.listeners.has(key) || tick < listener.next) continue; + listener.next = tick + listener.interval; + try { listener.callback(now); } + catch (error) { this.listeners.delete(key); throw error; } + } + } finally { this.schedule(); } + }, Math.max(0, next - this.monotonic())); + this.timer.unref?.(); + } +} + +export const presentationClock = new PresentationClock(); diff --git a/src/motion/effects.ts b/src/motion/effects.ts new file mode 100644 index 00000000..405a6b21 --- /dev/null +++ b/src/motion/effects.ts @@ -0,0 +1,73 @@ +import type {Region, ScreenPlan} from '../app/screenPlan.js'; +import {colorEscape} from '../chroma/escape.js'; +import {BRAND_LAVENDER, mixRgb} from '../chroma/chroma.js'; +import type {TreatmentSettings} from '../chroma/treatment.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; + +export const EFFECT_DURATION_MS = 3000; +export const MAX_PARTICLES = 64; +export type EffectKind = 'sparkles' | 'rain'; +export type EffectPlacement = 'top' | 'bottom'; +export interface ActiveEffect {kind: EffectKind; placement: EffectPlacement; startedAt: number; seed: number} +export interface EffectCell {row: number; column: number; glyph: string; intensity: number} + +/** Replace-active policy. No particles, timers or escapes are persisted. */ +export class EffectState { + active?: ActiveEffect; + trigger(kind: EffectKind, placement: EffectPlacement, now: number, seed: number, settings: TreatmentSettings): boolean { + this.cancel(); + if (settings.effectsOff || settings.reducedMotion) return false; + this.active = {kind, placement, startedAt: now, seed: seed >>> 0}; + return true; + } + cancel(): void { this.active = undefined; } + expire(now: number): boolean { + if (!this.active || now - this.active.startedAt < EFFECT_DURATION_MS) return false; + this.cancel(); return true; + } +} + +/** Only empty gaps and NMSh decorative rules. Never transcript, prompt, input or focus. */ +export function effectRegion(plan: ScreenPlan, placement: EffectPlacement): Region | undefined { + const regions = plan.regions.filter(region => region.height > 0 && ['gap', 'separator', 'composerBorder'].includes(region.kind)); + const region = placement === 'top' ? regions[0] : regions[regions.length - 1]; + return region && {...region, height: Math.min(4, region.height)}; +} + +function noise(seed: number, index: number): number { + let n = (seed ^ Math.imul(index + 1, 0x9e3779b1)) >>> 0; + n = Math.imul(n ^ (n >>> 16), 0x85ebca6b); + n = Math.imul(n ^ (n >>> 13), 0xc2b2ae35); + return ((n ^ (n >>> 16)) >>> 0) / 0x100000000; +} + +/** Pure seeded frames, bounded independently of transcript size and resize. */ +export function effectCells(effect: ActiveEffect, region: Region, columns: number, now: number, safe: boolean): EffectCell[] { + const elapsed = now - effect.startedAt; + if (elapsed < 0 || elapsed >= EFFECT_DURATION_MS) return []; + const width = Math.max(1, Math.min(512, Math.floor(columns))); + const height = Math.max(0, Math.min(4, Math.floor(region.height))); + const count = Math.min(MAX_PARTICLES, width * height); + const frame = Math.floor(elapsed / 100); + return Array.from({length: count}, (_, index) => { + const phase = noise(effect.seed, index * 3); + return { + column: Math.floor(noise(effect.seed, index * 3 + 1) * width), + row: region.top + (effect.kind === 'rain' ? (Math.floor(phase * height) + frame) % height : Math.floor(noise(effect.seed, index * 3 + 2) * height)), + glyph: effect.kind === 'rain' ? '|' : safe ? '+' : '·', + intensity: (1 + Math.sin((phase + frame / 12) * 2 * Math.PI)) / 2, + }; + }); +} + +/** Replaces only decorative rows in a copy of the current base projection. */ +export function applyEffect(rows: readonly string[], effect: ActiveEffect, region: Region, columns: number, now: number, safe: boolean, level: ColorLevel): string[] { + const next = [...rows]; + const width = Math.max(1, Math.min(512, Math.floor(columns))); + const grid = Array.from({length: Math.min(4, region.height)}, () => Array(width).fill(' ')); + for (const cell of effectCells(effect, region, columns, now, safe)) { + grid[cell.row - region.top]![cell.column] = `${colorEscape(38, mixRgb(BRAND_LAVENDER, {red: 235, green: 220, blue: 255}, cell.intensity), level)}${cell.glyph}`; + } + grid.forEach((row, index) => { next[region.top + index] = row.join('') + (level === 'none' ? '' : '\u001B[0m'); }); + return next; +} diff --git a/src/notifications/commandNotifications.ts b/src/notifications/commandNotifications.ts new file mode 100644 index 00000000..28f0d968 --- /dev/null +++ b/src/notifications/commandNotifications.ts @@ -0,0 +1,129 @@ +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; +import {PRODUCT_NAME} from '../config.js'; +import type {NotificationSettings} from '../prompt/configuration.js'; +import {parseSlashCommand} from '../commands/slashCommands.js'; +import {formatDuration} from '../status/commandTiming.js'; + +/** Terminal focus as learned from focus reports; unknown until the terminal sends one. */ +export type TerminalFocus = 'focused' | 'blurred' | 'unknown'; + +/** A live, real shell command that just finished. Slash commands and restored history never produce one. */ +export interface CompletedCommand { + command: string; + elapsedMs: number; + exitCode: number; + interrupted: boolean; +} + +export function commandSucceeded(completed: CompletedCommand): boolean { + return completed.exitCode === 0 && !completed.interrupted; +} + +/** The master filter: settings are the current ones, read when the command completes. */ +/** Only a recognized NMSh slash command is internal; an absolute-path shell command is a real command. */ +function isInternalCommand(command: string): boolean { + const parsed = parseSlashCommand(command.trim()); + return parsed !== undefined && parsed.kind !== 'unknown'; +} + +export function shouldNotify(completed: CompletedCommand, settings: NotificationSettings, focus: TerminalFocus): boolean { + if (!settings.enabled || isInternalCommand(completed.command) || !Number.isFinite(completed.elapsedMs)) return false; + if (completed.elapsedMs < settings.thresholdSeconds * 1000) return false; + const success = commandSucceeded(completed); + if (success && !settings.onSuccess) return false; + if (!success && !settings.onFailure) return false; + // Unknown is not definitely focused: terminals without focus reports still notify. + if (focus === 'focused' && settings.whenFocused === 'suppress') return false; + return true; +} + +export interface CommandNotification { + title: string; + subtitle: string; + body: string; +} + +/** Generic body deliberately excludes command text and shell output. */ +export function formatCommandNotification(completed: CompletedCommand): CommandNotification { + const duration = formatDuration(completed.elapsedMs); + const subtitle = commandSucceeded(completed) ? `Command finished · ${duration}` + : completed.interrupted ? `Command interrupted · ${duration}` + : `Command failed · ${duration} · exit ${completed.exitCode}`; + return {title: PRODUCT_NAME, subtitle, body: 'Your shell command has completed.'}; +} + +/** What happened to one delivery attempt; for tests and the opt-in debug log, never shell output. */ +export type NotificationDelivery = + | {ok: true} + | {ok: false; reason: 'unsupported' | 'spawn' | 'exit' | 'timeout'; exitCode?: number | null; message?: string}; + +/** Best-effort delivery; implementations never throw, never block, and never write to the terminal. */ +export interface NotificationService { + readonly supported: boolean; + notify(notification: CommandNotification): Promise; +} + +export type SpawnFunction = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + +export const OSASCRIPT_PATH = '/usr/bin/osascript'; + +/** + * Strings arrive only as argv, never inside the script source. The title comes + * first so osascript stops option parsing before any command text that begins + * with a dash. + */ +const NOTIFICATION_SCRIPT = [ + 'on run argv', + 'display notification (item 3 of argv) with title (item 1 of argv) subtitle (item 2 of argv)', + 'end run', +]; + +export function osascriptArguments(notification: CommandNotification): string[] { + return [...NOTIFICATION_SCRIPT.flatMap(line => ['-e', line]), notification.title, notification.subtitle, notification.body]; +} + +const STDERR_LIMIT = 2048; +export const OSASCRIPT_TIMEOUT_MS = 10_000; + +/** + * An ordinary attached child: osascript lives for well under a second, so it + * is neither detached nor unref'd, and its exit status and stderr are kept. + */ +export class MacNotificationService implements NotificationService { + readonly supported = true; + + constructor(private readonly spawnProcess: SpawnFunction = spawn) {} + + notify(notification: CommandNotification): Promise { + return new Promise(resolve => { + let child: ChildProcess; + try { + child = this.spawnProcess(OSASCRIPT_PATH, osascriptArguments(notification), + {shell: false, stdio: ['ignore', 'ignore', 'pipe'], timeout: OSASCRIPT_TIMEOUT_MS}); + } catch (error) { + resolve({ok: false, reason: 'spawn', message: String(error)}); + return; + } + let stderr = ''; + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk: string) => { if (stderr.length < STDERR_LIMIT) stderr += chunk; }); + child.once('error', error => resolve({ok: false, reason: 'spawn', message: error.message})); + child.once('close', (code, signal) => { + if (code === 0) resolve({ok: true}); + else resolve({ok: false, reason: signal === 'SIGTERM' ? 'timeout' : 'exit', exitCode: code, + message: stderr.trim().slice(0, STDERR_LIMIT) || signal || undefined}); + }); + }); + } +} + +export class UnsupportedNotificationService implements NotificationService { + readonly supported = false; + notify(): Promise { + return Promise.resolve({ok: false, reason: 'unsupported'}); + } +} + +export function createNotificationService(platform: NodeJS.Platform = process.platform, spawnProcess?: SpawnFunction): NotificationService { + return platform === 'darwin' ? new MacNotificationService(spawnProcess) : new UnsupportedNotificationService(); +} diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index b0edafeb..0acd4ad9 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -4,6 +4,16 @@ export interface StyledCell { text: string; width: number; style: string; + /** Original program-emitted OSC 8, never generated presentation. */ + hyperlink?: string; +} + +const revisions = new WeakMap(); +export function lineRevision(line: StyledLine): number { return revisions.get(line) ?? 0; } + +export function validOsc8Payload(payload: string): boolean { + return payload.length <= 4096 && /^8;[^;]*;.+$/u.test(payload) + && !/[\u0000-\u001f\u007f-\u009f]/u.test(payload); } export type StyledLine = Array; @@ -16,6 +26,8 @@ export class AnsiOutputParser { private column = 0; private style = ''; private pending = ''; + private hyperlink?: string; + private discardingOsc = false; constructor(private readonly onClear?: () => void) {} @@ -23,6 +35,14 @@ export class AnsiOutputParser { const input = this.pending + chunk; this.pending = ''; let index = 0; + if (this.discardingOsc) { + const bell = input.indexOf('\u0007'); + const st = input.indexOf('\u001B\\'); + const end = bell < 0 ? st : st < 0 ? bell : Math.min(bell, st); + if (end < 0) { this.pending = input.endsWith('\u001B') ? '\u001B' : ''; return; } + index = end + (input[end] === '\u0007' ? 1 : 2); + this.discardingOsc = false; + } while (index < input.length) { const character = input[index] ?? ''; @@ -30,6 +50,11 @@ export class AnsiOutputParser { const parsed = this.consumeEscape(input, index); if (!parsed.complete) { this.pending = input.slice(index); + if (input[index + 1] === ']' && this.pending.length > 8192) { + this.discardingOsc = true; + this.hyperlink = undefined; + this.pending = input.endsWith('\u001B') ? '\u001B' : ''; + } break; } index = parsed.next; @@ -66,7 +91,7 @@ export class AnsiOutputParser { const width = stringWidth(value); if (width === 0) { const previous = this.findPreviousCell(); - if (previous) previous.text += value; + if (previous) { previous.text += value; this.touch(); } } else { this.put(value, width); } @@ -75,12 +100,14 @@ export class AnsiOutputParser { addLine(text: string, style = ''): void { this.ensureLineBoundary(); + this.hyperlink = undefined; this.style = style; this.write(text); this.lines.push(this.current); this.current = []; this.column = 0; this.style = ''; + this.hyperlink = undefined; } ensureLineBoundary(): void { @@ -97,11 +124,15 @@ export class AnsiOutputParser { const oldColumn = this.column; const oldStyle = this.style; const oldPending = this.pending; + const oldHyperlink = this.hyperlink; + const oldDiscardingOsc = this.discardingOsc; this.current = []; this.column = 0; this.style = ''; this.pending = ''; + this.discardingOsc = false; + this.hyperlink = undefined; this.write(text); if (this.pending.length > 0) { @@ -114,6 +145,8 @@ export class AnsiOutputParser { this.column = oldColumn; this.style = oldStyle; this.pending = oldPending; + this.hyperlink = oldHyperlink; + this.discardingOsc = oldDiscardingOsc; } completedCount(): number { @@ -160,6 +193,8 @@ export class AnsiOutputParser { this.column = 0; this.style = ''; this.pending = ''; + this.discardingOsc = false; + this.hyperlink = undefined; for (const line of lines) { const restored: StyledLine = line.map(cell => cell === null ? null : 'empty' in cell ? undefined : {...cell}); this.lines.push(restored); @@ -189,12 +224,23 @@ export class AnsiOutputParser { const stringTerminator = input.indexOf('\u001B\\', start + 2); const end = bell === -1 ? stringTerminator : stringTerminator === -1 ? bell : Math.min(bell, stringTerminator); if (end === -1) return {complete: false, next: start}; + const payload = input.slice(start + 2, end); + if (payload.startsWith('8;')) { + const separator = payload.indexOf(';', 2); + if (separator !== -1) { + const target = payload.slice(separator + 1); + // Preserve safe original payloads; reject controls and bound retained data. + this.hyperlink = target && validOsc8Payload(payload) + ? payload : undefined; + } else this.hyperlink = undefined; + } return {complete: true, next: end + (input[end] === '\u0007' ? 1 : 2)}; } return {complete: true, next: Math.min(input.length, start + 2)}; } private applyCsi(params: string, final: string): void { + this.touch(); const values = params.replace(/^\?/u, '').split(';').map(value => Number(value || '0')); const amount = values[0] || 1; if (final === 'm') { @@ -222,11 +268,14 @@ export class AnsiOutputParser { private put(text: string, width: number): void { for (let position = 0; position < width; position += 1) this.current[this.column + position] = undefined; - this.current[this.column] = {text, width, style: this.style}; + this.current[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink} : {})}; + this.touch(); for (let position = 1; position < width; position += 1) this.current[this.column + position] = null; this.column += width; } + private touch(): void { revisions.set(this.current, lineRevision(this.current) + 1); } + private findPreviousCell(): StyledCell | undefined { for (let index = Math.min(this.column - 1, this.current.length - 1); index >= 0; index -= 1) { const cell = this.current[index]; diff --git a/src/output/Hyperlinks.ts b/src/output/Hyperlinks.ts new file mode 100644 index 00000000..ebab9168 --- /dev/null +++ b/src/output/Hyperlinks.ts @@ -0,0 +1,120 @@ +import {existsSync, readFileSync, statSync} from 'node:fs'; +import {dirname, isAbsolute, join, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {lineRevision, type StyledLine} from './AnsiOutputParser.js'; + +const CONTROL = /[\u0000-\u0020\u007f-\u009f]/u; +const MAX_LINE = 8192; +const MAX_TARGETS = 16; + +/** Generated payloads only: never copy terminal controls into OSC strings. */ +export function safeHyperlinkTarget(target: string): string | undefined { + if (!target || target.length > 4096 || CONTROL.test(target)) return undefined; + try { + const url = new URL(target); + if (!['http:', 'https:', 'file:'].includes(url.protocol)) return undefined; + if (url.protocol !== 'file:' && (!url.hostname || url.username || url.password)) return undefined; + if (url.protocol === 'file:' && url.hostname) return undefined; + return url.href; + } catch { return undefined; } +} + +/** Only an explicit origin GitHub remote identifies shorthand. No session default. */ +export function githubRepository(cwd: string): string | undefined { + let directory = resolve(cwd); + for (let depth = 0; depth < 16; depth++) { + const dotgit = join(directory, '.git'); + try { + if (existsSync(dotgit)) { + let gitdir = dotgit; + if (statSync(dotgit).isFile()) { + if (statSync(dotgit).size > 4096) return undefined; + const pointer = /^gitdir: ([^\r\n]+)\s*$/u.exec(readFileSync(dotgit, 'utf8')); + if (!pointer) return undefined; + gitdir = resolve(directory, pointer[1]!); + const common = join(gitdir, 'commondir'); + if (existsSync(common) && statSync(common).size <= 4096) gitdir = resolve(gitdir, readFileSync(common, 'utf8').trim()); + } + const config = join(gitdir, 'config'); + if (statSync(config).size > 65536) return undefined; + const source = readFileSync(config, 'utf8'); + const origin = /^\[remote "origin"\]\s*\n([^[]*)/mu.exec(source)?.[1]; + const remote = origin && /^\s*url\s*=\s*(\S+)\s*$/mu.exec(origin)?.[1]; + const match = remote && /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(remote); + return match ? `https://github.com/${match[1]}` : undefined; + } + } catch { return undefined; } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return undefined; +} + +/** Bounded, presentation-only clones. Original parser cells are never decorated. */ +export class HyperlinkPresenter { + private readonly cache = new WeakMap(); + private readonly repositories = new Map(); + + line(source: StyledLine, cwd?: string): StyledLine { + const revision = lineRevision(source); + const cached = this.cache.get(source); + if (cached && cached.revision === revision && cached.cwd === cwd) return cached.line; + if (source.length > MAX_LINE) return source; + const plain = source.map(cell => cell === null ? '' : cell?.text ?? ' ').join(''); + if (plain.length > MAX_LINE) return source; + let repository: string | undefined; + if (cwd && /(?:^|\s)#[1-9]\d*\b/u.test(plain)) { + if (!this.repositories.has(cwd)) { + if (this.repositories.size >= 128) this.repositories.delete(this.repositories.keys().next().value!); + this.repositories.set(cwd, githubRepository(cwd)); + } + repository = this.repositories.get(cwd); + } + const spans: Array<{start: number; end: number; payload: string}> = []; + // Require whole whitespace-delimited paths or explicit punctuation-delimited URLs/refs. + const tokens = /(?:^|[\s([<])((?:https?:\/\/[^\s<>"']+)|(?:#[1-9]\d*)|(?:\.{1,2}\/[^\s<>"']+)|(?:\/[^\s<>"']+)|(?:[A-Za-z0-9_.-]+\/[^\s<>"']+)|(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_.-]+))(?=$|[\s)>.,:;!?])/gu; + let count = 0; + for (const match of plain.matchAll(tokens)) { + if (++count > MAX_TARGETS) break; + const raw = match[1]!; + let token = raw; + if (/^https?:/u.test(raw)) { + token = raw.replace(/[.,;!?]+$/u, ''); + while (token.endsWith(')') && (token.match(/\)/gu)?.length ?? 0) > (token.match(/\(/gu)?.length ?? 0)) token = token.slice(0, -1); + } + let target: string | undefined; + if (/^https?:\/\//u.test(token)) target = safeHyperlinkTarget(token); + else if (/^#[1-9]\d*$/u.test(token) && repository) target = `${repository}/issues/${token.slice(1)}`; + else if (cwd && !token.startsWith('#') && !CONTROL.test(token) && !token.includes(':')) { + const path = isAbsolute(token) ? token : resolve(cwd, token); + try { if (existsSync(path)) target = safeHyperlinkTarget(pathToFileURL(path).href); } catch { /* plain fallback */ } + } + if (target) { + const start = match.index! + match[0].indexOf(raw); + spans.push({start, end: start + token.length, payload: `8;;${target}`}); + } + } + // An existing program link owns its entire recognized token, even if the + // program linked only part of it. Never add a competing target around it. + let position = 0; + const originalRanges: Array<{start: number; end: number}> = []; + for (const cell of source) { + if (cell === null) continue; + const length = cell?.text.length ?? 1; + if (cell?.hyperlink) originalRanges.push({start: position, end: position + length}); + position += length; + } + const generated = spans.filter(span => !originalRanges.some(range => range.start < span.end && range.end > span.start)); + let offset = 0; + const line = source.map(cell => { + if (cell === null) return cell; + const length = cell?.text.length ?? 1; + const span = generated.find(candidate => offset >= candidate.start && offset + length <= candidate.end); + offset += length; + return cell && !cell.hyperlink && span ? {...cell, hyperlink: span.payload} : cell; + }); + this.cache.set(source, {revision, cwd, line}); + return line; + } +} diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 583d29ac..aedd4c57 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -1,3 +1,5 @@ +import {paintTreatment, DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; +import {HyperlinkPresenter} from './Hyperlinks.js'; import {type StyledLine} from './AnsiOutputParser.js'; import {wrapStyledLine, type WrappedRow} from './viewport.js'; import {background, foreground, UI_COLORS} from '../ui/palette.js'; @@ -50,7 +52,7 @@ export interface TranscriptView { lines: readonly StyledLine[]; completed: readonly CompletedCommand[]; /** The running command, when one is active. */ - active?: {activities: readonly SecondaryActivity[]}; + active?: {activities: readonly SecondaryActivity[]; start?: number; historicalContext?: HistoricalContextSnapshot}; visualGaps: ReadonlySet; lineTypes: ReadonlyMap; historicalContexts: ReadonlyMap; @@ -74,6 +76,22 @@ export interface RowInteraction { * frame) lives here and is never serialized. */ export class TranscriptPresenter { + private hyperlinks = false; + private readonly links = new HyperlinkPresenter(); + + setHyperlinks(enabled: boolean): void { this.hyperlinks = enabled; } + + private wrap(view: TranscriptView, index: number, width: number): WrappedRow[] { + const owner = view.ownerOf(index); + const context = owner === undefined ? undefined : view.historicalContexts.get(owner); + const line = view.lines[index] ?? []; + return wrapStyledLine(this.hyperlinks ? this.links.line(line, context?.cwd) : line, width, this.hyperlinks); + } + + private treatment = DEFAULT_TREATMENT_SETTINGS; + + setTreatment(settings: TreatmentSettings): void { this.treatment = {...settings, motion: 'static'}; } + private appearance: TranscriptAppearance = {...DEFAULT_TRANSCRIPT_APPEARANCE}; private welcomeFrame: WelcomeCatFrame = 'open'; private layout: TranscriptLayout = 'normal'; @@ -105,7 +123,7 @@ export class TranscriptPresenter { if (cached !== undefined) return cached; let widest = 0; for (let index = start; view.lineTypes.get(index) === 'command' && (ownerOf(index) ?? index) === start; index += 1) { - for (const row of wrapStyledLine(lines[index] ?? [], column!)) widest = Math.max(widest, displayWidth(row.plain)); + for (const row of this.wrap(view, index, column!)) widest = Math.max(widest, displayWidth(row.plain)); } blockWidths.set(start, widest); return widest; @@ -127,7 +145,7 @@ export class TranscriptPresenter { const historicalContext = historicalContexts.get(i); // Chat: the header (prompt snapshot + local divider) spans the command column on the right. - const rendered = historicalContext && renderHistoricalContext(historicalContext, column ?? width, this.appearance); + const rendered = historicalContext && renderHistoricalContext(historicalContext, column ?? width, this.appearance, this.treatment); const header = rendered && column ? indentRow(rendered, width - displayWidth(rendered.plain)) : rendered; if (header) { const owner = ownerOf(i); @@ -155,7 +173,7 @@ export class TranscriptPresenter { const {head, tail} = foldWindow(hiddenLines); const pushLines = (from: number, to: number) => { for (let line = from; line < to; line += 1) { - for (const row of wrapStyledLine(lines[line] ?? '', width)) result.push({...row, lineIndex: line, commandIndex}); + for (const row of this.wrap(view, line, width)) result.push({...row, lineIndex: line, commandIndex}); } }; pushLines(cmd.outputStartId, cmd.outputStartId + head); @@ -186,7 +204,7 @@ export class TranscriptPresenter { continue; } else { result.push(renderActivityRow(activity, width)); - if (activity.expanded) appendActivityOutput(result, lines, activity, width); + if (activity.expanded) appendActivityOutput(result, lines, activity, width, (index, columns) => this.wrap(view, index, columns)); skipUntil = Math.max(skipUntil, activity.outputEndId); continue; } @@ -195,8 +213,8 @@ export class TranscriptPresenter { const parentDisclosure = completed.find(command => command.activities?.length && command.endId === i); const chatCommand = column !== undefined && view.lineTypes.get(i) === 'command'; const wrappedRows = chatCommand - ? wrapStyledLine(lines[i], column!).map(row => indentRow(row, width - commandBlockWidth(i))) - : wrapStyledLine(lines[i], parentDisclosure ? Math.max(1, width - 2) : width); + ? this.wrap(view, i, column!).map(row => indentRow(row, width - commandBlockWidth(i))) + : this.wrap(view, i, parentDisclosure ? Math.max(1, width - 2) : width); const cmdIndex = completed.findIndex(c => c.startId <= i); if (parentDisclosure && wrappedRows.length > 0) { const finalRow = wrappedRows[wrappedRows.length - 1]; @@ -218,7 +236,7 @@ export class TranscriptPresenter { if (active) { for (const activity of active.activities) { result.push(renderActivityRow(activity, width)); - if (activity.expanded) appendActivityOutput(result, lines, activity, width); + if (activity.expanded) appendActivityOutput(result, lines, activity, width, (index, columns) => this.wrap(view, index, columns)); } } for (const row of result) { @@ -238,7 +256,7 @@ export class TranscriptPresenter { if (width <= 0 || view.lineTypes.get(startId) !== 'command') return undefined; const line = view.lines[startId]; if (!line) return undefined; - const ansi = wrapStyledLine(line, Number.MAX_SAFE_INTEGER)[0]?.ansi ?? ''; + const ansi = this.wrap(view, startId, Number.MAX_SAFE_INTEGER)[0]?.ansi ?? ''; const continues = view.lineTypes.get(startId + 1) === 'command' && view.ownerOf(startId + 1) === startId; if (continues && displayWidth(ansi) < width) return `${ansi}${foreground(UI_COLORS.secondary)}…\u001B[0m`; return truncateAnsi(ansi, width); @@ -326,9 +344,9 @@ function foldHint(summary: string, disclosure: string, width: number): string { return `${truncateText(summary, width - displayWidth(suffix))}${suffix}`; } -function appendActivityOutput(result: WrappedRow[], lines: readonly StyledLine[], activity: SecondaryActivity, width: number): void { +function appendActivityOutput(result: WrappedRow[], lines: readonly StyledLine[], activity: SecondaryActivity, width: number, wrap: (index: number, width: number) => WrappedRow[]): void { for (let lineIndex = activity.outputStartId; lineIndex < Math.min(activity.outputEndId, lines.length); lineIndex += 1) { - for (const row of wrapStyledLine(lines[lineIndex] ?? [], Math.max(1, width - 4))) { + for (const row of wrap(lineIndex, Math.max(1, width - 4))) { result.push({ ansi: ` ${row.ansi}`, plain: ` ${row.plain}`, @@ -447,12 +465,13 @@ function historicalPrompt(context: HistoricalContextSnapshot, width: number, app * raw PTY output are never modified. */ export function renderHistoricalContext(context: HistoricalContextSnapshot, width: number, - appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE): WrappedRow | undefined { + appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE, + treatment: TreatmentSettings = DEFAULT_TREATMENT_SETTINGS): WrappedRow | undefined { if (!appearance.divider && !appearance.historicalPrompt) return undefined; const divider = DIVIDER_STYLES[appearance.dividerDensity]; if (!appearance.historicalPrompt) { const line = repeatToWidth(divider.glyph, width); - return {ansi: `${divider.color}${line}\u001B[0m`, plain: line, isHistoricalHeader: true}; + return {ansi: `${treatment.preset === 'off' ? divider.color + line : paintTreatment(line, {...treatment, motion: 'static'}, 'divider', ARCHIVE_DIVIDER_COLOR)}\u001B[0m`, plain: line, isHistoricalHeader: true}; } const parts = historicalPrompt(context, Math.max(0, width - (appearance.divider ? 1 : 0)), appearance); const prompt = typeof parts === 'string' ? parts : parts.left; @@ -466,7 +485,7 @@ export function renderHistoricalContext(context: HistoricalContextSnapshot, widt const remaining = Math.max(0, width - displayWidth(prompt) - 1 - rightWidth); const fill = repeatToWidth(divider.glyph, remaining); const rightAnsi = right ? ` ${right}\u001B[0m` : ''; - return {ansi: `${prompt}\u001B[0m ${divider.color}${fill}\u001B[0m${rightAnsi}`, plain: `${stripAnsi(prompt)} ${fill}${right ? ` ${stripAnsi(right)}` : ''}`, isHistoricalHeader: true}; + return {ansi: `${prompt}\u001B[0m ${treatment.preset === 'off' ? divider.color + fill : paintTreatment(fill, {...treatment, motion: 'static'}, 'divider', ARCHIVE_DIVIDER_COLOR)}\u001B[0m${rightAnsi}`, plain: `${stripAnsi(prompt)} ${fill}${right ? ` ${stripAnsi(right)}` : ''}`, isHistoricalHeader: true}; } function rgbStyle(foregroundColor?: Rgb, backgroundColor?: Rgb): string { diff --git a/src/output/Welcome.ts b/src/output/Welcome.ts index 0a21db71..13243dcd 100644 --- a/src/output/Welcome.ts +++ b/src/output/Welcome.ts @@ -1,5 +1,8 @@ import {homedir} from 'node:os'; +import {colorLevel} from '../presentation/capabilities.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; import type {BuildIdentity} from '../buildInfo.js'; +import type {WelcomeProviderId} from '../prompt/configuration.js'; import {background, foreground, UI_COLORS} from '../ui/palette.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import type {WrappedRow} from './viewport.js'; @@ -19,7 +22,7 @@ export interface WelcomeSnapshot { cwd: string; shell: 'zsh'; /** External welcome captured once at session start; absent means Vespyr. */ - provider?: 'fastfetch' | 'neofetch'; + provider?: Exclude; /** SGR-only rows the external provider printed. */ captured?: string[]; } @@ -157,7 +160,7 @@ export function renderWelcome(snapshot: WelcomeSnapshot, width: number, frame: W const spacer = ' '.repeat(gutter); rows.push({plain: `${prefix.plain}${spacer}${text.plain}`, ansi: `${prefix.ansi}${spacer}${text.ansi}`}); } - const line = repeatToWidth('─', width); + const line = repeatToWidth(getCurrentGlyphMode() === 'safe' ? '-' : '─', width); rows.push({plain: line, ansi: `${foreground(DIVIDER)}${line}${RESET}`}); // All rows belong to ordinary scrollback; none have a PTY line index. return rows.filter(row => displayWidth(row.plain) <= width); @@ -170,7 +173,7 @@ export const MIN_CAPTURED_WELCOME_WIDTH = 24; function renderCapturedWelcome(captured: readonly string[], width: number): WrappedRow[] { if (width < MIN_CAPTURED_WELCOME_WIDTH) return []; const rows = captured.map(line => { - const ansi = truncateAnsi(line, width); + const ansi = truncateAnsi(colorLevel() === 'none' ? stripAnsi(line) : line, width); return {ansi: `${ansi}${RESET}`, plain: stripAnsi(ansi)}; }); const line = repeatToWidth('─', width); diff --git a/src/output/WelcomeProviders.ts b/src/output/WelcomeProviders.ts index 572df427..b48aa7fd 100644 --- a/src/output/WelcomeProviders.ts +++ b/src/output/WelcomeProviders.ts @@ -10,6 +10,10 @@ export const WELCOME_PROVIDERS: readonly ProviderDescriptor[] ...(process.platform === 'darwin' ? {install: {label: 'brew install fastfetch', command: 'brew', args: ['install', 'fastfetch']}} : {})}, {id: 'neofetch', family: 'welcome', label: 'Neofetch', kind: 'external', executable: 'neofetch', versionArgs: ['--version'], legacy: true, description: 'archived upstream; used only if already installed'}, + {id: 'macchina', family: 'welcome', label: 'Macchina', kind: 'external', executable: 'macchina', versionArgs: ['--version'], + description: 'system information fetcher in maintenance mode'}, + {id: 'zigfetch', family: 'welcome', label: 'Zigfetch', kind: 'external', executable: 'zigfetch', + description: 'minimal system information fetcher; uses your installed configuration'}, {id: 'none', family: 'welcome', label: 'None', kind: 'none', description: 'no startup welcome'}, ]; @@ -18,7 +22,7 @@ export function welcomeProvider(id: WelcomeProviderId): ProviderDescriptor = {fastfetch: ['--pipe', 'false'], neofetch: []}; +const CAPTURE_ARGS: Partial> = {fastfetch: ['--pipe', 'false']}; export const WELCOME_CAPTURE_TIMEOUT_MS = 2500; const MAX_CAPTURE_BYTES = 128 * 1024; const MAX_ROWS = 40; @@ -30,15 +34,17 @@ export type WelcomeCapture = {ok: true; lines: string[]} | {ok: false; reason: s * Runs the user's installed fetch tool once (argv, no stdin, timeout, * bounded output) and flattens what it printed into SGR-only rows. */ -export async function captureWelcome(id: 'fastfetch' | 'neofetch', cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { - const status = await detectProvider(welcomeProvider(id), env.PATH ?? ''); +export async function captureWelcome(id: Exclude, cwd: string, + env: NodeJS.ProcessEnv = process.env): Promise { + const descriptor = welcomeProvider(id); + const status = await detectProvider(descriptor, env.PATH ?? ''); if (status.state !== 'installed' || !status.binary) return {ok: false, reason: 'not installed'}; - // A plain `zsh -f` parent lets fetch tools report the shell NMSh fronts instead of node; - // the command stays argv (`"$0" "$@"`), and `exit` keeps zsh from exec-replacing itself. + // Run under plain zsh so fetch tools report NMSh's shell rather than node. + // The script is fixed and only forwards positional argv; provider data is never interpolated. const zsh = findExecutable('zsh', env.PATH ?? ''); const [binary, args] = zsh - ? [zsh, ['-f', '-c', '"$0" "$@"; exit $?', status.binary, ...CAPTURE_ARGS[id]]] - : [status.binary, CAPTURE_ARGS[id]]; + ? [zsh, ['-f', '-c', '"$0" "$@"; exit $?', status.binary, ...(CAPTURE_ARGS[id] ?? [])]] + : [status.binary, CAPTURE_ARGS[id] ?? []]; const result = await runExternal(binary, args, {timeoutMs: WELCOME_CAPTURE_TIMEOUT_MS, maxBytes: MAX_CAPTURE_BYTES, cwd, env: {...env, TERM: env.TERM ?? 'xterm-256color'}}); if (!result.ok) return {ok: false, reason: result.error ?? 'failed'}; diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 6a745bef..627661ca 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -1,4 +1,4 @@ -import type {StyledCell, StyledLine} from './AnsiOutputParser.js'; +import {validOsc8Payload, type StyledCell, type StyledLine} from './AnsiOutputParser.js'; const RESET = '\u001B[0m'; @@ -42,16 +42,18 @@ export function stickyHeaderFor(rows: WrappedRow[], viewStart: number): StickyHe return commandRowAbove ? {startId, targetIndex} : undefined; } -export function wrapStyledLine(line: StyledLine, width: number): WrappedRow[] { +export function wrapStyledLine(line: StyledLine, width: number, hyperlinks = false): WrappedRow[] { if (width <= 0) return []; const rows: WrappedRow[] = []; let ansi = ''; let plain = ''; let column = 0; let activeStyle = ''; + let activeLink: string | undefined; const flush = () => { - rows.push({ansi: `${ansi}${RESET}`, plain}); + rows.push({ansi: `${ansi}${activeLink ? '\u001B]8;;\u001B\\' : ''}${RESET}`, plain}); + activeLink = undefined; ansi = ''; plain = ''; column = 0; @@ -63,6 +65,12 @@ export function wrapStyledLine(line: StyledLine, width: number): WrappedRow[] { if (cell === null) continue; const actual: StyledCell = cell ?? {text: ' ', width: 1, style: ''}; if (column > 0 && column + actual.width > width) flush(); + const link = hyperlinks && actual.hyperlink && validOsc8Payload(actual.hyperlink) ? actual.hyperlink : undefined; + if (link !== activeLink) { + if (activeLink) ansi += '\u001B]8;;\u001B\\'; + if (link) ansi += `\u001B]${link}\u001B\\`; + activeLink = link; + } if (actual.style !== activeStyle) { ansi += `${RESET}${actual.style}`; activeStyle = actual.style; diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts new file mode 100644 index 00000000..478f26b4 --- /dev/null +++ b/src/pickers/Picker.ts @@ -0,0 +1,99 @@ +import {spawn} from 'node:child_process'; +import {mkdtemp, writeFile, mkdir, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {resolveCommand, type ProviderDescriptor} from '../providers/providers.js'; + +export type PickerProviderId = 'native' | 'fzf' | 'television'; +export interface PickerCandidate { id: string; label: string; description?: string; value: string } +export type PickerResult = {kind: 'selected'; candidate: PickerCandidate} | {kind: 'cancelled'} | {kind: 'fallback'; reason: string}; +/** The host owns terminal release/restoration; a picker owns only selection. */ +export type PickerHandoff = (run: (signal: AbortSignal) => Promise) => Promise; +export const PICKER_PROVIDERS: readonly ProviderDescriptor[] = [ + {id: 'native', family: 'picker', label: 'NMSh Native', kind: 'native', description: 'search in the composer; select without executing'}, + {id: 'fzf', family: 'picker', label: 'fzf', kind: 'external', executable: 'fzf', versionArgs: ['--version'], description: 'optional terminal fuzzy picker'}, + {id: 'television', family: 'picker', label: 'Television', kind: 'external', executable: 'tv', versionArgs: ['--version'], description: 'optional terminal fuzzy picker'}, +]; +const plain = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); + +/** IDs on the wire are ordinal tokens, never commands or arbitrary tool output. */ +export function pickerInput(candidates: readonly PickerCandidate[]): string { + return candidates.map((candidate, index) => `${index}\t${plain(candidate.label)}${candidate.description ? ` · ${plain(candidate.description)}` : ''}\n`).join(''); +} +export function pickerSelection(output: string, candidates: readonly PickerCandidate[]): PickerResult { + const lines = output.trimEnd().split('\n'); + if (lines.length !== 1) return {kind: 'fallback', reason: 'Picker returned an invalid selection'}; + const line = lines[0]!; + const match = /^(0|[1-9]\d*)\t/u.exec(line); + const candidate = match ? candidates[Number(match[1])] : undefined; + if (!candidate || pickerInput([candidate]).replace(/^0/u, match![1]!).trimEnd() !== line) + return {kind: 'fallback', reason: 'Picker returned an unknown selection'}; + return {kind: 'selected', candidate}; +} + +/** Native surfaces delegate their existing editor UI through the same boundary. */ +export async function openPicker(provider: PickerProviderId, candidates: readonly PickerCandidate[], native: () => void, + handoff: PickerHandoff, env: NodeJS.ProcessEnv = process.env): Promise { + if (provider === 'native') { native(); return; } + const binary = resolveCommand(provider === 'fzf' ? 'fzf' : 'tv', env.PATH ?? '', []); + if (!binary) { native(); return {kind: 'fallback', reason: `${provider} is not installed; using Native`}; } + const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env)); + if (result.kind === 'fallback') native(); + return result; +} + +/** Interactive, bounded, host-TTY process; only call while the host has handed off ownership. */ +export async function runPicker(binary: string, provider: Exclude, + candidates: readonly PickerCandidate[], signal: AbortSignal, env: NodeJS.ProcessEnv = process.env, + timeoutMs = 300_000): Promise { + if (signal.aborted) return {kind: 'cancelled'}; + const input = pickerInput(candidates); + if (candidates.length > 100_000 || Buffer.byteLength(input) > 16 * 1024 * 1024) + return {kind: 'fallback', reason: 'Picker input exceeds the bounded limit; using Native'}; + const directory = await mkdtemp(join(tmpdir(), 'nmsh-picker-')); + try { + const environment = {...env}; + for (const key of Object.keys(environment)) if (/^(?:FZF_|TV_)/u.test(key)) delete environment[key]; + let args: string[]; + if (provider === 'fzf') args = ['--no-multi', '--no-sort', '--delimiter=\t', '--with-nth=2..', '--layout=reverse', '--no-mouse', '--pointer=>', '--marker=*']; + else { + // No user cable, hooks, preview command or persisted history is loaded. + await writeFile(join(directory, 'config.toml'), 'history_size = 0\n', {mode: 0o600}); + await mkdir(join(directory, 'cable')); + environment.XDG_CONFIG_HOME = directory; + environment.XDG_DATA_HOME = directory; + environment.XDG_CACHE_HOME = directory; + args = ['--config-file', join(directory, 'config.toml'), '--cable-dir', join(directory, 'cable'), '--no-preview', '--no-remote', '--keybindings', 'tab="select_next_entry";backtab="select_prev_entry"']; + } + if (provider === 'fzf' && env.NO_COLOR !== undefined) args.push('--color=bw'); + if (signal.aborted) return {kind: 'cancelled'}; + return await new Promise(resolve => { + const child = spawn(binary, args, {env: environment, stdio: ['pipe', 'pipe', 'inherit']}); + const chunks: Buffer[] = []; + let bytes = 0; + let failure: string | undefined; + let aborted = false; + let settled = false; + const abort = () => { aborted = true; child.kill('SIGKILL'); }; + const timer = setTimeout(() => { failure = 'Picker timed out; using Native'; child.kill('SIGKILL'); }, timeoutMs); + signal.addEventListener('abort', abort, {once: true}); + if (signal.aborted) abort(); + const finish = (result: PickerResult) => { + if (settled) return; + settled = true; clearTimeout(timer); signal.removeEventListener('abort', abort); resolve(result); + }; + child.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > 64 * 1024) { failure = 'Picker output exceeds the bounded limit; using Native'; child.kill('SIGKILL'); } + else chunks.push(chunk); + }); + child.stdin.on('error', () => { /* Early cancel may close stdin before candidates finish writing. */ }); + child.on('error', error => finish({kind: 'fallback', reason: `Picker failed: ${error.message}; using Native`})); + child.on('close', code => finish(aborted || code === 1 || code === 130 ? {kind: 'cancelled'} : failure + ? {kind: 'fallback', reason: failure} : code === 0 ? pickerSelection(Buffer.concat(chunks).toString('utf8'), candidates) + : {kind: 'fallback', reason: `Picker exited with ${code}; using Native`})); + child.stdin.end(input); + }); + } catch (error) { return {kind: 'fallback', reason: `Picker failed: ${String(error)}; using Native`}; } + finally { await rm(directory, {recursive: true, force: true}); } +} diff --git a/src/presentation/capabilities.ts b/src/presentation/capabilities.ts index 11647d51..d0deab47 100644 --- a/src/presentation/capabilities.ts +++ b/src/presentation/capabilities.ts @@ -1,16 +1,16 @@ -/** - * Terminal color capability for NMSh-owned UI. Only explicit signals lower the - * level; without one NMSh keeps its truecolor behavior. Raw PTY output is never - * affected: this applies to colors NMSh itself emits. - */ -export type ColorLevel = 'none' | 'ansi256' | 'truecolor'; +import {resolveHostCapabilities} from '../host/capabilities.js'; + +/** NMSh-owned colors; explicit settings win over host defaults. PTY SGR is untouched. */ +export type ColorLevel = 'none' | 'ansi16' | 'ansi256' | 'truecolor'; export function colorLevel(env: NodeJS.ProcessEnv = process.env): ColorLevel { const override = env.NMSH_COLOR?.toLowerCase(); if (override === '0' || override === 'none' || override === 'off') return 'none'; + if (override === '16') return 'ansi16'; if (override === '256') return 'ansi256'; if (override === 'truecolor') return 'truecolor'; if (env.NO_COLOR) return 'none'; if (env.TERM === 'dumb') return 'none'; - return 'truecolor'; + if (resolveHostCapabilities(env).truecolor) return 'truecolor'; + return /256color/u.test(env.TERM ?? '') ? 'ansi256' : 'ansi16'; } diff --git a/src/prompt/StarshipConfigAdapter.ts b/src/prompt/StarshipConfigAdapter.ts index 29f24349..fbe76884 100644 --- a/src/prompt/StarshipConfigAdapter.ts +++ b/src/prompt/StarshipConfigAdapter.ts @@ -21,18 +21,25 @@ export interface StarshipConfigProposal { diff: string[]; } -function changedLines(before: string, after: string): string[] { - const oldLines = before.split('\n'); - const newLines = after.split('\n'); - let start = 0; - while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) start++; - let oldEnd = oldLines.length; - let newEnd = newLines.length; - while (oldEnd > start && newEnd > start && oldLines[oldEnd - 1] === newLines[newEnd - 1]) { oldEnd--; newEnd--; } - const removed = oldLines.slice(start, oldEnd).map(line => `- ${line}`); - const added = newLines.slice(start, newEnd).map(line => `+ ${line}`); - if (removed.length + added.length > 40) throw new Error('Starship proposed a broad config rewrite; edit it manually instead.'); - return [...removed, ...added]; +const prepared = new WeakSet(); + +function validateModule(module: StarshipModule): void { + if (!STARSHIP_MODULES.includes(module)) throw new Error('Unsupported Starship module.'); +} + +/** Refuse native CLI rewrites outside the selected supported field. */ +function unsupportedContent(text: string, module: StarshipModule): string { + // A line scanner cannot safely interpret multiline TOML strings. + if (text.includes('"""') || text.includes("'''")) throw new Error('Multiline Starship config requires manual editing.'); + let target = false; + return text.split(/\r?\n/u).filter(line => { + const section = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/u.exec(line); + if (section) { + target = section[1] === module; + if (target) return false; + } + return !(target && /^\s*disabled\s*=\s*(true|false)\s*(?:#.*)?$/u.test(line)) && line.trim() !== ''; + }).join('\n'); } /** Narrow adapter around Starship's own CLI, never a generic TOML editor. */ @@ -56,12 +63,18 @@ export class StarshipConfigAdapter { } private async cli(args: string[], path: string): Promise { - const {stdout} = await this.run(this.status.binary!, args, {timeout: 5000, maxBuffer: 1024 * 1024, - env: {...process.env, STARSHIP_CONFIG: path}}); - return stdout; + try { + const {stdout} = await this.run(this.status.binary!, args, {timeout: 5000, maxBuffer: 1024 * 1024, + env: {...process.env, STARSHIP_CONFIG: path}}); + return stdout; + } catch { + // execFile errors can contain stderr, including unrelated user config. + throw new Error('Starship configuration command failed or timed out.'); + } } async disabled(module: StarshipModule): Promise { + validateModule(module); const output = await this.cli(['print-config', `${module}.disabled`], this.status.configPath); const match = /^disabled\s*=\s*(true|false)\s*$/mu.exec(output); if (!match) throw new Error(`Could not read Starship ${module} status.`); @@ -69,6 +82,8 @@ export class StarshipConfigAdapter { } async propose(module: StarshipModule, disabled: boolean): Promise { + validateModule(module); + if (typeof disabled !== 'boolean') throw new Error('Starship disabled value must be boolean.'); const source = await this.readOriginal(); const original = source.text; const temporaryDirectory = await mkdtemp(join(tmpdir(), 'nmsh-starship-config-')); @@ -78,12 +93,17 @@ export class StarshipConfigAdapter { try { await handle.writeFile(original, 'utf8'); } finally { await handle.close(); } await this.cli(['config', `${module}.disabled`, String(disabled)], staged); const proposed = await readFile(staged, 'utf8'); + if (unsupportedContent(original, module) !== unsupportedContent(proposed, module)) { + throw new Error('Starship proposed changes outside the supported field; edit it manually.'); + } const effective = await this.cli(['print-config', `${module}.disabled`], staged); if (!new RegExp(`^disabled\\s*=\\s*${disabled}\\s*$`, 'mu').test(effective)) { throw new Error('Starship did not accept the proposed module value.'); } - return {path: this.status.configPath, module, disabled, original, existed: source.exists, proposed, - diff: changedLines(original, proposed)}; + const proposal: StarshipConfigProposal = {path: this.status.configPath, module, disabled, original, existed: source.exists, proposed, + diff: [`+ ${module}.disabled = ${disabled}`]}; + prepared.add(proposal); + return Object.freeze(proposal); } finally { await rm(temporaryDirectory, {recursive: true, force: true}); } @@ -91,6 +111,7 @@ export class StarshipConfigAdapter { /** Recheck user edits, back up existing config, then atomically install the reviewed bytes. */ async apply(proposal: StarshipConfigProposal): Promise { + if (!prepared.has(proposal)) throw new Error('Review a prepared Starship proposal first.'); if (proposal.path !== this.status.configPath) throw new Error('Starship config path changed.'); const current = await this.readOriginal(); if (current.exists !== proposal.existed || current.text !== proposal.original) { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index a29cb6d8..b4728d92 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -1,3 +1,4 @@ +import {normalizeTreatmentSettings, DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; import {dirname} from 'node:path'; import {promptConfigurationPath} from '../configuration/paths.js'; @@ -8,6 +9,8 @@ export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; +import type {NavigationProviderId} from '../shell/DirectoryService.js'; +import type {PickerProviderId} from '../pickers/Picker.js'; import type {HistoryProviderId} from '../shell/historyProviders.js'; import {SUGGESTION_PROVIDER_IDS, type SuggestionProviderId} from '../suggestions/types.js'; import { @@ -23,8 +26,8 @@ import { type PromptStyle, } from './powerline.js'; -export type WelcomeProviderId = 'vespyr' | 'fastfetch' | 'neofetch' | 'none'; -export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'none']; +export type WelcomeProviderId = 'vespyr' | 'fastfetch' | 'neofetch' | 'macchina' | 'zigfetch' | 'none'; +export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'macchina', 'zigfetch', 'none']; export type ContextPlacement = 'header' | 'composer'; export type ComposerLayout = 'oneLine' | 'twoLine'; @@ -166,9 +169,50 @@ export function normalizeSyntaxAppearance(value: unknown): SyntaxAppearance { }; } +export type NotificationFocusPolicy = 'suppress' | 'notify'; + +/** Command-completion notifications; read at completion time, never snapshotted at start. */ +export interface NotificationSettings { + enabled: boolean; + /** Minimum elapsed command time, in seconds, before a completion notifies. */ + thresholdSeconds: number; + onSuccess: boolean; + onFailure: boolean; + /** Suppress: a definitely-focused terminal notifies nothing. Notify: focus is ignored. */ + whenFocused: NotificationFocusPolicy; +} + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { + enabled: true, + thresholdSeconds: 60, + onSuccess: true, + onFailure: true, + whenFocused: 'suppress', +}; + +/** One day; longer thresholds are almost certainly a typo. */ +export const MAX_NOTIFICATION_THRESHOLD_SECONDS = 86_400; + +export function normalizeNotificationSettings(value: unknown): NotificationSettings { + if (!isRecord(value)) return {...DEFAULT_NOTIFICATION_SETTINGS}; + const threshold = value.thresholdSeconds; + return { + enabled: typeof value.enabled === 'boolean' ? value.enabled : true, + thresholdSeconds: typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1 + ? Math.min(MAX_NOTIFICATION_THRESHOLD_SECONDS, Math.round(threshold)) + : DEFAULT_NOTIFICATION_SETTINGS.thresholdSeconds, + onSuccess: typeof value.onSuccess === 'boolean' ? value.onSuccess : true, + onFailure: typeof value.onFailure === 'boolean' ? value.onFailure : true, + whenFocused: value.whenFocused === 'notify' ? 'notify' : 'suppress', + }; +} + export interface PromptConfiguration { + presentation: TreatmentSettings; provider: PromptProviderId; onboardingComplete: boolean; + /** Optional discovery is separate; legacy completed onboarding stays completed. */ + toolsSetupComplete: boolean; /** Missing in v0.3 configs; normalize to nerd to preserve their appearance. */ glyphStyle: GlyphStyle; glyphChoiceComplete: boolean; @@ -188,6 +232,8 @@ export interface PromptConfiguration { suggestions: SuggestionProviderId; /** Native default; Atuin is an explicit local read-only source. */ history: HistoryProviderId; + picker: PickerProviderId; + navigation: NavigationProviderId; /** Predict a whole command on an empty prompt from the previous one. */ suggestionsOnEmpty: boolean; nmsh: { @@ -213,6 +259,7 @@ export interface PromptConfiguration { starship: {configPath: string | null}; /** Optional overrides; null uses detection and the default ~/.p10k.zsh. Never written to. */ powerlevel10k: {themePath: string | null; configPath: string | null}; + notifications: NotificationSettings; transcript: TranscriptAppearance; syntax: SyntaxAppearance; placement: ContextPlacement; @@ -229,18 +276,23 @@ export interface PromptConfiguration { } export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { + presentation: {...DEFAULT_TREATMENT_SETTINGS, customStops: []}, provider: 'nmsh', onboardingComplete: false, + toolsSetupComplete: false, glyphStyle: 'nerd', glyphChoiceComplete: false, sessionRetention: 1000, updateChecks: 'off', liveSessionStartup: 'ask', liveSessionMultiple: 'ask', + notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', welcome: 'vespyr', suggestions: 'nmsh', history: 'native', + picker: 'native', + navigation: 'native', suggestionsOnEmpty: false, nmsh: {gapEnabled: true, startStyle: 'wedge', connector: 'wedge', endStyle: 'fadeWedge', palette: 'lavender', icons: 'nerd', style: 'powerline', connectorFade: 'off', connectorFadeColors: 'previous', gitEnabled: true, gitColors: 'semantic', gitGeometry: 'follow', gitConnectorFade: 'followMain', @@ -286,10 +338,12 @@ function validSeparator(value: unknown): value is string { export function normalizePromptConfiguration(value: unknown): PromptConfiguration { if (!isRecord(value)) return structuredClone(DEFAULT_PROMPT_CONFIGURATION); + const presentation = normalizeTreatmentSettings(value.presentation); const promptValue = isRecord(value.prompt) ? value.prompt : value; const glyphStyle: GlyphStyle = value.glyphStyle === 'safe' ? 'safe' : 'nerd'; // Existing configured installations keep their v0.3 appearance without a new wizard. const glyphChoiceComplete = value.glyphChoiceComplete === true || value.onboardingComplete === true; + const toolsSetupComplete = typeof value.toolsSetupComplete === 'boolean' ? value.toolsSetupComplete : value.onboardingComplete === true; const sessionRetention: SessionRetention = value.sessionRetention === null ? null : [100, 500, 1000, 5000].includes(value.sessionRetention as number) ? value.sessionRetention as SessionRetention : 1000; @@ -305,6 +359,8 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) ? value.suggestions as SuggestionProviderId : 'nmsh'; + const navigation: NavigationProviderId = value.navigation === 'zoxide' ? 'zoxide' : 'native'; + const picker: PickerProviderId = value.picker === 'fzf' || value.picker === 'television' ? value.picker : 'native'; const history: HistoryProviderId = value.history === 'atuin' ? 'atuin' : 'native'; const suggestionsOnEmpty = value.suggestionsOnEmpty === true; const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' @@ -323,6 +379,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const palette = normalizePaletteId(nativeValue.palette); const transcript = normalizeTranscriptAppearance(promptValue.transcript); const syntax = normalizeSyntaxAppearance(promptValue.syntax); + const notifications = normalizeNotificationSettings(value.notifications); const nmsh = {gapEnabled: typeof nativeValue.gapEnabled === 'boolean' ? nativeValue.gapEnabled : true, startStyle, connector, endStyle, palette, icons, style, connectorFade: normalizeConnectorFade(nativeValue.connectorFade), @@ -352,8 +409,8 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, - nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; + toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; } const modules: ContextModuleConfig[] = []; @@ -393,7 +450,9 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio modules.splice(before === -1 ? modules.length : before, 0, {...fallback}); }); - return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, + return {provider, onboardingComplete: value.onboardingComplete === true, + toolsSetupComplete, + glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; } @@ -405,11 +464,68 @@ export function loadPromptConfiguration(path = promptConfigurationPath()): Promp } } -export function savePromptConfiguration(configuration: PromptConfiguration, path = promptConfigurationPath()): void { +/** Merge only along the bounded normalized schema; unknown declarative fields survive edits. */ +function preserveConfiguration(existing: unknown, normalized: unknown): unknown { + if (!isRecord(existing) || !isRecord(normalized)) return normalized; + return Object.fromEntries(Object.entries({...existing, ...normalized}).map(([key, value]) => + [key, key in normalized ? preserveConfiguration(existing[key], value) : value])); +} + +/** Raised when an existing config cannot be safely read; the file is left untouched. */ +export class ConfigurationUnreadableError extends Error { + constructor(readonly path: string, reason: string) { + super(`Settings were not saved: ${path} ${reason}. The file was left unchanged; fix or move it, then try again.`); + this.name = 'ConfigurationUnreadableError'; + } +} + +/** Absent files yield undefined; anything present but unusable throws instead of being replaced. */ +function readExistingConfiguration(path: string): Record | undefined { + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new ConfigurationUnreadableError(path, `could not be read (${(error as NodeJS.ErrnoException).code ?? 'unknown error'})`); + } + let parsed: unknown; + try { parsed = JSON.parse(text) as unknown; } catch { throw new ConfigurationUnreadableError(path, 'is not valid JSON'); } + if (!isRecord(parsed)) throw new ConfigurationUnreadableError(path, 'is not a JSON object'); + // Flatten the legacy prompt wrapper so it cannot shadow newly saved values on reload. + if (isRecord(parsed.prompt)) { + const {prompt, ...root} = parsed; + return {...prompt as Record, ...root}; + } + return parsed; +} + +/** Apply only the leaves that differ between base and next onto the fresh on-disk state. */ +function applyChanges(fresh: unknown, base: unknown, next: unknown): unknown { + if (!isRecord(next)) return JSON.stringify(base) === JSON.stringify(next) && fresh !== undefined ? fresh : next; + const target: Record = isRecord(fresh) ? {...fresh} : {}; + const baseRecord = isRecord(base) ? base : {}; + for (const [key, value] of Object.entries(next)) { + const changed = !(key in baseRecord) || JSON.stringify(baseRecord[key]) !== JSON.stringify(value); + // Unchanged settings keep whatever is on disk (another frontend may have changed them); absent ones are filled in. + if (changed || !(key in target) || isRecord(value)) target[key] = isRecord(value) ? applyChanges(target[key], baseRecord[key], value) : value; + } + return target; +} + +/** + * Persist the configuration atomically. With `base` (the state this frontend last loaded or saved), + * only changed settings are written over a fresh read, so another frontend's unrelated edits survive. + * An existing file that cannot be read or parsed is never replaced. + */ +export function savePromptConfiguration(configuration: PromptConfiguration, path = promptConfigurationPath(), base?: PromptConfiguration): void { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); const normalized = normalizePromptConfiguration(configuration); + const existing = readExistingConfiguration(path); + const persisted = base + ? applyChanges(existing ?? {}, normalizePromptConfiguration(base), normalized) + : preserveConfiguration(existing, normalized); const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + writeFileSync(temporary, `${JSON.stringify(persisted, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); renameSync(temporary, path); } diff --git a/src/prompt/powerline.ts b/src/prompt/powerline.ts index ad299e66..1e21a825 100644 --- a/src/prompt/powerline.ts +++ b/src/prompt/powerline.ts @@ -1,3 +1,4 @@ +import {paintTreatment, type TreatmentSettings} from '../chroma/treatment.js'; import {background, foreground, type RgbColor} from '../ui/palette.js'; import {getCurrentGlyphMode, GLYPHS, powerlineShapeGlyphs, type PowerlineShape} from '../ui/glyphs.js'; import {fadePromptColor} from './snapshot.js'; @@ -40,6 +41,7 @@ export function normalizePromptStyle(value: unknown): PromptStyle { } export interface PowerlineBlock { + treatment?: TreatmentSettings; /** Visual style; every block of one prompt carries the same one. Missing means Powerline. */ style?: PromptStyle; text: string; @@ -445,7 +447,8 @@ function renderTextStyle(modules: readonly PowerlineBlock[], style: 'minimal' | const separator = ' '.repeat(style === 'minimal' ? Math.max(2, gap + 1) : Math.max(1, gap)); const parts = modules.map(block => { const tone = foreground(textTone(block.background)); - const text = block.compact ? (safe ? '*' : '●') : block.text; + const plain = block.compact ? (safe ? '*' : '●') : block.text; + const text = block.treatment ? paintTreatment(plain, block.treatment, 'native-identity', textTone(block.background)) : plain; return style === 'minimal' ? `${tone}${text}` : `${tone}${open}${pad}${text}${pad}${close}`; diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index a8d2165c..310ad2f7 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -1,3 +1,4 @@ +import type {TreatmentSettings} from '../chroma/treatment.js'; import {displayWidth, repeatToWidth, stripAnsi} from '../util/text.js'; import type {PromptContext, ToolchainId} from '../shell/ShellContext.js'; import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; @@ -30,6 +31,7 @@ function safePromptText(value: string): string { } interface RenderedModule { + treatment?: TreatmentSettings; id: ContextModuleConfig['id']; role: PromptRole; style?: PromptStyle; @@ -303,6 +305,8 @@ export function renderedModules(context: PromptContext, configuration: PromptCon const custom = !isGitStateRole(segment.role); return { ...(configuration.nmsh.style !== 'powerline' ? {style: configuration.nmsh.style} : {}), + ...(configuration.provider === 'nmsh' && ['project', 'cwd', 'toolchain'].includes(segment.role) && !segment.module.foreground + ? {treatment: configuration.presentation} : {}), id: segment.module.id, role: segment.role, text: segment.text, diff --git a/src/providers/providers.ts b/src/providers/providers.ts index edd218dd..1dce05e7 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -7,7 +7,7 @@ import {delimiter, join} from 'node:path'; * runtime interface (prompt render, welcome render, suggestion query, ...); * this module only describes providers and how their availability looks. */ -export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'tool'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { @@ -99,7 +99,7 @@ export interface ExternalResult { * host TTY) so a timeout kills everything it started. Never throws. */ export function runExternal(binary: string, args: readonly string[], options: {timeoutMs?: number; maxBytes?: number; - env?: NodeJS.ProcessEnv; cwd?: string; signal?: AbortSignal} = {}): Promise { + env?: NodeJS.ProcessEnv; cwd?: string; signal?: AbortSignal; terminationGraceMs?: number} = {}): Promise { if (options.signal?.aborted) return Promise.resolve({ok: false, stdout: '', error: 'cancelled'}); const maxBytes = options.maxBytes ?? 256 * 1024; return new Promise(resolve => { @@ -119,6 +119,23 @@ export function runExternal(binary: string, args: readonly string[], options: {t clearTimeout(timer); options.signal?.removeEventListener('abort', abort); if (child.exitCode === null && child.signalCode === null && child.pid) { + // PTY-owning helpers need their EXIT trap to reap a separate inner group. + // Keep this opt-in and bounded; ordinary providers retain immediate kill. + if (options.terminationGraceMs) { + const pid = child.pid; + const deadline = setTimeout(() => { + try { process.kill(-pid, 'SIGKILL'); } catch { /* Already gone. */ } + resolve(result); + }, Math.min(100, Math.max(1, options.terminationGraceMs))); + child.once('close', () => { + clearTimeout(deadline); + // A closed parent does not prove TERM-ignoring descendants exited. + try { process.kill(-pid, 'SIGKILL'); } catch { /* Group already gone. */ } + resolve(result); + }); + try { process.kill(-pid, 'SIGTERM'); } catch { clearTimeout(deadline); resolve(result); } + return; + } try { process.kill(-child.pid, 'SIGKILL'); } catch { /* Already gone. */ } } resolve(result); diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index 4e5518cc..b3ae77b0 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -15,6 +15,8 @@ export class InProcessSessionClient extends EventEmitter im this.shell.on('data', data => this.emit('data', data, NO_STAMP)); this.shell.on('prompt', marker => this.emit('prompt', marker, NO_STAMP)); this.shell.on('exec', (command, historyAllowed) => this.emit('exec', command, historyAllowed === undefined ? NO_STAMP : {historyAllowed})); + this.shell.on('startup', tail => this.emit('startup', tail)); + this.shell.on('inputRejected', (data, submission) => this.emit('inputRejected', data, submission)); this.shell.on('exit', event => this.emit('exit', event)); } diff --git a/src/session/PresetPanel.ts b/src/session/PresetPanel.ts new file mode 100644 index 00000000..705db01b --- /dev/null +++ b/src/session/PresetPanel.ts @@ -0,0 +1,103 @@ +import type {Key} from '../terminal/keys.js'; +import {createConfirm, handleConfirmKey, renderConfirm, editText, type ConfirmState} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {presetCommands, presetNeedsAcknowledgement, type SessionPreset} from './SessionPresets.js'; + +export interface PresetPanel { + presets: SessionPreset[]; selected: number; detail?: SessionPreset; + form?: {name: string; cwd: string; commands: string; field: number}; + confirm?: ConfirmState; operation?: 'launch' | 'delete'; scroll: number; message?: string; +} +export type PresetAction = 'close' | 'create' | 'delete' | 'launch'; +export function createPresetPanel(presets: SessionPreset[]): PresetPanel { return {presets, selected:0, scroll:0}; } +export function presetPanelKey(state: PresetPanel, key: Key, cwd: string): PresetAction | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') { + if (state.confirm) { state.confirm = undefined; state.operation = undefined; } + else if (state.form) state.form = undefined; + else if (state.detail) { state.detail = undefined; state.scroll = 0; } + else return 'close'; + return; + } + if (state.confirm) { + if (key.kind === 'pageUp' || key.kind === 'pageDown') { state.scroll = Math.max(0,state.scroll + (key.kind === 'pageUp' ? -5 : 5)); return; } + const decision = handleConfirmKey(key,state.confirm); + if (decision === 'cancel') { state.confirm = undefined; state.operation = undefined; } + if (decision === 'confirm') { const action = state.operation; state.confirm = undefined; state.operation = undefined; return action; } + return; + } + if (state.form) { + if (key.kind === 'complete') state.form.field = (state.form.field + 1) % 3; + else if (key.kind === 'enter') return 'create'; + else { + const field = (['name','cwd','commands'] as const)[state.form.field]!; + if (key.kind === 'newline' && field === 'commands') state.form.commands += '\n'; + else { const value = editText(state.form[field],key); if (value !== undefined) state.form[field] = value; } + } + return; + } + if (state.detail) { + if (key.kind === 'up' || key.kind === 'down' || key.kind === 'pageUp' || key.kind === 'pageDown') state.scroll = Math.max(0,state.scroll + (key.kind === 'up' || key.kind === 'pageUp' ? -1 : 1)); + if (key.kind === 'text') { + if (key.value.toLowerCase() === 'l') { + state.operation = 'launch'; state.scroll = 0; + if (presetNeedsAcknowledgement(state.detail)) state.confirm = createConfirm(); + else { state.operation = undefined; return 'launch'; } + } else if (key.value.toLowerCase() === 'd') { state.operation = 'delete'; state.confirm = createConfirm(); } + } + return; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'n') { state.form = {name:'',cwd,commands:'',field:0}; state.message = undefined; } + else if (key.kind === 'up' || key.kind === 'down') state.selected = Math.max(0,Math.min(state.presets.length-1,state.selected + (key.kind === 'up' ? -1 : 1))); + else if (key.kind === 'enter') { state.detail = state.presets[state.selected]; state.scroll = 0; state.message = undefined; } +} + +/** Wrap every command character so inspection/acknowledgement can scroll without elision. */ +export function presetReviewRows(preset: SessionPreset, width: number): string[] { + const rows: string[] = []; + for (const command of presetCommands(preset)) { + for (const line of command.split('\n')) { + let row = ''; + for (const char of line.replace(/\t/gu,' ')) { + if (row && displayWidth(row+char) > Math.max(2,width)) { rows.push(row); row = ''; } + row += char; + } + rows.push(row); + } + rows.push(''); + } + return rows; +} + +export function renderPresetPanel(state: PresetPanel, columns: number, height: number): string[] { + state.selected = Math.max(0,Math.min(state.selected,state.presets.length-1)); + const header = [' Session presets — create a new live real-zsh session']; + const body: string[] = [], footer: string[] = []; + if (state.form) { + header.push(' Explicit startup commands only; never put secrets here.'); + body.push(...(['name','cwd','commands'] as const).map((field,i)=>` ${state.form!.field === i ? '>' : ' '} ${field}: ${state.form![field].replace(/\n/gu, ' | ') || '_'}`)); + footer.push(' Tab fields; Ctrl+J adds command line; Enter create; Esc cancel', ' Commands: one shell command per line. Environment values are not captured.'); + } else if (state.detail) { + header.push(` ${state.detail.name} / ${state.detail.cwd}`); + if (state.operation === 'delete') { + body.push(' Delete this preset? Live sessions are unaffected.'); + } else { + header.push(state.operation === 'launch' ? ' Review startup commands before launching:' : ' Stored startup commands (read-only):'); + body.push(...presetReviewRows(state.detail,columns-4).map(row=>` ${row}`)); + } + if (state.confirm) footer.push(renderConfirm(state.confirm,{focused:true,color:colorLevel() !== 'none'}), ' Arrows choose; Enter confirms; PgUp/PgDn review; Esc cancel'); + else footer.push(' L launch NEW session; D delete; Up/Down review; Esc back'); + if (state.operation !== 'delete') footer.push(' Existing live session stays detached; reattach it with /resume.'); + } else { + body.push(...state.presets.map((preset,i)=>` ${state.selected === i ? '>' : ' '} ${preset.name} / ${preset.cwd}`)); + if (!state.presets.length) body.push(' No presets. N creates one.'); + footer.push(' N create; Up/Down choose; Enter inspect; Esc back'); + } + if (state.message) footer.push(` ${state.message}`); + const budget = Math.max(1,height-header.length-footer.length-2); + const start = state.detail ? Math.min(state.scroll,Math.max(0,body.length-budget)) : Math.max(0,state.selected-budget+1); + if (state.detail) state.scroll = start; + const rows = [...header,...body.slice(start,start+budget),...footer]; + return framePanel(rows.map(row=>truncateAnsi(row,columns)),columns).slice(0,Math.max(1,height)); +} diff --git a/src/session/SessionClient.ts b/src/session/SessionClient.ts index b290b70a..b026adac 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -18,6 +18,10 @@ export interface SessionClientEvents { prompt: [ShellMarker, StreamStamp]; /** zsh is about to run a command line (preexec). */ exec: [string, StreamStamp]; + /** Bounded, sanitized startup output while the shell has not yet reached its first prompt. */ + startup: [string]; + /** Input was not queued or written; the caller can restore it. */ + inputRejected: [data: string, submission: boolean]; /** The backlog sent after a reattach has been delivered. */ replayed: [{truncatedBytes: number}]; /** The managed shell ended. */ @@ -64,6 +68,10 @@ export interface AttachedSession { /** Journal the previous frontend kept for this session, and how far it got. */ journalId?: string; ackedSeq: number; + /** Latest bounded name snapshot, independent of journal acknowledgements. */ + knowledge?: string; + /** Set only while the shell has not reached its first prompt: its startup output so far. */ + startup?: string; } export interface SessionOptions { diff --git a/src/session/SessionPresets.ts b/src/session/SessionPresets.ts new file mode 100644 index 00000000..0d81ffa8 --- /dev/null +++ b/src/session/SessionPresets.ts @@ -0,0 +1,196 @@ +import {createHash, randomUUID} from 'node:crypto'; +import {readFileSync, writeFileSync, mkdirSync, lstatSync, statSync, realpathSync, renameSync, unlinkSync, linkSync, rmdirSync} from 'node:fs'; +import {isAbsolute, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; + +export interface SessionPreset {name: string; cwd: string; commands: string[]; acknowledged?: string} +interface PresetFile {version: 1; presets: SessionPreset[]} +export class PresetError extends Error {} +function ownerPid(lock: string): number | undefined | 'gone' { + try { + const stat = lstatSync(lock); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 32) return; + const text = readFileSync(lock, 'utf8'); + if (!/^\d{1,10}\n?$/u.test(text)) return; + const pid = Number.parseInt(text, 10); + return Number.isSafeInteger(pid) && pid > 0 && pid <= 0x7fffffff ? pid : undefined; + } catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'gone' : undefined; } +} +function processAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } +} +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const safeText = (value: unknown, max: number): value is string => typeof value === 'string' && value.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/u.test(value); +export function validatePreset(value: unknown): SessionPreset { + if (!object(value) || typeof value.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/u.test(value.name) || value.name.trim() !== value.name) throw new PresetError('Invalid preset name. Use 1–64 letters, numbers, spaces, _ or -.'); + if (!safeText(value.cwd,4096) || !isAbsolute(value.cwd) || /[\r\n\t]/u.test(value.cwd)) throw new PresetError('Preset cwd must be an absolute directory path.'); + if (!Array.isArray(value.commands) || value.commands.length > 16 || !value.commands.every(command => safeText(command,4096) && command.trim().length > 0 && !command.includes('\r')) || Buffer.byteLength(value.commands.join(''),'utf8') > 16384) throw new PresetError('Invalid startup commands (maximum 16 commands / 16 KiB).'); + if (value.acknowledged !== undefined && (typeof value.acknowledged !== 'string' || !/^[a-f0-9]{64}$/u.test(value.acknowledged))) throw new PresetError('Malformed preset acknowledgement.'); + return {name:value.name, cwd:value.cwd, commands:[...value.commands], ...(value.acknowledged ? {acknowledged:value.acknowledged as string} : {})}; +} +export function presetDigest(preset: SessionPreset): string { + return createHash('sha256').update(JSON.stringify([preset.cwd,preset.commands])).digest('hex'); +} +export function presetNeedsAcknowledgement(preset: SessionPreset): boolean { return preset.acknowledged !== presetDigest(preset); } +export function validatePresetCwd(preset: SessionPreset): void { + try { if (statSync(preset.cwd).isDirectory()) return; } catch { /* missing/inaccessible */ } + throw new PresetError('Preset cwd is missing or is not an accessible directory.'); +} +export function presetCommands(preset: SessionPreset): string[] { + return [`cd -- '${preset.cwd.replace(/'/gu,"'\\''")}'`, ...preset.commands]; +} + +/** Additive versioned sidecar: never rewrites config.json or captures session/env/history. */ +export class SessionPresetStore { + readonly path: string; + constructor(readonly directory = nmshConfigDirectory()) { this.path = join(directory,'presets.json'); } + private read(): PresetFile { + let source: string; + try { + const stat = lstatSync(this.path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024) throw new PresetError('Preset storage is not a supported regular file.'); + source = readFileSync(this.path,'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {version:1,presets:[]}; + throw error instanceof PresetError ? error : new PresetError('Could not read preset storage.'); + } + try { + const parsed: unknown = JSON.parse(source); + if (!object(parsed) || parsed.version !== 1 || !Array.isArray(parsed.presets) || parsed.presets.length > 100) throw new Error(); + const presets = parsed.presets.map(validatePreset); + if (new Set(presets.map(preset=>preset.name)).size !== presets.length) throw new Error(); + return {version:1,presets}; + } catch { throw new PresetError('Malformed or unsupported preset storage; existing file was preserved.'); } + } + list(): SessionPreset[] { return this.read().presets; } + get(name: string): SessionPreset { + const preset = this.list().find(item=>item.name === name); + if (!preset) throw new PresetError('Preset not found.'); + return preset; + } + private mutate(change: (file: PresetFile)=>void): void { + mkdirSync(this.directory,{recursive:true,mode:0o700}); + const lock = `${this.path}.lock`, temporary = `${this.path}.${randomUUID()}.tmp`; + this.acquire(lock); + try { + const original = this.diskContents(); + const file = this.read(); + if (this.diskContents() !== original) throw new PresetError('Preset storage changed; retry after inspecting it.'); + change(file); + if (file.presets.length > 100) throw new PresetError('Preset limit reached (100).'); + const contents = JSON.stringify(file,null,2)+'\n'; + if (Buffer.byteLength(contents,'utf8') > 256 * 1024) throw new PresetError('Preset storage limit reached (256 KiB); existing presets were preserved.'); + writeFileSync(temporary,contents,{flag:'wx',mode:0o600}); + this.read(); // Refuse symlinks or a malformed intervening replacement. + if (this.diskContents() !== original) throw new PresetError('Preset storage changed; retry after inspecting it.'); + renameSync(temporary,this.path); + } finally { + try { unlinkSync(temporary); } catch { /* no staged file */ } + unlinkSync(lock); + } + } + /** + * Take the cross-process lock the way TranscriptStore.withLock does: the owner pid is written to a private + * file that is hard-linked into place, so the lock never exists without an owner. A lock whose owner process + * is gone is re-inspected under an exclusive recovery guard and removed; a live, reused or unreadable owner is never displaced. + */ + private acquire(lock: string): void { + const mine = `${lock}.${randomUUID()}`; + writeFileSync(mine, `${process.pid}\n`, {flag: 'wx', mode: 0o600}); + const deadline = Date.now() + 1000; + try { + for (;;) { + try { linkSync(mine, lock); return; } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw new PresetError(`Preset storage is not writable (${lock}).`); + } + const owner = ownerPid(lock); + if (owner === 'gone') continue; // Released between our link attempt and the read. + if (owner !== undefined && !processAlive(owner)) { + // Every recoverer must hold this guard BEFORE inspecting/removing the + // stale instance. A competing actor may already have replaced it. + // An abandoned guard fails closed; recovering it by read-then-remove + // would merely move the same race to another filename. + const recovery = `${lock}.recovery`; + let claimed = false; + try { + mkdirSync(recovery, {mode: 0o700}); + claimed = true; + const current = ownerPid(lock); + if (typeof current === 'number' && !processAlive(current)) unlinkSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' && (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new PresetError(`Could not recover preset lock (${lock}); inspect ${recovery}.`); + } + } finally { if (claimed) rmdirSync(recovery); } + if (Date.now() >= deadline) throw new PresetError(`Preset storage is busy: inspect ${lock} and ${recovery}. Remove them only if no NMSh process is running.`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); + continue; + } + if (owner === undefined || Date.now() >= deadline) { + throw new PresetError(`Preset storage is busy: ${lock} is held by ${owner === undefined ? 'an unreadable owner' : `process ${owner}`}. Remove it only if no NMSh process is running.`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); + } + } finally { + try { unlinkSync(mine); } catch { /* already gone */ } + } + } + private diskContents(): string | undefined { + try { return readFileSync(this.path,'utf8'); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw new PresetError('Could not read preset storage.'); } + } + create(value: Pick): SessionPreset { + const preset = validatePreset({name:value.name,cwd:value.cwd,commands:value.commands}); + validatePresetCwd(preset); + this.mutate(file=>{ + if (file.presets.some(item=>item.name === preset.name)) throw new PresetError('Duplicate preset name.'); + file.presets.push(preset); + }); + return preset; + } + delete(name: string): void { + this.mutate(file=>{ + if (!file.presets.some(item=>item.name === name)) throw new PresetError('Preset not found.'); + file.presets = file.presets.filter(item=>item.name !== name); + }); + } + acknowledge(reviewed: SessionPreset): SessionPreset { + let acknowledged: SessionPreset | undefined; + this.mutate(file=>{ + const current = file.presets.find(item=>item.name === reviewed.name); + if (!current || presetDigest(current) !== presetDigest(reviewed)) throw new PresetError('Preset changed; inspect and acknowledge it again.'); + validatePresetCwd(current); + current.acknowledged = presetDigest(current); + acknowledged = current; + }); + return acknowledged!; + } +} + +/** One visible ordinary submission per real prompt. Nothing is replayed on attach. */ +export class PresetStartup { + private queue: string[]; + private submitted = 0; + active = true; + constructor(readonly preset: SessionPreset) { + if (presetNeedsAcknowledgement(preset)) throw new PresetError('Startup acknowledgement required.'); + validatePresetCwd(preset); + this.queue = presetCommands(preset); + } + cancel(): void { this.active = false; this.queue = []; } + next(exitCode: number, cwd: string): {command: string} | {error: string} | undefined { + if (!this.active) return; + let enteredCwd = cwd === this.preset.cwd; + if (this.submitted === 1 && !enteredCwd) { + try { enteredCwd = realpathSync(cwd) === realpathSync(this.preset.cwd); } catch { /* missing */ } + } + if (this.submitted > 0 && (exitCode !== 0 || (this.submitted === 1 && !enteredCwd))) { + this.cancel(); + return {error:'Preset startup stopped: command failed or requested cwd was not entered.'}; + } + const command = this.queue.shift(); + if (command === undefined) { this.active = false; return; } + this.submitted++; + return {command}; + } +} diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 9a33c4d2..b77df83b 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -19,7 +19,8 @@ export type ClientMessage = | {type: 'attach'; sessionId: string; columns: number; rows: number} | {type: 'detach'} | {type: 'list'} - | {type: 'input'; data: string} + /** submission=1 identifies a composer submission for rejection recovery; absent for raw input. */ + | {type: 'input'; data: string; submission?: number} | {type: 'resize'; columns: number; rows: number} /** Everything up to seq is durable in the frontend journal journalId. */ | {type: 'ack'; seq: number; journalId: string} @@ -28,11 +29,17 @@ export type ClientMessage = | {type: 'terminate'}; export type ServerMessage = - | {type: 'welcome'; version: number; service: string} + /** startupSafety=1 promises pre-ready input isolation, bounded rejection and startup state reporting. */ + | {type: 'welcome'; version: number; service: string; startupSafety?: number} | {type: 'error'; code: string; message: string} | {type: 'created'; sessionId: string; pid: number} | {type: 'attached'; sessionId: string; pid: number; cwd: string; fullscreen: number; modes?: string; running?: string; runningSince?: number; - journalId?: string; ackedSeq: number} + journalId?: string; ackedSeq: number; knowledge?: string; + /** Present only while the shell has not reached its first prompt: the sanitized, bounded tail of its startup output. */ + startup?: string} + /** Startup output of a shell still blocked or slow before its first prompt (bounded, sanitized, coalesced). */ + | {type: 'startup'; output: string} + | {type: 'input-rejected'; data: string; submission?: number} | {type: 'detached'; sessionId: string} | {type: 'sessions'; sessions: SessionInfo[]} /** @@ -42,7 +49,7 @@ export type ServerMessage = */ | {type: 'output'; data: string; seq?: number; at?: number} | {type: 'exec'; command: string; seq: number; at: number; historyAllowed?: number} - | {type: 'prompt'; exitCode: number; cwd: string; seq?: number; at?: number} + | {type: 'prompt'; exitCode: number; cwd: string; knowledge?: string; seq?: number; at?: number} /** End of the backlog sent after attach. */ | {type: 'replayed'; truncatedBytes: number} | {type: 'killed'; sessionId: string} @@ -99,21 +106,23 @@ const SHAPES: Record = { attach: {sessionId: 'string', columns: 'int', rows: 'int'}, detach: {}, list: {}, - input: {data: 'string'}, + input: {data: 'string', submission: 'int?'}, resize: {columns: 'int', rows: 'int'}, ack: {seq: 'int', journalId: 'string'}, kill: {sessionId: 'string'}, terminate: {}, - welcome: {version: 'int', service: 'string'}, + welcome: {version: 'int', service: 'string', startupSafety: 'int?'}, error: {code: 'string', message: 'string'}, created: {sessionId: 'string', pid: 'int'}, attached: {sessionId: 'string', pid: 'int', cwd: 'string', fullscreen: 'int', modes: 'string?', running: 'string?', runningSince: 'int?', - journalId: 'string?', ackedSeq: 'int'}, + journalId: 'string?', ackedSeq: 'int', knowledge: 'string?', startup: 'string?'}, + startup: {output: 'string'}, + 'input-rejected': {data: 'string', submission: 'int?'}, detached: {sessionId: 'string'}, sessions: {sessions: 'sessions'}, output: {data: 'string', seq: 'int?', at: 'int?'}, exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?'}, - prompt: {exitCode: 'int', cwd: 'string', seq: 'int?', at: 'int?'}, + prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, exit: {exitCode: 'int', signal: 'int?'}, diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index ccd42536..ed0a458a 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -36,6 +36,7 @@ interface ManagedSession { backlog: StreamBacklog; /** What the foreground program's own output says: recency, title, attention, last exit. */ evidence: SessionEvidence; + knowledge?: string; } export {AlternateScreenTracker} from './TerminalModes.js'; @@ -60,7 +61,8 @@ function toMessage(event: BacklogEvent): ServerMessage { case 'output': return {type: 'output', data: event.data, seq: event.seq, at: event.at}; case 'exec': return {type: 'exec', command: event.command, seq: event.seq, at: event.at, ...(event.historyAllowed === undefined ? {} : {historyAllowed: event.historyAllowed})}; - case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at}; + case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, seq: event.seq, at: event.at, + ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge})}; } } @@ -165,7 +167,7 @@ export class SessionService { return; } greeted = true; - send({type: 'welcome', version: PROTOCOL_VERSION, service: SERVICE_NAME}); + send({type: 'welcome', version: PROTOCOL_VERSION, service: SERVICE_NAME, startupSafety: 1}); continue; } switch (message.type) { @@ -194,7 +196,9 @@ export class SessionService { send({type: 'attached', sessionId: info.id, pid: info.pid, cwd: info.cwd, fullscreen: session.screen.ownsTerminal ? 1 : 0, ...(session.screen.ownsTerminal && session.screen.restoreSequence() ? {modes: session.screen.restoreSequence()} : {}), ...(info.running ? {running: info.running, runningSince: info.runningSince} : {}), - ...(backlog.journalId ? {journalId: backlog.journalId} : {}), ackedSeq: backlog.ackedSeq}); + ...(backlog.journalId ? {journalId: backlog.journalId} : {}), ackedSeq: backlog.ackedSeq, + ...(session.knowledge === undefined ? {} : {knowledge: session.knowledge}), + ...(session.shell.isReady ? {} : {startup: session.shell.startupTail() ?? ''})}); // Everything the journal does not have yet, then the live stream continues. const missed = backlog.events(); for (const event of missed) send(toMessage(event)); @@ -213,7 +217,15 @@ export class SessionService { case 'list': send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session))}); break; - case 'input': owned?.evidence.onInput(); owned?.shell.write(message.data); break; + case 'input': + owned?.evidence.onInput(); + if (owned) { + const rejected = (data: string, submission: boolean) => send({type: 'input-rejected', data, submission: submission ? 1 : 0}); + owned.shell.once('inputRejected', rejected); + owned.shell.write(message.data, message.submission === 1); + owned.shell.off('inputRejected', rejected); + } + break; case 'resize': if (owned) { owned.resizes += 1; this.resize(owned, message.columns, message.rows, send); } break; @@ -303,6 +315,7 @@ export class SessionService { if (kept) emit({kind: 'output', seq: ++session.seq, at, data: kept}, {type: 'output', data, seq: session.seq, at}); else session.controller?.({type: 'output', data}); }); + shell.on('startup', output => session.controller?.({type: 'startup', output})); shell.on('exec', (command, historyAllowed) => { const at = Date.now(); session.running = {command, since: at}; @@ -310,12 +323,14 @@ export class SessionService { emit({kind: 'exec', seq: ++session.seq, at, command, ...(historyAllowed === undefined ? {} : {historyAllowed})}); }); shell.on('prompt', marker => { + session.knowledge = marker.knowledge; record.cwd = marker.cwd; session.running = undefined; session.idleSince = Date.now(); session.evidence.onPrompt(marker.exitCode); session.screen.reset(); - emit({kind: 'prompt', seq: ++session.seq, at: Date.now(), exitCode: marker.exitCode, cwd: marker.cwd}); + emit({kind: 'prompt', seq: ++session.seq, at: Date.now(), exitCode: marker.exitCode, cwd: marker.cwd, + ...(marker.knowledge === undefined ? {} : {knowledge: marker.knowledge})}); }); shell.on('exit', event => { this.sessions.delete(record.id); diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 22c52f58..8b9f9a6d 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -51,6 +51,10 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage if (message.type === 'error') { fail(`session service refused: ${message.message}`, message.code); return; } if (message.type === 'welcome') { if (message.version !== PROTOCOL_VERSION) { fail(`protocol mismatch: service ${message.version}, client ${PROTOCOL_VERSION}`, 'version'); return; } + if ((first?.type === 'create' || first?.type === 'attach') && message.startupSafety !== 1) { + fail('service does not advertise startup safety; end its sessions and restart the service before attaching', 'startup-safety'); + return; + } if (first) send(first); continue; } @@ -154,8 +158,11 @@ export class SocketSessionClient extends EventEmitter imple private receive(message: ServerMessage): void { if (message.type === 'output') this.emit('data', message.data, {seq: message.seq, at: message.at}); - else if (message.type === 'prompt') this.emit('prompt', {exitCode: message.exitCode, cwd: message.cwd}, {seq: message.seq, at: message.at}); + else if (message.type === 'prompt') this.emit('prompt', {exitCode: message.exitCode, cwd: message.cwd, + ...(message.knowledge === undefined ? {} : {knowledge: message.knowledge})}, {seq: message.seq, at: message.at}); else if (message.type === 'exec') this.emit('exec', message.command, {seq: message.seq, at: message.at, historyAllowed: message.historyAllowed}); + else if (message.type === 'input-rejected') this.emit('inputRejected', message.data, message.submission === 1); + else if (message.type === 'startup') this.emit('startup', message.output); else if (message.type === 'replayed') this.emit('replayed', {truncatedBytes: message.truncatedBytes}); else if (message.type === 'exit') { this.finish(message.exitCode, message.signal); @@ -174,7 +181,7 @@ export class SocketSessionClient extends EventEmitter imple if (!this.socket.destroyed && this.socket.writable) this.socket.write(encodeMessage(message)); } - submit(command: string): void { this.send({type: 'input', data: `${command}\r`}); } + submit(command: string): void { this.send({type: 'input', data: `${command}\r`, submission: 1}); } write(data: string): void { this.send({type: 'input', data}); } interrupt(): void { this.send({type: 'input', data: '\u0003'}); } endInput(): void { this.send({type: 'input', data: '\u0004'}); } diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index bc76fabf..cd6b0db9 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -5,7 +5,7 @@ import {dirname} from 'node:path'; export type BacklogEvent = | {kind: 'output'; seq: number; at: number; data: string} | {kind: 'exec'; seq: number; at: number; command: string; historyAllowed?: number} - | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string}; + | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string; knowledge?: string}; /** Non-event spool records: journal acknowledgements, truncation and the shell's end. */ type SpoolRecord = BacklogEvent @@ -23,7 +23,7 @@ export interface BacklogLimits { export const DEFAULT_BACKLOG_LIMITS: BacklogLimits = {memoryBytes: 1024 * 1024, spoolBytes: 64 * 1024 * 1024}; function eventBytes(event: BacklogEvent): number { - return event.kind === 'output' ? event.data.length : event.kind === 'exec' ? event.command.length : event.cwd.length; + return event.kind === 'output' ? event.data.length : event.kind === 'exec' ? event.command.length : event.cwd.length + (event.knowledge?.length ?? 0); } function validEvent(value: unknown): value is SpoolRecord { @@ -33,7 +33,8 @@ function validEvent(value: unknown): value is SpoolRecord { switch (record.kind) { case 'output': return int('seq') && int('at') && typeof record.data === 'string'; case 'exec': return int('seq') && int('at') && typeof record.command === 'string'; - case 'prompt': return int('seq') && int('at') && int('exitCode') && typeof record.cwd === 'string'; + case 'prompt': return int('seq') && int('at') && int('exitCode') && typeof record.cwd === 'string' + && (record.knowledge === undefined || typeof record.knowledge === 'string' && Buffer.byteLength(record.knowledge) <= 65536); case 'ack': return int('seq') && typeof record.journalId === 'string'; case 'truncated': return int('bytes'); case 'exit': return int('exitCode') && int('at'); diff --git a/src/session/TerminalModes.ts b/src/session/TerminalModes.ts index a5007d47..97f6ef2a 100644 --- a/src/session/TerminalModes.ts +++ b/src/session/TerminalModes.ts @@ -1,10 +1,8 @@ // DECSET/DECRST 1049, 1047 and 47: the alternate-screen switches. const ALT_SCREEN = /\u001b\[\?(?:1049|1047|47)([hl])/g; -const DEC_MODE = /\u001b\[\?([\d;]+)([hl])/g; -const KEYPAD = /\u001b([=>])/g; -// Kitty keyboard protocol: push (CSI > flags u) and pop (CSI < n u). -const KITTY_PUSH = /\u001b\[>(\d*)u/g; -const KITTY_POP = /\u001b\[<\d*u/g; +// Process modes in wire order, including independent main/alternate keyboard stacks. +const MODE_SEQUENCE = /\u001b\[\?([\d;]+)([hl])|\u001b([=>])|\u001b\[([><])(\d{0,10})u/g; +const MAX_KEYBOARD_STACK = 32; /** DECCKM, cursor visibility, mouse protocols, focus events, bracketed paste. */ const TRACKED_MODES = new Set([1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004]); /** @@ -52,18 +50,22 @@ export class AlternateScreenTracker { return kept; } - reset(): void { + reset(screen: 'main' | 'alternate' = 'main'): void { this.active = false; this.interactive = false; this.carry = ''; this.modes.clear(); this.keypad = false; - this.kittyFlags = undefined; + this.modeCarry = ''; + this.keyboardScreen = screen; + this.keyboardStacks.main.length = 0; + this.keyboardStacks.alternate.length = 0; } private readonly modes = new Map(); private keypad = false; - private kittyFlags?: string; + private keyboardScreen: 'main' | 'alternate' = 'main'; + private readonly keyboardStacks = {main: [] as string[], alternate: [] as string[]}; private modeCarry = ''; /** @@ -75,23 +77,34 @@ export class AlternateScreenTracker { */ observeModes(data: string): void { const text = this.modeCarry + data; - for (const match of text.matchAll(DEC_MODE)) { - for (const param of match[1]!.split(';')) { - const mode = Number(param); - if (!TRACKED_MODES.has(mode)) continue; - this.modes.set(mode, match[2] === 'h'); - if (match[2] === 'h' && INPUT_MODES.has(mode)) this.interactive = true; + for (const match of text.matchAll(MODE_SEQUENCE)) { + if (match[1] !== undefined) { + for (const param of match[1].split(';')) { + const mode = Number(param); + if ([47, 1047, 1049].includes(mode)) this.keyboardScreen = match[2] === 'h' ? 'alternate' : 'main'; + if (!TRACKED_MODES.has(mode)) continue; + this.modes.set(mode, match[2] === 'h'); + if (match[2] === 'h' && INPUT_MODES.has(mode)) this.interactive = true; + } + } else if (match[3] !== undefined) this.keypad = match[3] === '='; + else { + const stack = this.keyboardStacks[this.keyboardScreen]; + if (match[4] === '>') { + if (stack.length >= MAX_KEYBOARD_STACK) stack.shift(); + stack.push(match[5] || '0'); + this.interactive = true; + } else { + const count = match[5] === '' ? 1 : Number(match[5]); + stack.splice(Math.max(0, stack.length - count)); + } } } - for (const match of text.matchAll(KEYPAD)) this.keypad = match[1] === '='; - for (const match of text.matchAll(KITTY_PUSH)) { - this.kittyFlags = match[1] || '1'; - this.interactive = true; - } - if (KITTY_POP.test(text)) this.kittyFlags = undefined; - KITTY_POP.lastIndex = 0; + // Retain only an incomplete sequence. Replaying a complete push would + // duplicate stack ownership every time the next output chunk arrived. const escape = text.lastIndexOf('\u001b'); - this.modeCarry = escape !== -1 && text.length - escape < 16 ? text.slice(escape) : ''; + const tail = escape < 0 ? '' : text.slice(escape); + this.modeCarry = tail.length < 16 && /^\u001b(?:\[(?:\?[\d;]*|[><]\d*)?)?$/u.test(tail) ? tail : ''; + } /** Sequences that put a fresh terminal into the app's current input modes. */ @@ -101,7 +114,18 @@ export class AlternateScreenTracker { if (mode === 25) { if (!on) sequence += '\u001b[?25l'; } else if (on) sequence += `\u001b[?${mode}h`; } if (this.keypad) sequence += '\u001b='; - if (this.kittyFlags) sequence += `\u001b[>${this.kittyFlags}u`; + for (const flags of this.keyboardStacks[this.keyboardScreen]) sequence += `\u001b[>${flags}u`; + return sequence; + } + + /** Remove only outstanding child pushes, on the screen that owns each stack. + * Always finish on alternate, where NMSh draws. Inherited entries are never flattened. + */ + releaseKeyboardSequence(): string { + let sequence = ''; + if (this.keyboardStacks.main.length) sequence += `\u001b[?1049l\u001b[<${this.keyboardStacks.main.length}u`; + sequence += '\u001b[?1049h'; + if (this.keyboardStacks.alternate.length) sequence += `\u001b[<${this.keyboardStacks.alternate.length}u`; return sequence; } diff --git a/src/session/runtimeDir.ts b/src/session/runtimeDir.ts index 1b659b6c..a3141d26 100644 --- a/src/session/runtimeDir.ts +++ b/src/session/runtimeDir.ts @@ -1,7 +1,7 @@ import {lstatSync, mkdirSync, readdirSync} from 'node:fs'; import {PROTOCOL_VERSION} from './SessionProtocol.js'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {isAbsolute, join} from 'node:path'; export const RUNTIME_DIR_ENV = 'NMSH_RUNTIME_DIR'; @@ -10,8 +10,19 @@ function uid(): number { } /** Per-user runtime directory. macOS TMPDIR is already per-user; the uid suffix covers a shared /tmp. */ -export function defaultRuntimeDir(env: NodeJS.ProcessEnv = process.env): string { - return env[RUNTIME_DIR_ENV] || join(tmpdir(), `nmsh-${uid()}`); +export function defaultRuntimeDir(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string { + if (env[RUNTIME_DIR_ENV]) return env[RUNTIME_DIR_ENV]; + const xdg = env.XDG_RUNTIME_DIR; + if (platform === 'linux' && xdg && isAbsolute(xdg)) { + const directory = join(xdg, 'nmsh'); + try { + const stat = lstatSync(xdg); + // XDG runtime roots must already be private, owned directories. Do not repair them. + if (stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid() + && (stat.mode & 0o077) === 0 && Buffer.byteLength(socketPathFor(directory)) <= 100) return directory; + } catch { /* A missing/unsafe root uses the existing private temporary fallback. */ } + } + return join(tmpdir(), `nmsh-${uid()}`); } /** diff --git a/src/shell/CommandCorrection.ts b/src/shell/CommandCorrection.ts new file mode 100644 index 00000000..59c05849 --- /dev/null +++ b/src/shell/CommandCorrection.ts @@ -0,0 +1,100 @@ +import {access, readdir, stat} from 'node:fs/promises'; +import {constants} from 'node:fs'; +import {delimiter, isAbsolute, join} from 'node:path'; +import {stripAnsi, truncateAnsi} from '../util/text.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {renderActionHelp, type UiAction} from '../ui/actions.js'; + +export interface CommandCorrection {correction: true; name: string; insertion: string; original: string; description: string} +export const CORRECTION_ACTIONS: readonly UiAction[] = [ + {id: 'insert', label: 'edit', keyLabel: 'Tab', kinds: ['complete']}, + {id: 'dismiss', label: 'dismiss', keyLabel: 'Esc', kinds: ['escape']}, +]; +const dangerous = new Set(['rm', 'rmdir', 'mv', 'dd', 'sudo', 'su', 'doas', 'chmod', 'chown', 'chgrp', 'kill', 'killall', + 'pkill', 'shutdown', 'reboot', 'halt', 'mkfs', 'fdisk', 'diskutil', 'truncate', 'unlink', 'eval', 'exec']); +const commandName = /^[a-zA-Z][a-zA-Z0-9_-]{2,31}$/u; + +/** One insertion/deletion/transposition; substitution is permitted only for longer words. */ +export function closeCommand(typed: string, known: string, countShortSubstitution = false): boolean { + if (typed === known || Math.abs(typed.length - known.length) > 1) return false; + if (typed.length === known.length) { + const differences = [...typed].map((letter, index) => letter === known[index] ? -1 : index).filter(index => index !== -1); + if (differences.length === 1) return countShortSubstitution || typed.length >= 5; + return differences.length === 2 && differences[1] === differences[0]! + 1 + && typed[differences[0]!] === known[differences[1]!] && typed[differences[1]!] === known[differences[0]!]; + } + const longer = typed.length > known.length ? typed : known; + const shorter = typed.length > known.length ? known : typed; + let index = 0; + while (index < shorter.length && longer[index] === shorter[index]) index++; + return longer.slice(index + 1) === shorter.slice(index); +} + +export function correctionTarget(input: string, names: readonly string[]): string | undefined { + if (!commandName.test(input) || names.includes(input)) return; + const matches = [...new Set(names)].filter(name => commandName.test(name) && closeCommand(input, name, true)); + if (matches.length !== 1 || !closeCommand(input, matches[0]!) || dangerous.has(matches[0]!)) return; + return matches[0]; +} + +export class CommandCorrectionService { + private cache?: {path: string; at: number; files: Map}; + constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + + async suggest(command: string, exitCode: number, output: string, signal?: AbortSignal): Promise { + // No quotes, substitutions, redirections, pipelines, assignments, multiline or leading-space private input. + const match = /^([a-zA-Z][a-zA-Z0-9_-]{2,31})(?:[ \t]+[a-zA-Z0-9_./:@%+=,-]+)*[ \t]*$/u.exec(command); + if (exitCode !== 127 || !match || signal?.aborted) return; + const typed = match[1]!; + const diagnostic = stripAnsi(output).split('\n').some(line => new RegExp(`^(?:zsh(?::[^:]+)*: |nmsh: )?command not found: ${typed}\\s*$`, 'u').test(line.trim())); + if (!diagnostic) return; + const files = await this.commandFiles(signal); + if (signal?.aborted) return; + const possible = [...files.keys()].filter(name => name === typed || closeCommand(typed, name, true)); + const executable: string[] = []; + for (const name of possible) { + if (signal?.aborted) return; + for (const file of files.get(name)!) { + try { + await access(file, constants.X_OK); + if (!(await stat(file)).isFile()) continue; + executable.push(name); break; + } catch { /* Missing, unreadable or non-executable entries are not candidates. */ } + } + } + const target = correctionTarget(typed, executable); + if (!target || signal?.aborted) return; + const insertion = target + command.slice(typed.length); + return {correction: true, original: command, insertion, name: insertion, + description: 'Correction · Tab edits · press Enter separately'}; + } + + private async commandFiles(signal?: AbortSignal): Promise> { + const path = this.env.PATH ?? ''; + if (this.cache?.path === path && Date.now() - this.cache.at < 60_000) return this.cache.files; + const files = new Map(); + let count = 0; + const directories = path.split(delimiter).filter(isAbsolute); + if (directories.length > 64) return files; + for (const directory of directories) { + if (signal?.aborted) return files; + try { + for (const entry of await readdir(directory, {withFileTypes: true})) { + if (++count > 20_000) return new Map(); // An incomplete namespace cannot justify a unique correction. + if (entry.isDirectory() || !commandName.test(entry.name)) continue; + const locations = files.get(entry.name) ?? []; + locations.push(join(directory, entry.name)); files.set(entry.name, locations); + } + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return new Map(); + } + } + if (!signal?.aborted) this.cache = {path, at: Date.now(), files}; + return files; + } +} + +export function renderCorrection(correction: CommandCorrection, columns: number): string { + return truncateAnsi(`${foreground(UI_COLORS.accent)}${GLYPHS.prompt} ${correction.name}\u001b[0m ${foreground(UI_COLORS.subtle)}Correction · ${stripAnsi(renderActionHelp(CORRECTION_ACTIONS))} · Enter separately\u001b[0m`, columns); +} diff --git a/src/shell/CommandInspector.ts b/src/shell/CommandInspector.ts new file mode 100644 index 00000000..14ae6a4b --- /dev/null +++ b/src/shell/CommandInspector.ts @@ -0,0 +1,12 @@ +import {inspectCommand, type InspectorContext} from './CommandKnowledge.js'; +import {truncateText} from '../util/text.js'; +export {inspectCommand}; + +/** Plain presentation is deterministic and safe under all glyph/color policies. */ +export function renderInspector(context: InspectorContext | undefined, width: number): string[] { + if (!context || width < 1) return []; + const title = `${context.kind === 'option' ? 'flag' : context.kind}: ${context.value}`; + if (width < 32) return [truncateText(`${title} - ${context.description}`, width)]; + return [truncateText(`Inspect ${title}`, width), + truncateText(`${context.description}${context.usage ? ` | ${context.usage}` : ''}`, width)]; +} diff --git a/src/shell/CommandKnowledge.ts b/src/shell/CommandKnowledge.ts new file mode 100644 index 00000000..a9bf78a2 --- /dev/null +++ b/src/shell/CommandKnowledge.ts @@ -0,0 +1,97 @@ +import {Highlighter} from '../input/Highlighter.js'; +import {graphemes} from '../input/inputLayout.js'; +import {completionLabel, type CompletionCandidate, type CompletionKind} from './completion.js'; +import type {CommandType} from './SemanticService.js'; + +export interface CommandKnowledge { + value: string; + kind: CompletionKind; + description: string; + usage?: string; +} + +/** Explicit local facts, shared by completion and inspection. No executable adapters. */ +const COMMANDS: Record = { + git: {description: 'Distributed version control', usage: 'git [options]', words: [ + {value: 'status', kind: 'subcommand', description: 'Show working tree and index status'}, + {value: 'diff', kind: 'subcommand', description: 'Show changes between commits, index and working tree'}, + {value: 'log', kind: 'subcommand', description: 'Show commit history'}, + {value: 'add', kind: 'subcommand', description: 'Stage file content for the next commit'}, + {value: 'commit', kind: 'subcommand', description: 'Record staged changes'}, + ]}, + rg: {description: 'Search files for a pattern', usage: 'rg [options] [path ...]', words: [ + {value: '--hidden', kind: 'option', description: 'Search hidden files and directories'}, + {value: '--glob', kind: 'option', description: 'Include or exclude paths matching a glob', usage: ''}, + {value: '--ignore-case', kind: 'option', description: 'Search case insensitively'}, + {value: '--files', kind: 'option', description: 'List files that would be searched'}, + {value: '--line-number', kind: 'option', description: 'Show line numbers'}, + ]}, + npm: {description: 'Node package manager', usage: 'npm [options]', words: [ + {value: 'run', kind: 'subcommand', description: 'Run a script from package.json', usage: '